Skip to content

Rendering Prefab UIs in MCP Apps Hosts

How to make an MCP server return interactive $prefab UIs that render inside any MCP Apps host — VS Code Copilot Chat, Claude Desktop, ChatGPT, and others.

This guide documents the MCP Apps UI protocol (ui/* JSON-RPC 2.0 over postMessage), the server-side wire format, and the host-specific gotchas that cost us several hours of debugging.

TL;DR The renderer auto-detects the host protocol. You only need an HTML page with a single <script> tag pointing at renderer.auto.min.js. The server must return structuredContent alongside content[], and CSP (if the host requires it) should be declared on both the resource listing and the content item returned by readResource. Where both are present the content item wins, and a listing-only CSP breaks on hosts that read only the content item.

Architecture overview

┌─────────────────────────┐  stdio   ┌──────────────────────────┐
│  MCP Apps host           │◄────────►│   your MCP server        │
│  (VS Code / Claude /    │  MCP     │   (Node, Python, etc.)   │
│   ChatGPT / …)          │          └──────────────────────────┘
└──────────┬──────────────┘

           │  sandboxed iframe

┌─────────────────────────┐  postMessage   ┌──────────────────┐
│  ui:// resource (HTML)  │◄──────────────►│  Host bridge     │
│  + renderer.auto.min.js │   JSON-RPC     │  (ui/* protocol) │
└─────────────────────────┘   ui/*         └──────────────────┘

The flow:

  1. Tool's _meta.ui.resourceUri points the host at a ui:// resource.
  2. The host calls readResource and loads the HTML into a sandboxed iframe (webview in VS Code, iframe in Claude/ChatGPT).
  3. The renderer JS performs a ui/initialize handshake with the host.
  4. When the tool runs, the host sends the result to the iframe as a JSON-RPC ui/notifications/tool-result notification.
  5. The renderer extracts $prefab wire data and mounts it into #root.

Host-specific notes

HostTransportCSP handlingNotes
VS CodeacquireVsCodeApi().postMessage()Reads _meta.ui.csp from content itemInjects CSP via <meta> tag + acquireVsCodeApi() shim
Claude Desktopwindow.parent.postMessage()Reads _meta.ui.csp from content itemEnforces CSP via HTTP headers; assigns stable origin ({hash}.claudemcpcontent.com). Sends tool result before ui/initialize response — buffering is critical
ChatGPTwindow.parent.postMessage()Reads _meta.ui.csp from content itemEnforces CSP via HTTP headers; assigns stable origin ({slug}.oaiusercontent.com)

Server-side wire format

1. Tools — point at the renderer resource

ts
mcp.registerTool(
  'browse_patient',
  {
    title: 'Browse Patient',
    description: '...',
    inputSchema: { /* zod shape */ },
    _meta: { ui: { resourceUri: 'ui://your/viewer' } },
  },
  async (args) => ({
    content: [{ type: 'text', text: JSON.stringify(myPrefabData) }],
    // ⚠️ structuredContent is REQUIRED for the host to render the UI.
    structuredContent: myPrefabData,
  }),
);

structuredContent is what the host forwards to the iframe via ui/notifications/tool-result. The text in content[] is the LLM fallback for hosts without UI rendering.

2. Renderer resource — declare CSP on both, the content item wins

Per the MCP Apps spec: "When _meta.ui is present on both, the content-item value takes precedence. Hosts MUST check both locations, preferring the content item and falling back to the listing entry." The reference host implementation is literally contentMeta?.ui ?? listingMeta?.ui.

So declare _meta.ui in both places and let precedence sort it out. VS Code injects the resulting policy as a <meta> tag; Claude Desktop and ChatGPT enforce it via HTTP headers on a sandboxed origin.

This is the bug that wastes everyone's afternoon, and it is specifically a listing-only CSP. Some hosts (VS Code among them) read only the content item, so a policy declared solely on the resource listing is silently dropped and you get a black iframe. Setting it on both is always safe, since the content item takes precedence anyway.

ts
const CSP_META = {
  ui: {
    csp: {
      // Origins allowed for <script src>, <link href>, <img>, etc.
      resourceDomains: ['https://cdn.jsdelivr.net'],
      // Origins allowed for fetch / XHR / WebSocket.
      connectDomains: [],
      // Origins allowed in <iframe>.
      frameDomains: [],
      baseUriDomains: [],
    },
  },
};

