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()(NOTsrcdoc)
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:
- Messages are sent with
"*"target origin — no origin restriction on outbound - Inbound validation is SOURCE-based, not origin-based:
event.source !== this.eventSource - All messages must be valid JSON-RPC 2.0 — Zod-parsed via
JSONRPCMessageSchema - 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:
- Have declared
serverTools: {}inhostCapabilitiesduring init - Have registered a handler via
bridge.oncalltool = async (params) => { ... } - Forward the
tools/callrequest to the actual MCP server - 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
srcdociframe has origin"null"(opaque origin) event.sourcevalidation still works (it checks the Window object reference, not the origin)- BUT:
srcdociframes may have timing issues —contentWindowmight not be available immediately - Some features (like
CesiumJS) break insrcdocmode - The
document.write()approach keeps the iframe on the same origin as the sandbox proxy, soallow-same-originactually means something
If your host injects HTML via srcdoc:
postMessagewill still work (origin"*"on send, source-based validation on receive)- But the iframe's origin will be
"null", which can break:localStorage/sessionStoragefetch()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:
- The iframe reloads —
contentWindowchanges, old reference is stale - Double-iframe architecture — if the host points at the outer iframe but messages come from the inner iframe
- Timing — if you create the transport before
iframe.contentWindowis 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.originagainst 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:
- Calls
super.connect(transport)— starts the transport - Sends
ui/initializerequest (with id) and waits for response - If response succeeds, sends
ui/notifications/initializednotification - 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)
postMessageworks normallyevent.sourcevalidation 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 postMessagewith"*"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 |