Skip to content
Sneak peek — you found hosted ggui early · official launch soon

MCP Apps support

read as .md

MCP 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.

Without MCP Apps, an MCP tool that produces UI has to choose between:

  1. Returning structured data and hoping the host formats it (no interactivity), or
  2. 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.

When the server boots with mcpApps enabled, three things happen:

  1. io.modelcontextprotocol/ui is advertised in the server’s initialize capabilities (under experimental). MCP-Apps-aware hosts read this and switch on inline rendering.
  2. ui://ggui/render is served as a resource via resources/read — a minimal HTML shell that loads @ggui-ai/iframe-runtime and opens the WebSocket channel.
  3. Every ggui_render tool result carries _meta.ui.resourceUri — the ui:// 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-hosted ggui serve inlines it.

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.

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.

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.

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.

Reading the resource returns a small HTML document — paper-themed, full-bleed, no chrome — whose only job is:

  1. Obtain the ai.ggui/render slice — from the host via postMessage, or, for a per-render self-contained shell, from the globalThis.__GGUI_META__ global it inlines synchronously before the bundle loads.
  2. Dynamically load runtimeUrl (the iframe-runtime bundle).
  3. 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.

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.

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.

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 ship ws://localhost:… to internet-accessible servers — clients must be able to reach it.
  • runtime: true (default when mcpApps is on) — mounts the iframe-runtime bundle at /_ggui/iframe-runtime.js. Pass runtime: { 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.

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.