# MCP Apps PostMessage Bridge Protocol — Complete Analysis ## Executive Summary The MCP Apps ext-apps SDK uses **JSON-RPC 2.0 over `window.postMessage`** for iframe↔host communication. The protocol has a well-defined initialization handshake, request/response flow, and notification system. There are several places where our setup could break. --- ## 1. Architecture Overview ``` ┌─────────────────────────────────────────────────────────┐ │ HOST (Parent Window) │ │ ┌─────────────┐ │ │ │ AppBridge │ ← Protocol class (handles JSON-RPC) │ │ │ + PostMsg │ │ │ │ Transport │ │ │ └──────┬───────┘ │ │ │ postMessage (JSON-RPC 2.0) │ │ ┌──────▼───────┐ │ │ │ SANDBOX PROXY│ (outer iframe, DIFFERENT origin) │ │ │ Relays msgs │ │ │ │ ┌──────────┐│ │ │ │ │ VIEW ││ (inner iframe, loaded via doc.write) │ │ │ │ (App) ││ ← Your MCP App HTML │ │ │ │ + PostMsg││ │ │ │ │ Transport││ │ │ │ └──────────┘│ │ │ └──────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` **Web hosts use a DOUBLE-IFRAME architecture:** - Outer iframe = "Sandbox Proxy" on a separate origin - Inner iframe = The actual View (App HTML), loaded via `document.write()` (NOT `srcdoc`) **Desktop/native hosts** can embed directly (single iframe), since they control the origin. --- ## 2. The PostMessage Transport (`PostMessageTransport`) **File:** `src/message-transport.ts` The transport is simple — it wraps `window.postMessage`: ```typescript class PostMessageTransport implements Transport { constructor( private eventTarget: Window, // where to SEND messages private eventSource: MessageEventSource // validate incoming message SOURCE ) async start() { window.addEventListener("message", this.messageListener); } async send(message: JSONRPCMessage) { this.eventTarget.postMessage(message, "*"); // ← sends with "*" origin } // Message listener validates event.source === this.eventSource // Then parses as JSONRPCMessageSchema (Zod validation) // If valid → this.onmessage(parsed.data) // If invalid → this.onerror(new Error(...)) } ``` ### Critical Details: 1. **Messages are sent with `"*"` target origin** — no origin restriction on outbound 2. **Inbound validation is SOURCE-based**, not origin-based: `event.source !== this.eventSource` 3. **All messages must be valid JSON-RPC 2.0** — Zod-parsed via `JSONRPCMessageSchema` 4. **Messages are raw objects** posted directly (NOT stringified) ### View-side instantiation: ```typescript const transport = new PostMessageTransport(window.parent, window.parent); ``` ### Host-side instantiation: ```typescript const transport = new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!); ``` --- ## 3. Complete Message Protocol ### 3.1 Initialization Handshake (REQUIRED) ``` VIEW (App) HOST (AppBridge) │ │ │── ui/initialize (request) ────────>│ JSON-RPC request with id │ { │ │ method: "ui/initialize", │ │ params: { │ │ appInfo: {name, version}, │ │ appCapabilities: {...}, │ │ protocolVersion: "2026-01-26"│ │ } │ │ } │ │ │ │<── initialize result (response) ───│ JSON-RPC response with matching id │ { │ │ protocolVersion: "2026-01-26", │ │ hostInfo: {name, version}, │ │ hostCapabilities: {...}, │ │ hostContext: {theme, styles,...}│ │ } │ │ │ │── ui/notifications/initialized ───>│ JSON-RPC notification (no id) │ { method: "ui/notifications/ │ │ initialized", params: {} } │ │ │ ``` ### 3.2 `callServerTool` Flow (App → Host → Server → Host → App) ``` VIEW (App) HOST (AppBridge) MCP SERVER │ │ │ │── tools/call (request) ───────────>│ │ │ { │ │ │ jsonrpc: "2.0", │ │ │ id: 1, │ │ │ method: "tools/call", │ │ │ params: { │ │ │ name: "get_weather", │ │ │ arguments: {location:"NYC"} │ │ │ } │ │ │ } │── tools/call ────────────>│ │ │ │ │ │<── result ────────────────│ │ │ │ │<── tools/call result (response) ───│ │ │ { │ │ │ jsonrpc: "2.0", │ │ │ id: 1, │ │ │ result: { │ │ │ content: [{type:"text",...}], │ │ │ isError: false │ │ │ } │ │ │ } │ │ ``` **For `callServerTool` to work, the host MUST:** 1. Have declared `serverTools: {}` in `hostCapabilities` during init 2. Have registered a handler via `bridge.oncalltool = async (params) => { ... }` 3. Forward the `tools/call` request to the actual MCP server 4. Return the result as a JSON-RPC response with the matching `id` ### 3.3 All Message Methods #### Host → View (Notifications) | Method | Purpose | |--------|---------| | `ui/notifications/tool-input` | Complete tool arguments | | `ui/notifications/tool-input-partial` | Streaming partial arguments | | `ui/notifications/tool-result` | Tool execution result | | `ui/notifications/tool-cancelled` | Tool execution cancelled | | `ui/notifications/host-context-changed` | Theme/locale/container changes | | `notifications/tools/list_changed` | Server tool list changed | | `notifications/resources/list_changed` | Server resource list changed | | `notifications/prompts/list_changed` | Server prompt list changed | #### Host → View (Requests) | Method | Purpose | |--------|---------| | `ui/resource-teardown` | Graceful shutdown request | | `ping` | Connection health check | | `tools/call` | Call app-provided tool | | `tools/list` | List app-provided tools | #### View → Host (Requests) | Method | Purpose | |--------|---------| | `ui/initialize` | Initialization handshake | | `ui/message` | Send chat message | | `ui/open-link` | Open external URL | | `ui/update-model-context` | Update model context | | `ui/request-display-mode` | Request fullscreen/pip | | `tools/call` | Call server tool (proxied) | | `resources/list` | List server resources | | `resources/read` | Read server resource | | `resources/templates/list` | List resource templates | | `prompts/list` | List server prompts | | `ping` | Connection health check | #### View → Host (Notifications) | Method | Purpose | |--------|---------| | `ui/notifications/initialized` | Init handshake complete | | `ui/notifications/size-changed` | Content size changed | | `notifications/message` | Log message | #### Sandbox-only (NOT relayed) | Method | Purpose | |--------|---------| | `ui/notifications/sandbox-proxy-ready` | Sandbox → Host: ready for HTML | | `ui/notifications/sandbox-resource-ready` | Host → Sandbox: here's the HTML | --- ## 4. Where the Bridge Can Break ### 4.1 ⚠️ `srcdoc` vs `document.write()` vs URL Loading **This is the #1 likely breakpoint.** The reference implementation **explicitly avoids `srcdoc`**: ```typescript // From examples/basic-host/src/sandbox.ts: // Use document.write instead of srcdoc (which the CesiumJS Map won't work with) const doc = inner.contentDocument || inner.contentWindow?.document; if (doc) { doc.open(); doc.write(html); doc.close(); } else { // Fallback to srcdoc if document is not accessible console.warn("[Sandbox] document.write not available, falling back to srcdoc"); inner.srcdoc = html; } ``` **Why `srcdoc` is problematic:** - An `srcdoc` iframe has origin `"null"` (opaque origin) - `event.source` validation still works (it checks the Window object reference, not the origin) - BUT: `srcdoc` iframes may have timing issues — `contentWindow` might not be available immediately - Some features (like `CesiumJS`) break in `srcdoc` mode - The `document.write()` approach keeps the iframe on the **same origin as the sandbox proxy**, so `allow-same-origin` actually means something **If your host injects HTML via `srcdoc`:** - `postMessage` will still work (origin `"*"` on send, source-based validation on receive) - But the iframe's origin will be `"null"`, which can break: - `localStorage` / `sessionStorage` - `fetch()` with relative URLs - Cookie access - Any API that checks same-origin policy ### 4.2 ⚠️ `event.source` Mismatch The transport validates `event.source !== this.eventSource`. This breaks when: 1. **The iframe reloads** — `contentWindow` changes, old reference is stale 2. **Double-iframe architecture** — if the host points at the outer iframe but messages come from the inner iframe 3. **Timing** — if you create the transport before `iframe.contentWindow` is available In the reference host, this is handled carefully: ```typescript // IMPORTANT: Pass `iframe.contentWindow` as BOTH target and source await appBridge.connect( new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!), ); ``` ### 4.3 ⚠️ Sandbox Proxy Message Relay The sandbox proxy forwards messages bidirectionally: ``` Host ←postMessage→ Sandbox (outer iframe) ←postMessage→ View (inner iframe) ``` The sandbox proxy code: - **Intercepts** `ui/notifications/sandbox-resource-ready` (doesn't relay) - **Relays everything else** between Host and View - **Validates origins**: checks `event.origin` against expected host origin and own origin If you're building a custom host without the sandbox proxy, messages go directly: ``` Host ←postMessage→ View (single iframe) ``` ### 4.4 ⚠️ JSON-RPC Schema Validation Every message is parsed through Zod's `JSONRPCMessageSchema`. If a message doesn't conform, it's **silently dropped** (logged to console.error): ```typescript const parsed = JSONRPCMessageSchema.safeParse(event.data); if (parsed.success) { this.onmessage?.(parsed.data); } else { console.error("Failed to parse message", parsed.error.message, event); this.onerror?.(new Error("Invalid JSON-RPC message received: " + ...)); } ``` **Required message structure:** ```typescript // Request: { jsonrpc: "2.0", id: number|string, method: string, params: object } // Notification: { jsonrpc: "2.0", method: string, params?: object } // Response: { jsonrpc: "2.0", id: number|string, result: object } // Error: { jsonrpc: "2.0", id: number|string, error: { code: number, message: string } } ``` ### 4.5 ⚠️ Initialization Must Complete Before Anything Works The `App.connect()` method: 1. Calls `super.connect(transport)` — starts the transport 2. Sends `ui/initialize` **request** (with id) and waits for response 3. If response succeeds, sends `ui/notifications/initialized` **notification** 4. If init fails, calls `this.close()` and throws **If the host doesn't respond to `ui/initialize`, the app hangs forever** (or until timeout). ### 4.6 ⚠️ `blob:` URLs If the iframe loads from a `blob:` URL: - Origin is the origin that created the blob (usually the parent page's origin) - `postMessage` works normally - `event.source` validation works - This is generally **safe** but could cause confusion with CORS ### 4.7 ⚠️ `data:` URIs If the iframe loads from a `data:` URI: - Origin is `"null"` (opaque) - Similar issues to `srcdoc` - `postMessage` with `"*"` still works - But many browser APIs are restricted --- ## 5. Diagnosis Checklist for Our Setup If interactive features don't work, check these in order: ### Step 1: Is the transport connected? ```javascript // In browser console of the iframe: // Check if the App instance exists and transport is connected ``` ### Step 2: Is `ui/initialize` being sent and responded to? Look in DevTools Network/Console for: - Outgoing: `{ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {...} }` - Incoming: `{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2026-01-26", ... } }` ### Step 3: Is `event.source` correct? ```javascript // In host code, verify: console.log("iframe.contentWindow:", iframe.contentWindow); // This MUST be the same Window object that sends the messages ``` ### Step 4: Are messages reaching the other side? ```javascript // Add a global listener to see ALL postMessages: window.addEventListener("message", (e) => { console.log("RAW MESSAGE:", e.data, "from:", e.source, "origin:", e.origin); }); ``` ### Step 5: Is the JSON-RPC format correct? Messages MUST have `jsonrpc: "2.0"`. Missing this field = silently dropped. ### Step 6: Does the host declare `serverTools` capability? For `callServerTool` to work, the host init response must include: ```json { "hostCapabilities": { "serverTools": {} } } ``` ### Step 7: Is the host handling the `tools/call` request? The host must have a handler registered: ```typescript bridge.oncalltool = async (params, extra) => { return mcpClient.request( { method: "tools/call", params }, CallToolResultSchema, { signal: extra.signal }, ); }; ``` --- ## 6. Key Code Locations | File | Purpose | |------|---------| | `src/message-transport.ts` | PostMessageTransport — the actual postMessage bridge | | `src/app.ts` | App class — view/iframe side | | `src/app-bridge.ts` | AppBridge class — host side | | `src/types.ts` | All message type definitions | | `src/spec.types.ts` | Protocol specification types | | `examples/basic-host/src/implementation.ts` | Reference host implementation | | `examples/basic-host/src/sandbox.ts` | Sandbox proxy (double-iframe relay) | | `specification/2026-01-26/apps.mdx` | Full protocol specification |