# 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 |
