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.
What a server ships
Section titled “What a server ships”- A resource with a
ui://URI and mimeTypetext/html;profile=mcp-app: one self-contained HTML document. - A tool whose
_meta.ui.resourceUrinames it. That is the tool the host calls to open the app, and whose result the page receives. - Optionally
_meta.ui.csp.resourceDomainson the resource: thehttps://origins its images, fonts and media load from.
What the console does
Section titled “What the console does”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, withoutallow-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': nofetch, no XHR, no WebSocket.- Images, fonts and media only from
data:,blob:and the declaredresourceDomains. Each entry must be a barehttps://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-Siteother thansame-originornone) gets a page that says so and runs nothing. The console’s session cookie isSameSite=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, alocation =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.
What the page can do
Section titled “What the page can do”The host answers JSON-RPC over postMessage from its own frame and no other
window:
| Method | Answer |
|---|---|
ui/initialize | host info, and hostContext with the theme |
ping | {} |
ui/open-link | opens an http(s) URL in a new tab, without an opener |
tools/call | runs 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.
Console extensions
Section titled “Console extensions”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
←→↑↓andEsc, pressed anywhere on the host page and outside a text field, as a notificationx-strad/key {key: "ArrowLeft" | …}. An app should route it through its ownkeydownhandler. - Location. The frame has an opaque origin and cannot see the address bar.
The host appends its own query and
#hashto the frame’ssrc, so the app reads where a link or a refresh meant to land from its ownlocation.searchandlocation.hash:/apps/<slug>?view=week#item-3frames the app withview=weekin its query and#item-3as its hash. Two params are the host’s and are never forwarded:uri, the host’s own choice ofui://page, andtoken, the console’s sign-in token. The frame’s query also carries the host’suriahead of the app’s params, and the params are re-encoded on the way, so read them withURLSearchParamsrather 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 sendsx-strad/search {search}orx-strad/hash {hash}has it mirrored onto the address bar withhistory.replaceState(orpushState, 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 excepturi, which the host keeps, and it never writes atoken. 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 sendsx-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 withoutpushthe 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 ownlocation.hashin 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.
What it does not do
Section titled “What it does not do”- 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-contextand 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
sandboxdirective instead: an opaque origin with no network. Known limitations carries the difference.