MCP Apps support
read as.mdMCP Apps is the protocol extension that lets MCP servers ship interactive UI alongside structured data, and lets MCP hosts render those UIs inline in the chat surface. The OSS @ggui-ai/mcp-server implements the wire format on both sides — as does the hosted ggui server (mcp.ggui.ai) — so generative UIs render directly in chat instead of forcing a “click this link” detour to a browser tab.
This page documents the protocol pieces ggui implements. For end-user setup, see Connect Claude Desktop. For the underlying transport, see WebSocket protocol.
What MCP Apps adds
Section titled “What MCP Apps adds”Without MCP Apps, an MCP tool that produces UI has to choose between:
- Returning structured data and hoping the host formats it (no interactivity), or
- Returning a URL the user clicks out to (interactive, but chat and UI live in separate windows).
MCP Apps adds a third option: declare a UI resource alongside the tool result, the host sandboxes it in an iframe inside the chat, and a WebSocket channel carries data both ways — host to UI for live updates, UI to server for actions.
What ggui ships
Section titled “What ggui ships”When the server boots with mcpApps enabled, three things happen:
io.modelcontextprotocol/uiis advertised in the server’sinitializecapabilities (underexperimental). MCP-Apps-aware hosts read this and switch on inline rendering.ui://ggui/renderis served as a resource viaresources/read— a minimal HTML shell that loads@ggui-ai/iframe-runtimeand opens the WebSocket channel.- Every
ggui_rendertool result carries_meta.ui.resourceUri— theui://locator naming the view. Whether the result ALSO carries the_meta["ai.ggui/render"]bootstrap slice depends on the deployment posture; see Tool result shape below. Hosted ggui withholds it, self-hostedggui serveinlines it.
The bootstrap slice
Section titled “The bootstrap slice”Wherever it is delivered — on the tool result, or inlined by a per-render shell — the _meta["ai.ggui/render"] slice is the same shape: sessionId, appId, runtimeUrl, wsUrl, a short-TTL wsToken, and expiresAt (ISO 8601 string) as top-level fields, alongside capability + render-state fields, plus theme fields (themeId, themeMode, theme — a validated --ggui-* CSS-variable overlay the iframe applies at :root), a pollingUrl fallback for WS-blocked environments, lastSequence to seed replay cursors, and epoch. The iframe consumes the slice, opens the WebSocket, and trades the wsToken for a longer-lived sessionToken for reconnects.
epoch is the history epoch this mount belongs to, and it is load-bearing rather than decorative. ggui_render mints epoch 0; each ggui_update result carries its new epoch; a pinned #N read carries N; absent reads as 0. The iframe records it at boot as its OWN epoch, and when a later props_update frame arrives carrying a HIGHER epoch, this mount has been superseded and freezes instead of applying the new props. A custom runtime that ignores epoch will happily repaint a stale history card with state that belongs to a newer one — the exact bug the freeze latch exists to prevent.
Capability declaration
Section titled “Capability declaration”On initialize, ggui returns:
{ "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": false, "listChanged": false }, "experimental": { "io.modelcontextprotocol/ui": {} } }}Hosts that recognize io.modelcontextprotocol/ui flip into inline-render mode. Hosts that don’t simply ignore the capability and skip inline rendering — the render is still delivered as a resource, addressed by _meta.ui.resourceUri (see Render locator grammar), but without MCP-Apps support there is nothing to mount it. There is no agent-returned URL to open instead.
Tool result shape
Section titled “Tool result shape”Every UI-producing tool (today: ggui_render) stamps a locator on the result so the host knows where to load the UI from. There are two postures, and a host builder must handle the one their server runs.
Read-plane-only — what https://mcp.ggui.ai returns
Section titled “Read-plane-only — what https://mcp.ggui.ai returns”Hosted ggui runs read-plane-only. The result publishes the durable identity and nothing else: no ai.ggui/render slice, no wsToken. No bootstrap token is minted for a slice nobody receives.
{ "content": [{ "type": "text", "text": "Created render render_abc123" }], "structuredContent": { "sessionId": "render_abc123", "resourceUri": "ui://ggui/render/render_abc123/bp_9f2c" // … plus action / contractHash / blueprintId / variantKey / cache }, "_meta": { "ui": { "resourceUri": "ui://ggui/render/render_abc123/bp_9f2c" }, "ui/resourceUri": "ui://ggui/render/render_abc123/bp_9f2c" }}The host MUST resolve the view by an authenticated resources/read on that locator. What comes back is a per-render self-contained shell — an HTML document that inlines the bootstrap slice synchronously on globalThis.__GGUI_META__, so the runtime reads it directly and never waits for a host relay. ui/resourceUri is the flat legacy alias of the same value, carried for hosts that read that slot.
The failure to watch for: a host that mounts the declaration-level static shell instead of the per-render locator gets no slice at all and fails with MISSING_META_GGUI_BOOTSTRAP. Read the locator, not the declaration.
Inlined bootstrap — the self-hosted ggui serve default
Section titled “Inlined bootstrap — the self-hosted ggui serve default”Without the withhold posture, the result carries the bootstrap slice inline and a host may mount directly, with no further round-trip:
{ "content": [{ "type": "text", "text": "Created render render_abc123" }], "_meta": { "ui": { "resourceUri": "ui://ggui/render/render_abc123" }, "ai.ggui/render": { "sessionId": "render_abc123", "appId": "app_abc", "runtimeUrl": "https://your-server.example.com/_ggui/iframe-runtime.js", "wsUrl": "wss://your-server.example.com/ws", "wsToken": "btkn_…", "expiresAt": "2099-01-01T00:00:00.000Z", "epoch": 0 } }}Any deployment can adopt the read-plane-only posture by setting withholdResultMeta on the server factory. The identity pointer stays in both postures — the identity IS the pointer, and spec-canonical hosts mount from it.
The tool declaration (returned by tools/list) is what carries _meta.ui.visibility: ["model"] — the MCP Apps signal that this tool ships a renderable UI surface. Per-call results stamp the locator, as above.
Render locator grammar
Section titled “Render locator grammar”ui://ggui/render/<sessionId>[/<blueprintKey>][#<epoch>]| Segment | Presence | Meaning |
|---|---|---|
sessionId |
Always. | The render to resolve. |
blueprintKey |
On every render since the resume contract landed. | Lets a read fall back to a registry-only mount (the original card with default props) when the render row is gone, instead of returning a typed failure. The segment is named for its domain; the record field it comes from is contractKey. |
#<epoch> |
On ggui_update results; absent means the live head. |
Pins the immutable epoch-N record. Pinned reads serve identical content forever (conformance-pinned); the bare URI tracks the session’s current state. |
Both the single-segment and two-segment shapes resolve. The single-segment form is kept registered so pre-resume-contract cards still in chat history rehydrate — parse for the optional second segment rather than assuming one is absent.
The shell at ui://ggui/render
Section titled “The shell at ui://ggui/render”Reading the resource returns a small HTML document — paper-themed, full-bleed, no chrome — whose only job is:
- Obtain the
ai.ggui/renderslice — from the host viapostMessage, or, for a per-render self-contained shell, from theglobalThis.__GGUI_META__global it inlines synchronously before the bundle loads. - Dynamically load
runtimeUrl(the iframe-runtime bundle). - Hand the slice to the runtime, which opens the WebSocket and starts rendering.
The shell is intentionally minimal. The actual rendering work — component resolution, contract validation, action dispatch, gadget loading — lives in @ggui-ai/iframe-runtime, which the shell loads on demand. This keeps the shell payload tiny and lets the runtime version-bump independently of host caches.
Three shell registrations
Section titled “Three shell registrations”Reading traffic against a real server, you will see more than one shell URI. There are three registrations, and they all serve the same current shell:
| URI | Who asks for it |
|---|---|
ui://ggui/render |
Grandfathered sessions and hosts that read the bare URI directly. |
ui://ggui/render/rt-<sha12> |
The content-addressed twin. Tool declarations advertise THIS URI, not the bare one. |
ui://ggui/render/rt-<any> |
Stale-hash grandfather template — serves the current shell under any prior hash. |
The hash covers the full served representation: the shell bytes AND the _meta.ui.csp declaration the response carries. Hosts cache the prefetched shell keyed on the resource URI, so the URI must change exactly when the content or the CSP policy does — and never otherwise. The meta has to be in the hash input because hosts cache the declaration alongside the bytes: a policy-only change would otherwise ship under an old URI and never reach cached frames.
The grandfather template exists because hosts snapshot tool declarations. After a shell-changing deploy they keep asking for the previous deploy’s rt- URI; without the template that read falls through to the per-session template (a bare rt-abc… segment parses as a sessionId), resolves no render, and the host shows a mount failure.
The _meta.ui.csp declaration
Section titled “The _meta.ui.csp declaration”Shell responses carry a _meta.ui.csp block with connectDomains. Hosts derive the frame’s connect-src from it — claude.ai applies a restrictive default (connect-src 'none') unless the resource declares otherwise, which blocks the cross-origin runtime <script> load and every rung of the live-channel failover ladder. Declare the public base URL, the runtime URL, and the live-channel origins; declaring only the runtime CDN origin is the known way to get a frame that boots and then cannot connect.
Note the limit, honestly: not every host honors the declaration. Claude Desktop’s iframe sandbox does not honor it for wss://, which is why the transport fail-fasts out of its reconnect ladder and pivots to polling — see Reconnection.
Bootstrap token exchange
Section titled “Bootstrap token exchange”The wsToken is short-lived (default 180s) and reusable within its TTL, so a transient WebSocket drop reconnects without a fresh handshake. The iframe trades it for a longer-lived sessionToken (default 4h) on the first wsToken-authed subscribe frame, then uses the session token (sessionToken) for reconnects. After the wsToken expires, the iframe swaps the envelope via ggui_runtime_refresh_ws_token (within the refresh window) or re-bootstraps. Consequences:
- Hosts can cache the resource document, but the bootstrap is per-call — every render mints a fresh token.
- An iframe that loses connection reconnects with its session token (
sessionToken) without re-fetching the resource or re-running OAuth. - A leaked bootstrap token is useless after the TTL expires.
The bootstrap is HMAC-signed with a server-side wsTokenSecret. Multi-pod deployments MUST share a deterministic secret (typically from a secrets manager) so any pod accepts any other pod’s tokens. The handshake details are documented in Bootstrap handshake.
Self-hosted: enabling MCP Apps in your own server
Section titled “Self-hosted: enabling MCP Apps in your own server”import { createGguiServer } from "@ggui-ai/mcp-server";
const server = createGguiServer({ // ... renderChannel: true, // required — MCP Apps needs the WS channel mcpApps: { wsUrl: "wss://your-server.example.com/ws", }, runtime: true, // serve the iframe-runtime bundle wsTokenSecret: process.env.WS_TOKEN_SECRET, // required for multi-pod});For local dev, wsUrl: "ws://127.0.0.1:6781/ws" is the conventional loopback URL. Hosted ggui uses wss://mcp.ggui.ai/ws.
What each option does:
renderChannel: true— mounts the live-channel WebSocket at/ws. MCP Apps requires it; the iframe has nowhere to connect without one.mcpApps.wsUrl— the publicly-reachable WebSocket URL written onto every bootstrap. Don’t shipws://localhost:…to internet-accessible servers — clients must be able to reach it.runtime: true(default whenmcpAppsis on) — mounts the iframe-runtime bundle at/_ggui/iframe-runtime.js. Passruntime: { url: "https://your-cdn/…" }to point at an externally-hosted bundle.wsTokenSecret— HMAC secret. If omitted, the server mints a random secret at boot — fine for single-process dev, wrong for multi-pod (pods would reject each other’s tokens).
mcpApps requires renderChannel: true; the factory throws at construction if you enable one without the other.
Compatibility matrix
Section titled “Compatibility matrix”Host capabilities below describe what each MCP host supports, whether connected to your self-hosted server or the hosted ggui connector:
| Host | OAuth | MCP Apps | Notes |
|---|---|---|---|
| Claude Desktop | Yes | Yes | Inline rendering, full UX. (install) |
| claude.ai (web) | Yes | Yes | Same as Desktop. |
| Goose | Yes | Yes | Inline rendering in TUI mode varies by terminal. |
| VS Code Copilot | Yes | Yes | UI renders in a side panel. |
| Cursor | Yes | Partial | OAuth works; MCP Apps support depends on version. |
| Generic MCP runtime | No | No | Static Authorization: Bearer …; no inline render — resolve the resourceUri resource yourself. |
If your host doesn’t yet implement MCP Apps, the underlying render still works — you just lose inline rendering. Each render is delivered as an MCP-Apps resource addressed by _meta.ui.resourceUri (see Render locator grammar); resolve it with resources/read. There is no render-viewer URL the agent receives.
Reference
Section titled “Reference”- MCP Apps protocol: https://modelcontextprotocol.io/extensions/apps/overview
- ggui server factory:
@ggui-ai/mcp-server - Iframe runtime:
@ggui-ai/iframe-runtime - Wire envelopes: Envelopes
- Glossary terms: gadget, tool, blueprint