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

402 lines
16 KiB
Markdown

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