mcp.registerResource(
  'viewer',
  'ui://your/viewer',
  {
    title: 'My Viewer',
    mimeType: 'text/html;profile=mcp-app',
    _meta: CSP_META,                       // listing-level fallback
    cacheHint: { ttlMs: 86_400_000, cacheScope: 'public' },
  },
  async (uri) => ({
    contents: [{
      uri: uri.toString(),
      mimeType: 'text/html;profile=mcp-app',
      text: rendererHtml(),
      _meta: CSP_META,                     // ← takes precedence; required by VS Code
    }],
    // Required on cacheable results as of protocol revision 2026-07-28
    // (SEP-2549). Omit them and the SDK falls back to { ttlMs: 0,
    // cacheScope: 'private' } — the viewer is re-fetched on every render.
    ttlMs: 86_400_000,
    cacheScope: 'public',
  }),
);

Use registerResource, not the older resource() overload — v1 deprecated it and @modelcontextprotocol/server v2 removed it.

Because the viewer HTML is pinned to an exact package version on the CDN, it cannot change for a given server build, which is what makes it safe to mark public with a long TTL. Values the handler returns win over the per-resource cacheHint, which in turn wins over the server-level cacheHints option.

Shortcut: if you're using prefab's TypeScript SDK, registerViewerResource() handles all of the above in one call — MIME type, CSP on both locations, the cache fields, and the io.modelcontextprotocol/apps extension capability (SEP-2133):

ts
import { registerViewerResource, PREFAB_RESOURCE_URI } from '@maxhealth.tech/prefab/mcp'

// Registers ui://prefab/viewer with correct MIME, CSP, and HTML
registerViewerResource(server)

// With custom options:
registerViewerResource(server, {
  uri: 'ui://myapp/dashboard',
  title: 'My Dashboard',
  csp: { connectDomains: ['https://api.example.com'] },
  permissions: { clipboardWrite: true },
  scripts: ['https://cdn.example.com/plugin.js'],
  cache: { ttlMs: 3_600_000, cacheScope: 'public' },  // default: 24h / public
})

Call it before the server connects — capabilities cannot be registered on a connected server. If it is called later the resource still registers, and prefab logs a warning instead of throwing.

Following the user's VS Code theme

prefab.css resolves every token through a chain: the MCP Apps host variable first, then the VS Code webview variable, then a static default.

