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/ui 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.

Notification ordering ​

The spec fixes the order of the tool notifications a view receives:

  1. ui/notifications/tool-input-partial: zero or more times, only while the agent is still streaming arguments.
  2. ui/notifications/tool-input: exactly once, with the complete arguments.
  3. ui/notifications/tool-result when the tool finishes, or ui/notifications/tool-cancelled when 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.

js
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:

ts
const toolMeta = { ui: { resourceUri: 'ui://your/viewer', visibility: ['app'] } }
visibilityAgent sees itView can call it
['model', 'app'] (default)YesYes
['model']YesNo
['app']NoYes

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':

js
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:

js
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.

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.

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.

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.

Other spec features ​

These are part of MCP Apps but outside what this guide walks through. The spec has the details.

  • hostContext in the ui/initialize result: theme and style variables (the renderer applies these), plus displayMode, availableDisplayModes, containerDimensions, locale, timeZone, platform, deviceCapabilities and safeAreaInsets. Changes arrive as ui/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 with ui/notifications/size-changed).
  • Display modes: a view lists the modes it supports in appCapabilities.availableDisplayModes and asks for one with ui/request-display-mode (the requestDisplayMode action). 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.com or www-example-com.oaiusercontent.com. It sits on the resource next to csp.
  • _meta.ui.prefersBorder: true asks the host for a border and background, false for 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, registerViewerResource declares the extension for you (opt out with declareCapability: false).
  • Newer than 2026-01-26: the ext-apps SDK and the draft spec add ui/download-file (host-mediated downloads, since a sandboxed iframe cannot download) and sampling/createMessage (behind hostCapabilities.sampling). Check the host's capabilities before using them.

Reference ​

  • MCP Apps spec: Protocol version 2026-01-26 — ui/* 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.