Skip to content

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 will the hosted ggui server (mcp.ggui.ai, coming soon) — 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 the _meta["ai.ggui/render"] slicesessionId, 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, and lastSequence to seed replay cursors. The iframe consumes the slice, opens the WebSocket, and trades the wsToken for a longer-lived sessionToken for reconnects.

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 (ui://ggui/render/<id> on _meta.ui.resourceUri), 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) declares a meta-resource on the result so the host knows where to load the UI from:

{
"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"
}
}
}

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. Each per-call result stamps _meta.ui.resourceUri (the per-render URI) plus the _meta["ai.ggui/render"] slice. The host fetches ui://ggui/render (or the per-render form ui://ggui/render/<sessionId>) once, sandboxes it in an iframe, and forwards the _meta["ai.ggui/render"] slice to it.

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

  1. Receive the ai.ggui/render slice from the host (via postMessage).
  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.

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 (coming soon) will use 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 when connected to your self-hosted server (a hosted ggui connector is coming soon):

HostOAuthMCP AppsNotes
Claude DesktopYesYesInline rendering, full UX. (install)
claude.ai (web)YesYesSame as Desktop.
GooseYesYesInline rendering in TUI mode varies by terminal.
VS Code CopilotYesYesUI renders in a side panel.
CursorYesPartialOAuth works; MCP Apps support depends on version.
Generic MCP runtimeNoNoStatic 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 (ui://ggui/render/<id> on _meta.ui.resourceUri); resolve it with resources/read. There is no render-viewer URL the agent receives.