feat(transport): support deferred target in PostMessageTransport for srcdoc iframes#543
Open
netanelavr wants to merge 1 commit intomodelcontextprotocol:mainfrom
Open
Conversation
…srcdoc iframes When hosts load View HTML dynamically via srcdoc, iframe.contentWindow is not available until the iframe loads. The current PostMessageTransport requires contentWindow at construction, creating a race condition where the View sends ui/initialize before the host's transport is listening. This adds deferred target support to PostMessageTransport: - eventTarget and eventSource now accept null - New setTarget() method sets the target and flushes queued messages - send() queues messages when target is null - close() clears the queue Fully backward compatible — existing constructor usage is unchanged. Fixes modelcontextprotocol#542 Made-with: Cursor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PostMessageTransportrequiresiframe.contentWindowat construction time for botheventTarget(send) andeventSource(receive validation). This creates a race condition for hosts that load View HTML dynamically viasrcdoc— the standard pattern when fetchingui://resources.The iframe starts executing immediately when
srcdocis set, so the View'sApp.connect()sendsui/initializebefore the host has created and started its transport. The message is silently lost, and the bridge never initializes.This is the likely root cause of #476 (
ontoolinputnot consistently called).Race condition (before this PR)
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 initializesWhy this affects all srcdoc hosts
Any host that:
ui://resource (or receives it inline)iframe.srcdoccontentWindowfor the transport constructor...will hit this race. The existing examples work because they assume the iframe is already loaded (
document.getElementById("app-iframe")), but real-world hosts create iframes dynamically.Solution
This PR adds deferred target support to
PostMessageTransport— no new classes, no breaking changes:eventTargetacceptsnull— outgoing messages are queued untilsetTarget()is calledeventSourceacceptsnull— all message sources are accepted (useful before the iframe loads)setTarget(target, eventSource?)method — sets the target window, updates the event source (defaults totarget), and flushes all queued messagesclose()clears the queue — prevents stale messages from leakingCorrect flow (after this PR)
sequenceDiagram participant Host participant Transport as PostMessageTransport participant Iframe as Iframe (View) Host->>Transport: 1. new PostMessageTransport(null, null) 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 initializedHost usage
Backward Compatibility
Fully backward compatible. Existing code works identically:
The only changes to the constructor signature:
eventTarget: Window→eventTarget: Window | null(default unchanged:window.parent)eventSource: MessageEventSource→eventSource: MessageEventSource | nullChanges
src/message-transport.ts_sendQueue,setTarget(), null-safesend()/close(), updated JSDocsrc/message-transport.examples.tssrc/message-transport.test.tsTests
4 focused tests (80/20 principle):
setTarget()eventSourceaccepts messages immediately (no init race)(window, window)constructor sends immediately, no queueclose()clears the send queueAll 91 tests pass (87 existing + 4 new):
Related
ontoolinputnot consistently called — likely caused by this race)Background
I discovered this race condition while building an MCP Apps host. Our workaround was a separate
DeferredPostMessageTransportclass, but the fix belongs in the SDK itself. The approach in this PR is minimal — it enhances the existing class rather than adding a new one, keeping the API surface small and discoverable.