modelcontextprotocol / modelcontextprotocol/ext-apps

PostMessageTransport: Race condition with srcdoc iframes — host misses ui/initialize

Open
#542 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
2.9k
Forks
387
Avg merge
3h 21m
Merged PRs (30d)
6

Description

Summary

PostMessageTransport requires iframe.contentWindow at construction time for both eventTarget and eventSource. This creates a race condition for hosts that load View HTML dynamically via srcdoc (the standard pattern when fetching ui:// resources): the iframe starts executing before the host's transport is listening, causing the ui/initialize request to be silently lost.

This is the likely root cause of #476 (ontoolinput not consistently called) — when the init handshake is dropped, the bridge never reaches the initialized state, so subsequent notifications like tool-input are never sent.

The Race Condition

The current PostMessageTransport constructor signature:

constructor(
  private eventTarget: Window = window.parent,
  private eventSource: MessageEventSource,
)

On the host side, the typical flow is:

  1. Fetch HTML content from ui:// resource
  2. Create iframe, set iframe.srcdoc = html
  3. Wait for iframe.onload
  4. Create PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)
  5. Call bridge.connect(transport)transport.start() begins listening

But the View's App.connect() runs during step 2-3, sending ui/initialize before step 5. The message is lost.

sequenceDiagram
    participant Host
    participant Transport as PostMessageTransport
    participant Iframe as Iframe (View)

    Note over Host: 1. Fetch HTML from ui:// resource
    Host->>Iframe: 2. iframe.srcdoc = html
    Note over Iframe: Script executes immediately
    Iframe->>Iframe: App.connect() → transport.start()
    Iframe-->>Host: 3. postMessage: ui/initialize
    Note over Host: ❌ MISSED — no listener yet!
    Host->>Host: 4. await iframe.onload
    Host->>Transport: 5. new PostMessageTransport(contentWindow, contentWindow)
    Host->>Transport: 6. bridge.connect(transport) → start()
    Note over Transport: Now listening, but ui/initialize was already sent
    Note over Host: ❌ Bridge never initializes → ontoolinput never fires

Why This Affects All srcdoc Hosts

Any host that:

  1. Fetches HTML from a ui:// resource (or receives it inline)
  2. Sets it via iframe.srcdoc
  3. Needs contentWindow for the transport constructor

will hit this race. This is the standard host pattern per the SDK docs and examples. The examples work because they assume the iframe is already loaded (document.getElementById("app-iframe")), but real hosts create iframes dynamically.

Workaround

I built a DeferredPostMessageTransport that decouples construction from target availability:

class DeferredPostMessageTransport implements Transport {
  private target: Window | null = null;
  private sendQueue: JSONRPCMessage[] = [];

  constructor() {
    // No contentWindow needed at construction
    this.messageListener = (event: MessageEvent) => {
      if (event.origin !== 'null') return; // srcdoc iframes have 'null' origin
      const parsed = JSONRPCMessageSchema.safeParse(event.data);
      if (parsed.success) this.onmessage?.(parsed.data);
    };
  }

  async start(): Promise<void> {
    window.addEventListener('message', this.messageListener);
  }

  async send(message: JSONRPCMessage): Promise<void> {
    if (this.target) {
      this.target.postMessage(message, '*');
    } else {
      this.sendQueue.push(message);
    }
  }

  setTarget(target: Window): void {
    this.target = target;
    for (const msg of this.sendQueue) {
      this.target.postMessage(msg, '*');
    }
    this.sendQueue = [];
  }

  async close(): Promise<void> {
    window.removeEventListener('message', this.messageListener);
    this.sendQueue = [];
    this.onclose?.();
  }
}

This enables the correct ordering:

sequenceDiagram
    participant Host
    participant Transport as DeferredPostMessageTransport
    participant Iframe as Iframe (View)

    Host->>Transport: 1. new DeferredPostMessageTransport()
    Host->>Transport: 2. bridge.connect(transport) → start()
    Note over Transport: ✅ Listening on window "message"
    Host->>Iframe: 3. iframe.srcdoc = html
    Iframe->>Iframe: App.connect()
    Iframe-->>Transport: 4. postMessage: ui/initialize
    Note over Transport: ✅ Received! Bridge handles init
    Transport-->>Iframe: 5. Queue: ui/initialize response (queued)
    Host->>Host: 6. await iframe.onload
    Host->>Transport: 7. setTarget(iframe.contentWindow)
    Note over Transport: ✅ Flush queue → response delivered
    Note over Host: Bridge initialized → ontoolinput works

Proposed Fix

Rather than requiring a separate class, PostMessageTransport itself could support deferred targets natively. A minimal change:

  1. Make eventTarget optional (default to null)
  2. Add a setTarget(target: Window) method that sets the target and flushes queued messages
  3. Queue outgoing messages in send() when target is null
  4. Clear the queue in close()

This is fully backward compatible — existing code passing (contentWindow, contentWindow) works identically. Hosts that need deferred initialization simply pass null (or omit the arg) and call setTarget() later.

Submitted a PR with this approach: #543

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at PostMessageTransport and the host-side bridge.connect/transport.start flow described in the issue, then compare it with the View App.connect path. Review PR #543 and verify construction without a target, handling ui/initialize before iframe load, queued responses, flushing after setTarget, and clearing on close. Done means srcdoc hosts complete initialization without losing the handshake while existing constructor usage still works.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.