402 lines
16 KiB
Markdown
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 |
|