Skip to content

MCP Apps support

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 their chat surface. Both mcp.ggui.ai and the OSS @ggui-ai/mcp-server implement the host-side and resource-side wire format so generative UIs appear directly in the chat instead of “click this link to open a browser tab”.

This page describes the protocol pieces ggui implements. For end-user setup, see Connect Claude Desktop.

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

  1. Returning structured data and hoping the host knows how to format it (no interactivity), or
  2. Returning a URL the user has to click out to (interactive but loses context — chat ↔ UI happens in two 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 small WebSocket channel carries data both ways (host → UI for live updates, UI → server for actions).

When the server is booted 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/session is served as a resource via resources/read. It’s a thin shell of HTML that loads the @ggui-ai/iframe-runtime bundle and connects to the WebSocket channel.
  3. Every ggui_push result carries _meta.ggui.bootstrapwsUrl + a short-TTL bootstrapToken + expiresAt + runtimeUrl. The iframe consumes the bootstrap, opens the WebSocket, and exchanges the bootstrap token for a longer-TTL sessionToken for reconnects.

On initialize, ggui returns:

{
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": false, "listChanged": false },
"experimental": {
"io.modelcontextprotocol/ui": {}
}
}
}

Hosts that recognise io.modelcontextprotocol/ui flip into inline-render mode. Hosts that don’t see it ignore the capability and fall back to opening renderUrl from the push result in an external browser.

Every UI-producing tool (today: ggui_push) declares a meta-resource on the result so the host knows where to load the UI from:

{
"content": [
{ "type": "text", "text": "Created session ses_abc123" }
],
"_meta": {
"ui": {
"resource": "ui://ggui/session",
"visibility": ["model"]
},
"ggui": {
"bootstrap": {
"wsUrl": "wss://mcp.ggui.ai/ws",
"renderBaseUrl": "https://mcp.ggui.ai/r/",
"runtimeUrl": "https://mcp.ggui.ai/_ggui/iframe-runtime.js",
"sessionId": "ses_abc123",
"bootstrapToken": "BTKN_…",
"expiresAt": 1735689600000
}
}
}
}

_meta.ui.visibility: ["model"] is the MCP Apps signal that this resource is a renderable UI surface. The host fetches ui://ggui/session once, sandboxes it in an iframe, and feeds it the _meta.ggui.bootstrap payload.

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

  1. Receive the bootstrap object from the host (via postMessage).
  2. Dynamically load runtimeUrl (the iframe-runtime bundle).
  3. Hand the bootstrap to the runtime, which opens the WebSocket and starts rendering.

The shell is intentionally minimal. The actual rendering logic — component resolution, contract validation, action dispatch — lives in @ggui-ai/iframe-runtime which the shell loads on demand. That keeps the shell payload tiny and lets the runtime version-bump independently.

The bootstrap token is short-lived (default 60s) and single-use. The iframe trades it for a long-lived sessionToken on the first WebSocket subscribe frame, then uses the session token for reconnects. This means:

  • Hosts can cache the resource document but the bootstrap is per-call (fresh token every push).
  • An iframe that loses connection reconnects with its session token without re-fetching the resource or re-running OAuth.
  • A leaked bootstrap token is useless 60 seconds later.

The bootstrap is HMAC-signed with a server-side bootstrapSecret. Multi-host deployments MUST pass a shared deterministic secret (typically from a secrets manager) so any pod accepts any other pod’s tokens.

import { createGguiServer } from "@ggui-ai/mcp-server";
const server = createGguiServer({
// ...
sessionChannel: true, // required — MCP Apps needs the WS channel
mcpApps: {
wsUrl: "wss://your-server.example.com/ws",
renderBaseUrl: "https://your-server.example.com/r/",
},
runtime: true, // serve the iframe-runtime bundle
bootstrapSecret: process.env.BOOTSTRAP_SECRET, // required for multi-pod
});

What each option does:

  • sessionChannel: true — mounts the channel-3 WebSocket at /ws. MCP Apps requires it; the iframe has nowhere to connect to without one.
  • mcpApps.wsUrl — the publicly-visible WebSocket URL written onto every bootstrap. Don’t pass ws://localhost:… if your server is internet-accessible — clients must be able to reach it.
  • mcpApps.renderBaseUrl — the public origin used when generating renderUrl in push responses. Hosts that don’t speak MCP Apps fall back to opening this URL in a browser.
  • 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.
  • bootstrapSecret — HMAC secret. If you don’t pass one, the server mints a random secret at boot — fine for single-process dev, wrong for multi-pod (each pod would reject the others’ tokens).

mcpApps requires sessionChannel: true — the factory throws at construction if you enable one without the other.

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 … + open renderUrl manually.

If your host doesn’t yet implement MCP Apps, the underlying session still works — you just lose inline rendering. The push response always includes a fully-functional renderUrl you can open in any browser.