css
--background: var(--color-background-primary, var(--vscode-editor-background, #ffffff));

That order is right in general, but a host which defines the MCP Apps --color-* variables shadows the VS Code ones completely, and the viewer stops tracking the user's editor theme. Opt into the bridge to invert the priority:

ts
registerViewerResource(server, { themeBridge: 'vscode' })
// or directly:
const html = rendererHtml({ themeBridge: 'vscode' })

It emits a <style> block after prefab.css that re-declares the affected tokens with the --vscode-* variable first and the MCP Apps layer dropped, keeping prefab's own static fallbacks so nothing changes outside VS Code. Head order is the contract: prefab.css, then the bridge, then your stylesheets, so your overrides still win.

It covers the :root default and the prefers-color-scheme: dark block. It deliberately does not touch :root[data-theme="dark"], which is prefab's standalone dark palette: host theming cascades, the manual toggle does not, so an app that pins data-theme="dark" keeps its own choice.

VSCODE_BRIDGE is the exported mapping table if you need to inspect or extend it; test/theme-bridge.test.ts cross-checks every entry against prefab.css so the two cannot drift.

The MIME type is exactly text/html;profile=mcp-app (with no space around the semicolon). Plain text/html is silently treated as a non-app resource.

3. CSP template (VS Code example)

All spec-compliant hosts enforce CSP. The exact directives vary by host, but the shape of _meta.ui.csp is the same everywhere. Below is the template VS Code uses; Claude Desktop and ChatGPT apply a similar policy via HTTP Content-Security-Policy headers.

VS Code merges your _meta.ui.csp into this template:

default-src 'none';
script-src 'self' 'unsafe-inline' {resourceDomains};
style-src  'self' 'unsafe-inline' {resourceDomains};
connect-src 'self' {connectDomains};
img-src 'self' data: {resourceDomains};
font-src 'self' {resourceDomains};
media-src 'self' data: {resourceDomains};
frame-src {frameDomains || 'none'};
object-src 'none';
base-uri {baseUriDomains || 'self'};

Inline <script> works ('unsafe-inline'), but external <script src="https://..."> requires the origin to be listed in resourceDomains.

Custom pipes under this policy

Note what is not in that template: 'unsafe-eval'. Custom pipes travel over the wire as function source (pipes: { humanName: '(value) => ...' }) and the renderer hydrates them with new Function(), which the policy above forbids. The renderer degrades gracefully — values pass through unformatted and one warning is logged — but the pipes do not run.

To use custom pipes in such a host, pre-register them from a companion script. Anything already registered wins over the wire source and is never evaluated:

ts
// companion.js, served from an origin listed in resourceDomains
window.prefab.registerPipe('humanName', (value) => {
  const n = value ?? {}
  return n.text ?? [n.given?.join(' '), n.family].filter(Boolean).join(' ')
})
ts
registerViewerResource(server, {
  scripts: ['https://cdn.example.com/companion.js'],  // origin auto-added to CSP
})

Sending the pipes on the wire as well is harmless and keeps the same server working in hosts that do allow eval.

4. Permission Policy — requesting browser capabilities

MCP Apps can request browser permissions (camera, microphone, geolocation, clipboard) via _meta.ui.permissions. This is part of the official MCP Apps spec (not host-specific) — though host support varies.

ts
const RESOURCE_META = {
  ui: {
    csp: {
      resourceDomains: ['https://cdn.jsdelivr.net'],
      connectDomains: [],
      frameDomains: [],
      baseUriDomains: [],
    },
    permissions: {
      camera: true,           // Request camera access
      microphone: true,       // Request microphone access
      geolocation: true,      // Request geolocation access
      clipboardWrite: true,   // Request clipboard write access
    },
  },
};
PermissionPermission Policy FeatureUse Case
cameracameraVideo capture, QR scanning
microphonemicrophoneAudio recording, voice input
geolocationgeolocationLocation-aware apps, maps
clipboardWriteclipboard-writeCopy-to-clipboard

All default to false. Hosts that support Permission Policy will set the allow attribute on the iframe accordingly. The browser still prompts the user for consent — permissions are requests, not guarantees.

Host support status:

HostSupports permissions?
VS CodePartial (may ignore unknown permissions)
Claude DesktopYes (reported via hostCapabilities.sandbox.permissions)
ChatGPTYes (reported via hostCapabilities.sandbox.permissions)

Hosts report the permissions and CSP they actually applied in the ui/initialize response under hostCapabilities.sandbox. Apps should use JS feature detection as a fallback since hosts MAY ignore permissions they don't support.

Client-side: the ui/* JSON-RPC protocol

Inside the iframe, communication with the host happens via postMessage. In VS Code, this is acquireVsCodeApi().postMessage(...). In all other hosts, it's window.parent.postMessage(...). The renderer detects the environment automatically.

All envelopes are JSON-RPC 2.0:

jsonc
// Host → client: tool result for the matching tool call
{
  "jsonrpc": "2.0",
  "method":  "ui/notifications/tool-result",
  "params":  {
    "content": [{ "type": "text", "text": "..." }],
    "structuredContent": { /* your UI payload */ }
  }
}

// Host → client: tool input arguments (echoed for context)
{
  "jsonrpc": "2.0",
  "method":  "ui/notifications/tool-input",
  "params":  { "arguments": { /* tool args */ } }
}

// Host → client: initialize request (responds with empty result is fine)
{
  "jsonrpc": "2.0",
  "id":      0,
  "result": {
    "protocolVersion": "2026-01-26",
    "hostInfo":        { "name": "Visual Studio Code", "version": "..." },
    "hostCapabilities": { /* ... */ }
  }
}

Methods (per the MCP Apps spec):

MethodDirectionPurpose
ui/initializeclient → hostHandshake request
(response to ui/initialize)host → clientMcpUiInitializeResult
ui/notifications/initializedclient → hostView confirms it is ready
ui/notifications/sandbox-proxy-readysandbox → hostSandbox proxy is ready
ui/notifications/sandbox-resource-readyhost → sandboxHost sends HTML to the sandbox proxy
ui/notifications/tool-inputhost → clientForwarded tool arguments
ui/notifications/tool-input-partialhost → clientStreaming partial input
ui/notifications/tool-resulthost → clientFinal tool result
ui/notifications/tool-cancelledhost → clientTool call cancelled
ui/notifications/host-context-changedhost → clientTheme / locale / dimensions changed
ui/notifications/size-changedclient → hostView resize
ui/open-linkclient → hostOpen URL externally
ui/messageclient → hostSend a message to the chat conversation
ui/request-display-modeclient → hostSwitch display mode
ui/update-model-contextclient → hostUpdate model context
ui/resource-teardownhost → clientIframe is being destroyed

Note: the View (your iframe) never sends sandbox-resource-ready — that one travels host → sandbox proxy and is internal to VS Code. After receiving the response to ui/initialize, the View must send ui/notifications/initialized to signal readiness.

Working adapter — renderer HTML

