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 atrenderer.auto.min.js. The server must returnstructuredContentalongsidecontent[], and CSP (if the host requires it) should be declared on both the resource listing and the content item returned byreadResource. 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:
- Tool's
_meta.ui.resourceUripoints the host at aui://resource. - The host calls
readResourceand loads the HTML into a sandboxed iframe (webview in VS Code, iframe in Claude/ChatGPT). - The renderer JS performs a
ui/initializehandshake with the host. - When the tool runs, the host sends the result to the iframe as a JSON-RPC
ui/notifications/tool-resultnotification. - The renderer extracts
$prefabwire data and mounts it into#root.
Host-specific notes
| Host | Transport | CSP handling | Notes |
|---|---|---|---|
| VS Code | acquireVsCodeApi().postMessage() | Reads _meta.ui.csp from content item | Injects CSP via <meta> tag + acquireVsCodeApi() shim |
| Claude Desktop | window.parent.postMessage() | Reads _meta.ui.csp from content item | Enforces CSP via HTTP headers; assigns stable origin ({hash}.claudemcpcontent.com). Sends tool result before ui/initialize response — buffering is critical |
| ChatGPT | window.parent.postMessage() | Reads _meta.ui.csp from content item | Enforces CSP via HTTP headers; assigns stable origin ({slug}.oaiusercontent.com) |
Server-side wire format
1. Tools — point at the renderer resource
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.uiis 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 literallycontentMeta?.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.
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):
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.
--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:
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.cspis the same everywhere. Below is the template VS Code uses; Claude Desktop and ChatGPT apply a similar policy via HTTPContent-Security-Policyheaders.
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:
// 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(' ')
})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.
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
},
},
};| Permission | Permission Policy Feature | Use Case |
|---|---|---|
camera | camera | Video capture, QR scanning |
microphone | microphone | Audio recording, voice input |
geolocation | geolocation | Location-aware apps, maps |
clipboardWrite | clipboard-write | Copy-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:
| Host | Supports permissions? |
|---|---|
| VS Code | Partial (may ignore unknown permissions) |
| Claude Desktop | Yes (reported via hostCapabilities.sandbox.permissions) |
| ChatGPT | Yes (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:
// 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):
| Method | Direction | Purpose |
|---|---|---|
ui/initialize | client → host | Handshake request |
(response to ui/initialize) | host → client | McpUiInitializeResult |
ui/notifications/initialized | client → host | View confirms it is ready |
ui/notifications/sandbox-proxy-ready | sandbox → host | Sandbox proxy is ready |
ui/notifications/sandbox-resource-ready | host → sandbox | Host sends HTML to the sandbox proxy |
ui/notifications/tool-input | host → client | Forwarded tool arguments |
ui/notifications/tool-input-partial | host → client | Streaming partial input |
ui/notifications/tool-result | host → client | Final tool result |
ui/notifications/tool-cancelled | host → client | Tool call cancelled |
ui/notifications/host-context-changed | host → client | Theme / locale / dimensions changed |
ui/notifications/size-changed | client → host | View resize |
ui/open-link | client → host | Open URL externally |
ui/message | client → host | Send a message to the chat conversation |
ui/request-display-mode | client → host | Switch display mode |
ui/update-model-context | client → host | Update model context |
ui/resource-teardown | host → client | Iframe 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 toui/initialize, the View must sendui/notifications/initializedto 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.
Option A: renderer.auto.min.js (recommended)
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.
<!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:
- Waits for
DOMContentLoaded(or microtask if already loaded) - Calls
app()which racesprefab:initandui/initializein parallel — whichever protocol responds first wins - Registers
onToolResultandonToolInputhandlers - Mounts
$prefabwire data into#rootwhen 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:
<!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:
{
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):
{
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.
| Aspect | ext-apps SDK (App class) | Native ui/* JSON-RPC |
|---|---|---|
| Wire format | JSON-RPC 2.0 over postMessage | JSON-RPC 2.0 over postMessage |
| Handshake | App.connect() → sends ui/initialize | Send ui/initialize manually |
| Init params | { appInfo, appCapabilities, protocolVersion } | Same |
| Tool input | addEventListener('toolinput', fn) | Listen for ui/notifications/tool-input |
| Tool result | addEventListener('toolresult', fn) | Listen for ui/notifications/tool-result |
| Tool calls | app.callServerTool({ name, arguments }) | Send tools/call JSON-RPC request |
| Open link | app.openLink({ url }) | Send ui/open-link JSON-RPC request |
| Size reporting | Auto 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/v4 | None |
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-26—ui/*JSON-RPC 2.0 overpostMessage - VS Code internals:
resources/app/out/vs/workbench/workbench.desktop.main.js_injectPreamble({ html, csp })— builds the CSP meta tag and theacquireVsCodeApi()shim.loadResource(uri)— reads the resource, returns{ ...n._meta?.ui, html, mimeType }.
- ext-apps SDK source:
@modelcontextprotocol/ext-appson npmApp.connect()indist/src/app.js— sendsui/initializewith{ appInfo, appCapabilities, protocolVersion }AppBridge._oninitialize()indist/src/app-bridge.js— host side, validates via Zod schema (requiresappInfo, notclientInfo)
- Reference implementations:
- TypeScript:
@maxhealth.tech/prefabon npm - Python:
prefab_uiandfastmcpon PyPI
- TypeScript: