Skip to content

Commit 6b44a81

Browse files
committed
feat(transport): add forHostIframe helper and improve init timeout message
- Add `PostMessageTransport.forHostIframe(iframe)` static method that validates the iframe is connected and returns a transport bound to its contentWindow. This makes the correct host construction order (connect before srcdoc) a one-liner. - Improve the View-side timeout message when `ui/initialize` fails: "no response within Ns — host may have loaded the View before connecting its transport" - Add "Host Construction Order" section to quickstart docs explaining the correct sequence: appendChild → transport → connect → srcdoc Fixes #542 Made-with: Cursor
1 parent 30a78b6 commit 6b44a81

5 files changed

Lines changed: 223 additions & 0 deletions

File tree

docs/quickstart.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,55 @@ Open http://localhost:8080 in your browser:
476476

477477
You've built your first MCP App!
478478

479+
## Host Construction Order
480+
481+
When building your own host (instead of using an existing MCP client), the order of operations matters. The host must start listening for messages **before** the View begins executing, otherwise the View's `ui/initialize` request will be lost.
482+
483+
### Correct Order
484+
485+
1. **Create and attach the iframe** to the document
486+
2. **Create the transport** using `PostMessageTransport.forHostIframe(iframe)`
487+
3. **Connect the bridge** with `await bridge.connect(transport)`
488+
4. **Then** set `iframe.srcdoc` or `iframe.src` to load the View
489+
490+
```ts
491+
import {
492+
AppBridge,
493+
PostMessageTransport,
494+
} from "@modelcontextprotocol/ext-apps/app-bridge";
495+
496+
const iframe = document.createElement("iframe");
497+
iframe.sandbox.add("allow-scripts");
498+
document.body.appendChild(iframe);
499+
500+
// Create transport — contentWindow exists once iframe is in DOM
501+
const transport = PostMessageTransport.forHostIframe(iframe);
502+
503+
// Connect bridge — now listening for messages
504+
const bridge = new AppBridge(mcpClient, hostInfo, hostCapabilities);
505+
await bridge.connect(transport);
506+
507+
// NOW load the content — ui/initialize will be received
508+
iframe.srcdoc = htmlContent;
509+
```
510+
511+
The `iframe.contentWindow` reference is available as soon as the iframe is in the DOM (it points to the initial `about:blank` document). You do **not** need to wait for `onload` to create the transport.
512+
513+
### Anti-Pattern
514+
515+
```ts
516+
// ❌ WRONG: Setting srcdoc before connecting
517+
iframe.srcdoc = htmlContent; // View sends ui/initialize immediately!
518+
const transport = PostMessageTransport.forHostIframe(iframe);
519+
await bridge.connect(transport); // Too late — message was already lost
520+
```
521+
522+
If you see a timeout error like:
523+
524+
> `ui/initialize: no response within 60s — host may have loaded the View before connecting its transport`
525+
526+
This is the likely cause. Reorder your code to connect the transport first.
527+
479528
## Next Steps
480529

481530
- **Continue learning**: The [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) example builds on this quickstart with host communication, theming, and lifecycle handlers

src/app.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1988,7 +1988,39 @@ export class App extends ProtocolWithEvents<
19881988
} catch (error) {
19891989
// Disconnect if initialization fails.
19901990
void this.close();
1991+
1992+
// Improve timeout message with actionable diagnosis for host developers.
1993+
// This commonly happens when the host loads the View before connecting
1994+
// its transport, causing the ui/initialize message to be lost.
1995+
if (isInitializationTimeoutError(error)) {
1996+
const timeoutMs = options?.timeout ?? 60000;
1997+
const timeoutSec = Math.round(timeoutMs / 1000);
1998+
throw new Error(
1999+
`ui/initialize: no response within ${timeoutSec}s — ` +
2000+
`host may have loaded the View before connecting its transport.`,
2001+
{ cause: error },
2002+
);
2003+
}
2004+
19912005
throw error;
19922006
}
19932007
}
19942008
}
2009+
2010+
/**
2011+
* Check if an error indicates the ui/initialize request timed out.
2012+
*/
2013+
function isInitializationTimeoutError(error: unknown): boolean {
2014+
if (!(error instanceof Error)) {
2015+
return false;
2016+
}
2017+
const message = error.message.toLowerCase();
2018+
const name = error.name.toLowerCase();
2019+
return (
2020+
message.includes("timeout") ||
2021+
message.includes("timed out") ||
2022+
message.includes("requesttimeout") ||
2023+
name.includes("timeout") ||
2024+
name === "aborterror"
2025+
);
2026+
}

src/message-transport.examples.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,24 @@ function PostMessageTransport_constructor_host() {
5656
);
5757
//#endregion PostMessageTransport_constructor_host
5858
}
59+
60+
/**
61+
* Example: Host using forHostIframe helper (recommended).
62+
*
63+
* The helper validates the iframe is connected and returns a transport bound
64+
* to its contentWindow. Connect before setting srcdoc/src.
65+
*/
66+
async function PostMessageTransport_forHostIframe(bridge: AppBridge) {
67+
//#region PostMessageTransport_forHostIframe
68+
const iframe = document.createElement("iframe");
69+
iframe.sandbox.add("allow-scripts");
70+
document.body.appendChild(iframe);
71+
72+
// Create transport BEFORE loading content
73+
const transport = PostMessageTransport.forHostIframe(iframe);
74+
await bridge.connect(transport);
75+
76+
// NOW load the view — ui/initialize will be received
77+
iframe.srcdoc = "<html>...</html>";
78+
//#endregion PostMessageTransport_forHostIframe
79+
}

src/message-transport.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,75 @@ describe("PostMessageTransport", () => {
387387
await transportB.close();
388388
});
389389
});
390+
391+
// ==========================================================================
392+
// forHostIframe() — static factory for host-side transport
393+
// ==========================================================================
394+
describe("forHostIframe()", () => {
395+
// These tests require a real DOM environment. We create minimal fakes
396+
// that satisfy the checks in forHostIframe().
397+
398+
it("throws when iframe is not connected to the document", () => {
399+
const iframe = {
400+
isConnected: false,
401+
contentWindow: {},
402+
} as unknown as HTMLIFrameElement;
403+
404+
expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow(
405+
/iframe must be in the document/,
406+
);
407+
});
408+
409+
it("error message mentions appendChild", () => {
410+
const iframe = {
411+
isConnected: false,
412+
contentWindow: {},
413+
} as unknown as HTMLIFrameElement;
414+
415+
expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow(
416+
/appendChild/,
417+
);
418+
});
419+
420+
it("throws when contentWindow is null", () => {
421+
const iframe = {
422+
isConnected: true,
423+
contentWindow: null,
424+
} as unknown as HTMLIFrameElement;
425+
426+
expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow(
427+
/contentWindow is null/,
428+
);
429+
});
430+
431+
it("returns a transport when iframe is connected with contentWindow", () => {
432+
const fakeContentWindow = { postMessage: mock(() => {}) };
433+
const iframe = {
434+
isConnected: true,
435+
contentWindow: fakeContentWindow,
436+
} as unknown as HTMLIFrameElement;
437+
438+
const transport = PostMessageTransport.forHostIframe(iframe);
439+
440+
expect(transport).toBeInstanceOf(PostMessageTransport);
441+
});
442+
443+
it("returned transport uses contentWindow as event target", async () => {
444+
const postMessageFn = mock(() => {});
445+
const fakeContentWindow = { postMessage: postMessageFn };
446+
const iframe = {
447+
isConnected: true,
448+
contentWindow: fakeContentWindow,
449+
} as unknown as HTMLIFrameElement;
450+
451+
const transport = PostMessageTransport.forHostIframe(iframe);
452+
await transport.send({ jsonrpc: "2.0", method: "test", id: 1 });
453+
454+
expect(postMessageFn).toHaveBeenCalledTimes(1);
455+
expect(postMessageFn).toHaveBeenCalledWith(
456+
{ jsonrpc: "2.0", method: "test", id: 1 },
457+
"*",
458+
);
459+
});
460+
});
390461
});

src/message-transport.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,54 @@ export class PostMessageTransport implements Transport {
187187
* @param version - The negotiated protocol version string
188188
*/
189189
setProtocolVersion?: (version: string) => void;
190+
191+
/**
192+
* Create a transport for a host embedding an MCP App in an iframe.
193+
*
194+
* This helper enforces the correct construction order: the iframe must be
195+
* in the document before creating the transport. Call this **before** setting
196+
* `srcdoc` or `src` on the iframe, then call `bridge.connect(transport)`, and
197+
* only then load the View content.
198+
*
199+
* The `contentWindow` reference is available as soon as the iframe is in the
200+
* DOM (it points to the initial `about:blank` document). You do **not** need
201+
* to wait for `onload`.
202+
*
203+
* @param iframe - An HTMLIFrameElement that is already in the DOM
204+
* @returns A PostMessageTransport configured for host→iframe communication
205+
* @throws Error if the iframe is not connected to the document
206+
* @throws Error if contentWindow is unavailable
207+
*
208+
* @example Correct host construction order
209+
* ```ts source="./message-transport.examples.ts#PostMessageTransport_forHostIframe"
210+
* const iframe = document.createElement("iframe");
211+
* iframe.sandbox.add("allow-scripts");
212+
* document.body.appendChild(iframe);
213+
*
214+
* // Create transport BEFORE loading content
215+
* const transport = PostMessageTransport.forHostIframe(iframe);
216+
* await bridge.connect(transport);
217+
*
218+
* // NOW load the view — ui/initialize will be received
219+
* iframe.srcdoc = "<html>...</html>";
220+
* ```
221+
*/
222+
static forHostIframe(iframe: HTMLIFrameElement): PostMessageTransport {
223+
if (!iframe.isConnected) {
224+
throw new Error(
225+
"PostMessageTransport.forHostIframe: iframe must be in the document. " +
226+
"Call document.body.appendChild(iframe) before creating the transport.",
227+
);
228+
}
229+
230+
const contentWindow = iframe.contentWindow;
231+
if (!contentWindow) {
232+
throw new Error(
233+
"PostMessageTransport.forHostIframe: iframe.contentWindow is null. " +
234+
"Ensure the iframe is attached to the DOM.",
235+
);
236+
}
237+
238+
return new PostMessageTransport(contentWindow, contentWindow);
239+
}
190240
}

0 commit comments

Comments
 (0)