Two options for rendering @maxhealth.tech/prefab $prefab JSON inside a ui:// resource. Both work in every MCP Apps host — VS Code, Claude Desktop, ChatGPT, and any other host that speaks the ui/* JSON-RPC protocol.

Since v0.2.8, the auto-mount bundle handles both bridge protocols (prefab:* and ui/* JSON-RPC) with zero inline script. It races the handshakes in parallel, buffers tool results that arrive before the handler is wired, and defers boot until the DOM is interactive.

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Prefab</title>
  <link rel="stylesheet" crossorigin
        href="https://cdn.jsdelivr.net/npm/@maxhealth.tech/prefab@0.3/dist/prefab.css">
</head>
<body>
  <div id="root"></div>
  <script crossorigin
          src="https://cdn.jsdelivr.net/npm/@maxhealth.tech/prefab@0.3/dist/renderer.auto.min.js"></script>
</body>
</html>

That's the entire file. No inline <script>, no adapter code. The auto bundle:

  1. Waits for DOMContentLoaded (or microtask if already loaded)
  2. Calls app() which races prefab:init and ui/initialize in parallel — whichever protocol responds first wins
  3. Registers onToolResult and onToolInput handlers
  4. Mounts $prefab wire data into #root when a tool result arrives

Requires ≥ 0.2.8. Earlier versions used a sequential waterfall that wasted 1.5s on every JSON-RPC host and could miss early tool results.

Option B: renderer.min.js + inline adapter

If you need full control over the JSON-RPC handshake, or you're using a pre-0.2.8 version, load the library-only bundle and wire the protocol yourself:

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Prefab</title>
  <link rel="stylesheet" crossorigin
        href="https://cdn.jsdelivr.net/npm/@maxhealth.tech/prefab@0.3/dist/prefab.css">
</head>
<body>
  <div id="root"></div>
  <script crossorigin
          src="https://cdn.jsdelivr.net/npm/@maxhealth.tech/prefab@0.3/dist/renderer.min.js"></script>
  <script>
    (function () {
      var api = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;
      var post = function (msg) {
        if (api) api.postMessage(msg);
        else if (window.parent !== window) window.parent.postMessage(msg, '*');
      };
      var mounted = null;

      function isPrefab(d) { return d && typeof d === 'object' && d.$prefab && d.view; }
      function tryParse(t) { try { return JSON.parse(t); } catch (_) { return null; } }
      function extract(payload) {
        if (!payload) return null;
        if (payload.structuredContent && isPrefab(payload.structuredContent)) {
          return payload.structuredContent;
        }
        if (Array.isArray(payload.content)) {
          for (var i = 0; i < payload.content.length; i++) {
            var c = payload.content[i];
            if (c && c.type === 'text' && typeof c.text === 'string') {
              var parsed = tryParse(c.text);
              if (isPrefab(parsed)) return parsed;
            }
          }
        }
        if (isPrefab(payload)) return payload;
        return null;
      }
      function render(data) {
        var root = document.getElementById('root');
        if (mounted && typeof mounted.destroy === 'function') {
          try { mounted.destroy(); } catch (_) {}
        }
        try { mounted = window.prefab.mount(root, data); }
        catch (e) { root.textContent = 'Render error: ' + (e && e.message || e); }
      }

      var INIT_ID = 1;
      var initialized = false;

      window.addEventListener('message', function (e) {
        var msg = e.data;
        if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;

        if (msg.id === INIT_ID && !msg.method && !initialized) {
          initialized = true;
          post({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
          return;
        }

        if (msg.method && typeof msg.id !== 'undefined') {
          post({ jsonrpc: '2.0', id: msg.id, result: {} });
        }

        if (msg.method === 'ui/notifications/tool-result') {
          var data = extract(msg.params);
          if (data) render(data);
        } else if (msg.method === 'ui/notifications/tool-input') {
          var input = msg.params && msg.params.arguments;
          if (input && isPrefab(input)) render(input);
        }
      });

      post({
        jsonrpc: '2.0', id: INIT_ID, method: 'ui/initialize',
        params: {
          protocolVersion: '2026-01-26',
          appInfo: { name: 'prefab-renderer', version: '0.2' },
          appCapabilities: {}
        }
      });
    })();
  </script>
</body>
</html>

Common pitfalls

"Black iframe" / nothing renders (VS Code)

Cause: CSP is blocking the external script load. You most likely declared _meta.ui.csp only on the resource listing. Hosts that read only the content item (VS Code among them) never see it, so your resourceDomains never reach script-src.

Fix: add _meta to each entry of the contents array returned by readResource, and keep it on the listing too. The content item takes precedence where both are set, so declaring both is always safe.

Cannot read properties of undefined (reading 'startsWith')

Cause: you're using renderer.auto.min.js before v0.2.8. Older versions used a sequential handshake waterfall that races against VS Code's own initialization.

Fix: upgrade to @maxhealth.tech/prefab@0.2.8 or later. The auto bundle now works correctly in VS Code.

Iframe loads but shows raw JSON (all hosts)

You returned content but no structuredContent. The host only invokes the UI rendering path when structuredContent is set. Add it to your tool result.

Wrong MIME type

It must be exactly text/html;profile=mcp-app. Other MIME types are treated as ordinary ui:// resources and won't trigger the iframe loader.

appInfo vs clientInfo in ui/initialize (Claude Desktop / ChatGPT)

Cause: the ui/initialize request uses appInfo (per the MCP Apps spec and the @modelcontextprotocol/ext-apps SDK Zod schema), notclientInfo. If you send clientInfo, the host's schema validation fails silently — the handshake never completes, the iframe stays blank, and the only clue is a timeout.

This was the root cause of the Claude Desktop breakage between v0.2.11 and v0.2.12. In v0.2.11 the ext-apps SDK fallback masked the bug because the SDK sent the correct field. When we removed the SDK in v0.2.12 to cut bundle size (405 KB → 80 KB), our native JSON-RPC handshake was exposed — and it was sending clientInfo.

Correct:

js
{
  jsonrpc: '2.0', id: 1, method: 'ui/initialize',
  params: {
    protocolVersion: '2026-01-26',
    appInfo: { name: 'my-app', version: '1.0' },  // ← correct
    appCapabilities: {}
  }
}

Wrong (will timeout on Claude Desktop / ChatGPT):

js
{
  jsonrpc: '2.0', id: 1, method: 'ui/initialize',
  params: {
    protocolVersion: '2026-01-26',
    capabilities: {},                               // ← ignored
    clientInfo: { name: 'my-app', version: '1.0' }, // ← wrong field name
    appCapabilities: {}
  }
}

Fixed in v0.2.15.

@modelcontextprotocol/ext-apps SDK vs native ui/* JSON-RPC

The ext-apps SDK and the ui/* JSON-RPC protocol are the same wire protocol. The SDK is a convenience wrapper — under the hood it sends identical JSON-RPC 2.0 messages over postMessage.

Aspectext-apps SDK (App class)Native ui/* JSON-RPC
Wire formatJSON-RPC 2.0 over postMessageJSON-RPC 2.0 over postMessage
HandshakeApp.connect() → sends ui/initializeSend ui/initialize manually
Init params{ appInfo, appCapabilities, protocolVersion }Same
Tool inputaddEventListener('toolinput', fn)Listen for ui/notifications/tool-input
Tool resultaddEventListener('toolresult', fn)Listen for ui/notifications/tool-result
Tool callsapp.callServerTool({ name, arguments })Send tools/call JSON-RPC request
Open linkapp.openLink({ url })Send ui/open-link JSON-RPC request
Size reportingAuto via ResizeObserver (autoResize: true)Send ui/notifications/size-changed manually
Bundle size~325 KB (SDK + @modelcontextprotocol/sdk + zod)0 KB (native)
Dependencies@modelcontextprotocol/sdk, zod/v4None

Recommendation: use renderer.auto.min.js which implements the native ui/* JSON-RPC protocol without the SDK dependency. The SDK adds no capabilities that aren't available via raw JSON-RPC — it just adds 325 KB of bundle weight.

If you need the SDK for its React hooks (useHostStyleVariables, useDocumentTheme) or App.setupSizeChangedNotifications(), install @modelcontextprotocol/ext-apps separately.

Reference

  • MCP Apps spec: Protocol version 2026-01-26ui/* JSON-RPC 2.0 over postMessage
  • VS Code internals: resources/app/out/vs/workbench/workbench.desktop.main.js
    • _injectPreamble({ html, csp }) — builds the CSP meta tag and the acquireVsCodeApi() shim.
    • loadResource(uri) — reads the resource, returns { ...n._meta?.ui, html, mimeType }.
  • ext-apps SDK source: @modelcontextprotocol/ext-apps on npm
    • App.connect() in dist/src/app.js — sends ui/initialize with { appInfo, appCapabilities, protocolVersion }
    • AppBridge._oninitialize() in dist/src/app-bridge.js — host side, validates via Zod schema (requires appInfo, not clientInfo)
  • Reference implementations:
    • TypeScript: @maxhealth.tech/prefab on npm
    • Python: prefab_ui and fastmcp on PyPI

Released under the MIT License.