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/ui 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.
Notification ordering
The spec fixes the order of the tool notifications a view receives:
ui/notifications/tool-input-partial: zero or more times, only while the agent is still streaming arguments.ui/notifications/tool-input: exactly once, with the complete arguments.ui/notifications/tool-resultwhen the tool finishes, orui/notifications/tool-cancelledwhen it was cancelled (user action, sampling error, classifier intervention). A host sends one of the two, never both.
The host sends nothing to the view before the view has sent ui/notifications/initialized.
Calling the server from the view
A view can call tools and read resources on the MCP server; the host proxies the request. Check hostCapabilities.serverTools and hostCapabilities.serverResources in the ui/initialize result first.
post({ jsonrpc: '2.0', id: nextId++, method: 'tools/call',
params: { name: 'get_weather', arguments: { location: 'NYC' } } })
post({ jsonrpc: '2.0', id: nextId++, method: 'resources/read',
params: { uri: 'data://weather/cache' } })A tool called this way produces the same tool-input-partial, tool-input, tool-result sequence as one the agent started. In prefab, the toolCall action and ctx.transport.callTool send tools/call for you.
Tool visibility
_meta.ui.visibility decides who may call a tool:
const toolMeta = { ui: { resourceUri: 'ui://your/viewer', visibility: ['app'] } }visibility | Agent sees it | View can call it |
|---|---|---|
['model', 'app'] (default) | Yes | Yes |
['model'] | Yes | No |
['app'] | No | Yes |
Use ['app'] for UI-only tools such as refresh, pagination, or form submission. The host leaves them out of the agent's tool list and rejects a tools/call from the view for a tool without 'app'.
Sending a chat message (ui/message)
content is an array of content blocks, and role must be 'user':
post({ jsonrpc: '2.0', id: nextId++, method: 'ui/message',
params: { role: 'user', content: [{ type: 'text', text: 'Show me tomorrow' }] } })The ext-apps host schema rejects a bare { type, text } object. The host may ask the user before adding the message, and a message starts a follow-up turn. The sendMessage action sends this envelope.
Telling the model what happened in the view
The model only sees the tool result. Anything the view learns afterwards, such as an image that failed to load or a selection the user made, stays invisible to it unless the view reports it with ui/update-model-context. The host keeps the latest update and hands it to the model with the next user message, without starting a turn of its own.
A custom component reaches it through the transport on its render context:
window.prefab.registerComponent('Preview', (node, ctx) => {
const report = (text) => ctx.transport?.updateModelContext?.({ content: [{ type: 'text', text }] })
// ...
report('The preview failed to load: the image server answered HTTP 404.')
})content is what the model reads; structuredContent is optional machine-readable data. Over the prefab:* protocol the same update travels as prefab:update-context.
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.
The spec's own "Transport Layer" section adds to the confusion: its native example sends initialize with clientInfo and capabilities, which is the core MCP handshake for an iframe acting as a full MCP client. The ui/initialize handshake is the formal McpUiInitializeRequest (appInfo, appCapabilities), and that is what Claude Desktop and ChatGPT validate.
@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.
Other spec features
These are part of MCP Apps but outside what this guide walks through. The spec has the details.
hostContextin theui/initializeresult: theme and style variables (the renderer applies these), plusdisplayMode,availableDisplayModes,containerDimensions,locale,timeZone,platform,deviceCapabilitiesandsafeAreaInsets. Changes arrive asui/notifications/host-context-changed.- Container dimensions: each of height and width is fixed (
height/width, the host decides) or flexible (maxHeight/maxWidth, the view decides up to the limit and reports its size withui/notifications/size-changed). - Display modes: a view lists the modes it supports in
appCapabilities.availableDisplayModesand asks for one withui/request-display-mode(therequestDisplayModeaction). The host answers with the mode it actually set. _meta.ui.domain: a stable sandbox origin, for OAuth callbacks or CORS allowlists. The format is host-specific, such as{hash}.claudemcpcontent.comorwww-example-com.oaiusercontent.com. It sits on the resource next tocsp._meta.ui.prefersBorder:trueasks the host for a border and background,falsefor none. Set it explicitly, since host defaults differ.- Capability negotiation: a client announces MCP Apps support under
capabilities.extensions["io.modelcontextprotocol/ui"], and ext-apps'getUiCapability()reads it. On the server side,registerViewerResourcedeclares the extension for you (opt out withdeclareCapability: false). - Newer than
2026-01-26: the ext-apps SDK and the draft spec addui/download-file(host-mediated downloads, since a sandboxed iframe cannot download) andsampling/createMessage(behindhostCapabilities.sampling). Check the host's capabilities before using them.
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: