Skip to content

Apps in the console

A server can ship its own page over MCP, and the console renders it at /apps/<slug>, behind the same Google sign-in as the rest of the console. The page is the server’s code, not strad’s. That is how a private server gets a UI without its source living in this repo.

This follows the MCP Apps extension, SEP-1865. /mcp already relays tools/list, resources/read and tools/call, so any MCP Apps client pointed at /mcp?servers=<slug> can render the same page. The console is one more such client.

  • A resource with a ui:// URI and mimeType text/html;profile=mcp-app: one self-contained HTML document.
  • A tool whose _meta.ui.resourceUri names it. That is the tool the host calls to open the app, and whose result the page receives.
  • Optionally _meta.ui.csp.resourceDomains on the resource: the https:// origins its images, fonts and media load from.

GET /apps/<slug> checks the signed-in human’s entitlement to the slug, the same check the playground makes. It finds the tool that carries _meta.ui.resourceUri (the deprecated flat ui/resourceUri is also read) and renders a thin bar plus a sandboxed iframe. A server whose app resource no tool names is still rendered, just without an initiating tool call.

The iframe loads GET /apps/<slug>/frame, which does a resources/read and serves the HTML with a Content-Security-Policy of its own:

  • sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox, without allow-same-origin. The document gets an opaque origin, whether it is framed or opened directly. It cannot read the console’s cookies or storage, or call a console route as the signed-in human.
  • connect-src 'none': no fetch, no XHR, no WebSocket.
  • Images, fonts and media only from data:, blob: and the declared resourceDomains. Each entry must be a bare https:// origin, so one carrying a ; or a path is dropped rather than spliced into the header.

Once the page sends ui/notifications/initialized, the host calls the declaring tool and sends the page ui/notifications/tool-input and ui/notifications/tool-result, as the spec’s lifecycle does. That opening call is host-initiated, so it is not bound by the tool’s visibility. Two guards keep it from being a way to run a tool by link:

  • It happens only for a declaring tool annotated readOnlyHint: true. A tool that writes waits for the page to ask.
  • A GET of /apps/<slug> that another site started (Sec-Fetch-Site other than same-origin or none) gets a page that says so and runs nothing. The console’s session cookie is SameSite=Lax, so it rides on a cross-site top-level GET, and this is the check that makes that harmless. That page’s Open button is a same-origin link, so one click opens the app.

The bridge also belongs to the FIRST document the frame loads. Every document the frame could hold has the same opaque origin, so the window is the only identity. Three layers keep a second document out:

  • The host page’s own CSP is frame-ancestors 'none'; frame-src <host>/apps/<slug>/frame. Nothing may frame the host, and the frame may load exactly that one path, so a link, a location = or a meta refresh to anywhere else is refused before any of its script runs.
  • The served page gets <base target="_blank">, which sends plain links out as new tabs.
  • If the frame loads a second document anyway (its own frame route again), the host removes the frame and the bridge with it.

The host answers JSON-RPC over postMessage from its own frame and no other window:

MethodAnswer
ui/initializehost info, and hostContext with the theme
ping{}
ui/open-linkopens an http(s) URL in a new tab, without an opener
tools/callruns one of this server’s tools

Notifications the host acts on: ui/notifications/initialized, and the x-strad/ ones below.

A tools/call is POSTed to /apps/<slug>/tools/call. It is the playground’s call route under another surface name, so it gets the same locks: the human’s own entitlement, the slug’s tools: policy on the action, no console-login-token sessions, and a JSON-only body a cross-site form cannot send. Every name is re-namespaced onto this slug, so a page cannot reach another server. A tool whose _meta.ui.visibility leaves out "app" is refused, as the spec requires.

So an app gives a human nothing the playground does not already give them over the same server. It is a better face on the same authority.

They are prefixed x-strad/ because the spec does not define them. An app that ignores them still works, and other hosts never send them.

  • Keys. Key presses go to whichever document has focus, and a click on the console bar would take it from the app. So the host focuses the frame on load and forwards ← → ↑ ↓ and Esc, pressed anywhere on the host page and outside a text field, as a notification x-strad/key {key: "ArrowLeft" | …}. An app should route it through its own keydown handler.
  • Location. The frame has an opaque origin and cannot see the address bar. The host appends its own query and #hash to the frame’s src, so the app reads where a link or a refresh meant to land from its own location.search and location.hash: /apps/<slug>?view=week#item-3 frames the app with view=week in its query and #item-3 as its hash. Two params are the host’s and are never forwarded: uri, the host’s own choice of ui:// page, and token, the console’s sign-in token. The frame’s query also carries the host’s uri ahead of the app’s params, and the params are re-encoded on the way, so read them with URLSearchParams rather than as raw text. Treat both as untrusted input: anyone can write a link, and all that stands between it and your app is the Open click a link from another site gets. An app that sends x-strad/search {search} or x-strad/hash {hash} has it mirrored onto the address bar with history.replaceState (or pushState, for a hash that asks for an entry — see History), so a filter or a selection is a link someone can share. A mirrored search replaces every param except uri, which the host keeps, and it never writes a token. The Open prompt carries the hash over too.
  • History. Back and Forward belong to the host page. The frame cannot keep its own: in Chromium, traversing an entry the frame pushed, or the host setting the frame’s #hash, reloads an opaque-origin frame, and the host closes an app whose frame loads twice. So an app that wants Back sends x-strad/hash {hash, push: true} for a route change that should be a step — opening an item, switching views — and the host pushes an entry instead of replacing one. It pushes only while the page has transient user activation (a click in the frame counts), so an app cannot fill the tab’s history on its own and its first report on load is never an entry. A hash the address bar already shows is never a second entry either, and without push the hash replaces the current entry, which suits paging from one item to the next. On Back or Forward the host sends the restored hash down as a notification, x-strad/hash {hash}, in the address bar’s encoding. The app should go there without reloading, keep its own location.hash in step, and report the route as usual: that report matches the address bar and adds nothing, and a late report of a hash Back has already moved past is dropped. Only the hash has history. The query always replaces, and after Back the address bar keeps the app’s latest query rather than the older entry’s.
  • No model. The console is a deterministic client: it calls the declaring tool on load and whatever the page asks for after that. ui/message, ui/update-model-context and the other methods that exist for an LLM host answer “not supported”.
  • No separate-origin sandbox proxy. The spec describes a sandbox proxy iframe on a different origin for web hosts. The console gets its isolation from the CSP sandbox directive instead: an opaque origin with no network. Known limitations carries the difference.