clawdbot-workspace/research/mcp-postmessage-bridge-protocol.md

16 KiB

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:

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:

const transport = new PostMessageTransport(window.parent, window.parent);

Host-side instantiation:

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:

// 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 reloadscontentWindow 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:

// 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):

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:

// 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?

// 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?

// 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?

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

{
  "hostCapabilities": {
    "serverTools": {}
  }
}

Step 7: Is the host handling the tools/call request?

The host must have a handler registered:

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