From 6b44a81ffd7731789d94db03ca2292415491f608 Mon Sep 17 00:00:00 2001 From: netanelavr Date: Mon, 27 Apr 2026 12:56:37 +0300 Subject: [PATCH 1/2] feat(transport): add forHostIframe helper and improve init timeout message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/quickstart.md | 49 +++++++++++++++++++++ src/app.ts | 32 ++++++++++++++ src/message-transport.examples.ts | 21 +++++++++ src/message-transport.test.ts | 71 +++++++++++++++++++++++++++++++ src/message-transport.ts | 50 ++++++++++++++++++++++ 5 files changed, 223 insertions(+) diff --git a/docs/quickstart.md b/docs/quickstart.md index 3e00796c5..2970ef833 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -476,6 +476,55 @@ Open http://localhost:8080 in your browser: You've built your first MCP App! +## Host Construction Order + +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. + +### Correct Order + +1. **Create and attach the iframe** to the document +2. **Create the transport** using `PostMessageTransport.forHostIframe(iframe)` +3. **Connect the bridge** with `await bridge.connect(transport)` +4. **Then** set `iframe.srcdoc` or `iframe.src` to load the View + +```ts +import { + AppBridge, + PostMessageTransport, +} from "@modelcontextprotocol/ext-apps/app-bridge"; + +const iframe = document.createElement("iframe"); +iframe.sandbox.add("allow-scripts"); +document.body.appendChild(iframe); + +// Create transport — contentWindow exists once iframe is in DOM +const transport = PostMessageTransport.forHostIframe(iframe); + +// Connect bridge — now listening for messages +const bridge = new AppBridge(mcpClient, hostInfo, hostCapabilities); +await bridge.connect(transport); + +// NOW load the content — ui/initialize will be received +iframe.srcdoc = htmlContent; +``` + +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. + +### Anti-Pattern + +```ts +// ❌ WRONG: Setting srcdoc before connecting +iframe.srcdoc = htmlContent; // View sends ui/initialize immediately! +const transport = PostMessageTransport.forHostIframe(iframe); +await bridge.connect(transport); // Too late — message was already lost +``` + +If you see a timeout error like: + +> `ui/initialize: no response within 60s — host may have loaded the View before connecting its transport` + +This is the likely cause. Reorder your code to connect the transport first. + ## Next Steps - **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 diff --git a/src/app.ts b/src/app.ts index adfad5c77..16f4a8a58 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1988,7 +1988,39 @@ export class App extends ProtocolWithEvents< } catch (error) { // Disconnect if initialization fails. void this.close(); + + // Improve timeout message with actionable diagnosis for host developers. + // This commonly happens when the host loads the View before connecting + // its transport, causing the ui/initialize message to be lost. + if (isInitializationTimeoutError(error)) { + const timeoutMs = options?.timeout ?? 60000; + const timeoutSec = Math.round(timeoutMs / 1000); + throw new Error( + `ui/initialize: no response within ${timeoutSec}s — ` + + `host may have loaded the View before connecting its transport.`, + { cause: error }, + ); + } + throw error; } } } + +/** + * Check if an error indicates the ui/initialize request timed out. + */ +function isInitializationTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + return ( + message.includes("timeout") || + message.includes("timed out") || + message.includes("requesttimeout") || + name.includes("timeout") || + name === "aborterror" + ); +} diff --git a/src/message-transport.examples.ts b/src/message-transport.examples.ts index b42184bf9..3a6737300 100644 --- a/src/message-transport.examples.ts +++ b/src/message-transport.examples.ts @@ -56,3 +56,24 @@ function PostMessageTransport_constructor_host() { ); //#endregion PostMessageTransport_constructor_host } + +/** + * Example: Host using forHostIframe helper (recommended). + * + * The helper validates the iframe is connected and returns a transport bound + * to its contentWindow. Connect before setting srcdoc/src. + */ +async function PostMessageTransport_forHostIframe(bridge: AppBridge) { + //#region PostMessageTransport_forHostIframe + const iframe = document.createElement("iframe"); + iframe.sandbox.add("allow-scripts"); + document.body.appendChild(iframe); + + // Create transport BEFORE loading content + const transport = PostMessageTransport.forHostIframe(iframe); + await bridge.connect(transport); + + // NOW load the view — ui/initialize will be received + iframe.srcdoc = "..."; + //#endregion PostMessageTransport_forHostIframe +} diff --git a/src/message-transport.test.ts b/src/message-transport.test.ts index d5f509204..177e8ab89 100644 --- a/src/message-transport.test.ts +++ b/src/message-transport.test.ts @@ -387,4 +387,75 @@ describe("PostMessageTransport", () => { await transportB.close(); }); }); + + // ========================================================================== + // forHostIframe() — static factory for host-side transport + // ========================================================================== + describe("forHostIframe()", () => { + // These tests require a real DOM environment. We create minimal fakes + // that satisfy the checks in forHostIframe(). + + it("throws when iframe is not connected to the document", () => { + const iframe = { + isConnected: false, + contentWindow: {}, + } as unknown as HTMLIFrameElement; + + expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow( + /iframe must be in the document/, + ); + }); + + it("error message mentions appendChild", () => { + const iframe = { + isConnected: false, + contentWindow: {}, + } as unknown as HTMLIFrameElement; + + expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow( + /appendChild/, + ); + }); + + it("throws when contentWindow is null", () => { + const iframe = { + isConnected: true, + contentWindow: null, + } as unknown as HTMLIFrameElement; + + expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow( + /contentWindow is null/, + ); + }); + + it("returns a transport when iframe is connected with contentWindow", () => { + const fakeContentWindow = { postMessage: mock(() => {}) }; + const iframe = { + isConnected: true, + contentWindow: fakeContentWindow, + } as unknown as HTMLIFrameElement; + + const transport = PostMessageTransport.forHostIframe(iframe); + + expect(transport).toBeInstanceOf(PostMessageTransport); + }); + + it("returned transport uses contentWindow as event target", async () => { + const postMessageFn = mock(() => {}); + const fakeContentWindow = { postMessage: postMessageFn }; + const iframe = { + isConnected: true, + contentWindow: fakeContentWindow, + } as unknown as HTMLIFrameElement; + + const transport = PostMessageTransport.forHostIframe(iframe); + await transport.send({ jsonrpc: "2.0", method: "test", id: 1 }); + + expect(postMessageFn).toHaveBeenCalledTimes(1); + expect(postMessageFn).toHaveBeenCalledWith( + { jsonrpc: "2.0", method: "test", id: 1 }, + "*", + ); + }); + }); }); diff --git a/src/message-transport.ts b/src/message-transport.ts index 9d195435a..5b80f3ee9 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -187,4 +187,54 @@ export class PostMessageTransport implements Transport { * @param version - The negotiated protocol version string */ setProtocolVersion?: (version: string) => void; + + /** + * Create a transport for a host embedding an MCP App in an iframe. + * + * This helper enforces the correct construction order: the iframe must be + * in the document before creating the transport. Call this **before** setting + * `srcdoc` or `src` on the iframe, then call `bridge.connect(transport)`, and + * only then load the View content. + * + * The `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`. + * + * @param iframe - An HTMLIFrameElement that is already in the DOM + * @returns A PostMessageTransport configured for host→iframe communication + * @throws Error if the iframe is not connected to the document + * @throws Error if contentWindow is unavailable + * + * @example Correct host construction order + * ```ts source="./message-transport.examples.ts#PostMessageTransport_forHostIframe" + * const iframe = document.createElement("iframe"); + * iframe.sandbox.add("allow-scripts"); + * document.body.appendChild(iframe); + * + * // Create transport BEFORE loading content + * const transport = PostMessageTransport.forHostIframe(iframe); + * await bridge.connect(transport); + * + * // NOW load the view — ui/initialize will be received + * iframe.srcdoc = "..."; + * ``` + */ + static forHostIframe(iframe: HTMLIFrameElement): PostMessageTransport { + if (!iframe.isConnected) { + throw new Error( + "PostMessageTransport.forHostIframe: iframe must be in the document. " + + "Call document.body.appendChild(iframe) before creating the transport.", + ); + } + + const contentWindow = iframe.contentWindow; + if (!contentWindow) { + throw new Error( + "PostMessageTransport.forHostIframe: iframe.contentWindow is null. " + + "Ensure the iframe is attached to the DOM.", + ); + } + + return new PostMessageTransport(contentWindow, contentWindow); + } } From 4d42f5709344596a825b87c0793c3d9b56624e57 Mon Sep 17 00:00:00 2001 From: netanelavr Date: Mon, 27 Apr 2026 15:00:23 +0300 Subject: [PATCH 2/2] refactor: reduce comments, align with repo conventions - Remove @example block from forHostIframe JSDoc (only constructors use it) - Reduce example JSDoc to single line matching existing style - Merge redundant test case, remove describe-level comment - Move isInitializationTimeoutError to private static on App class - Use DEFAULT_REQUEST_TIMEOUT_MSEC instead of hardcoded 60000 - Streamline quickstart docs: remove sub-headings and anti-pattern block, use [!CAUTION] callout matching existing doc style Made-with: Cursor --- docs/quickstart.md | 30 ++++--------------------- src/app.ts | 37 +++++++++++++------------------ src/message-transport.examples.ts | 5 +---- src/message-transport.test.ts | 11 --------- src/message-transport.ts | 14 ------------ 5 files changed, 21 insertions(+), 76 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 2970ef833..1aceb899b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -480,12 +480,7 @@ You've built your first MCP App! 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. -### Correct Order - -1. **Create and attach the iframe** to the document -2. **Create the transport** using `PostMessageTransport.forHostIframe(iframe)` -3. **Connect the bridge** with `await bridge.connect(transport)` -4. **Then** set `iframe.srcdoc` or `iframe.src` to load the View +`iframe.contentWindow` 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. ```ts import { @@ -497,33 +492,16 @@ const iframe = document.createElement("iframe"); iframe.sandbox.add("allow-scripts"); document.body.appendChild(iframe); -// Create transport — contentWindow exists once iframe is in DOM const transport = PostMessageTransport.forHostIframe(iframe); - -// Connect bridge — now listening for messages const bridge = new AppBridge(mcpClient, hostInfo, hostCapabilities); await bridge.connect(transport); -// NOW load the content — ui/initialize will be received +// Set content AFTER connecting — view's ui/initialize will be received iframe.srcdoc = htmlContent; ``` -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. - -### Anti-Pattern - -```ts -// ❌ WRONG: Setting srcdoc before connecting -iframe.srcdoc = htmlContent; // View sends ui/initialize immediately! -const transport = PostMessageTransport.forHostIframe(iframe); -await bridge.connect(transport); // Too late — message was already lost -``` - -If you see a timeout error like: - -> `ui/initialize: no response within 60s — host may have loaded the View before connecting its transport` - -This is the likely cause. Reorder your code to connect the transport first. +> [!CAUTION] +> Setting `srcdoc` or `src` **before** connecting the transport will cause the View's `ui/initialize` to be lost. If you see a timeout like `"no response within 60s"`, this is the likely cause. ## Next Steps diff --git a/src/app.ts b/src/app.ts index 16f4a8a58..2a0f3d2ef 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { type RequestOptions, mergeCapabilities, ProtocolOptions, + DEFAULT_REQUEST_TIMEOUT_MSEC, } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { @@ -1989,11 +1990,8 @@ export class App extends ProtocolWithEvents< // Disconnect if initialization fails. void this.close(); - // Improve timeout message with actionable diagnosis for host developers. - // This commonly happens when the host loads the View before connecting - // its transport, causing the ui/initialize message to be lost. - if (isInitializationTimeoutError(error)) { - const timeoutMs = options?.timeout ?? 60000; + if (App.isInitializationTimeoutError(error)) { + const timeoutMs = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; const timeoutSec = Math.round(timeoutMs / 1000); throw new Error( `ui/initialize: no response within ${timeoutSec}s — ` + @@ -2005,22 +2003,19 @@ export class App extends ProtocolWithEvents< throw error; } } -} -/** - * Check if an error indicates the ui/initialize request timed out. - */ -function isInitializationTimeoutError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; + private static isInitializationTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.toLowerCase(); + const name = error.name.toLowerCase(); + return ( + message.includes("timeout") || + message.includes("timed out") || + message.includes("requesttimeout") || + name.includes("timeout") || + name === "aborterror" + ); } - const message = error.message.toLowerCase(); - const name = error.name.toLowerCase(); - return ( - message.includes("timeout") || - message.includes("timed out") || - message.includes("requesttimeout") || - name.includes("timeout") || - name === "aborterror" - ); } diff --git a/src/message-transport.examples.ts b/src/message-transport.examples.ts index 3a6737300..fb2f680c9 100644 --- a/src/message-transport.examples.ts +++ b/src/message-transport.examples.ts @@ -58,10 +58,7 @@ function PostMessageTransport_constructor_host() { } /** - * Example: Host using forHostIframe helper (recommended). - * - * The helper validates the iframe is connected and returns a transport bound - * to its contentWindow. Connect before setting srcdoc/src. + * Example: Host using forHostIframe helper. */ async function PostMessageTransport_forHostIframe(bridge: AppBridge) { //#region PostMessageTransport_forHostIframe diff --git a/src/message-transport.test.ts b/src/message-transport.test.ts index 177e8ab89..36abd604d 100644 --- a/src/message-transport.test.ts +++ b/src/message-transport.test.ts @@ -392,9 +392,6 @@ describe("PostMessageTransport", () => { // forHostIframe() — static factory for host-side transport // ========================================================================== describe("forHostIframe()", () => { - // These tests require a real DOM environment. We create minimal fakes - // that satisfy the checks in forHostIframe(). - it("throws when iframe is not connected to the document", () => { const iframe = { isConnected: false, @@ -404,14 +401,6 @@ describe("PostMessageTransport", () => { expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow( /iframe must be in the document/, ); - }); - - it("error message mentions appendChild", () => { - const iframe = { - isConnected: false, - contentWindow: {}, - } as unknown as HTMLIFrameElement; - expect(() => PostMessageTransport.forHostIframe(iframe)).toThrow( /appendChild/, ); diff --git a/src/message-transport.ts b/src/message-transport.ts index 5b80f3ee9..8e6d67643 100644 --- a/src/message-transport.ts +++ b/src/message-transport.ts @@ -204,20 +204,6 @@ export class PostMessageTransport implements Transport { * @returns A PostMessageTransport configured for host→iframe communication * @throws Error if the iframe is not connected to the document * @throws Error if contentWindow is unavailable - * - * @example Correct host construction order - * ```ts source="./message-transport.examples.ts#PostMessageTransport_forHostIframe" - * const iframe = document.createElement("iframe"); - * iframe.sandbox.add("allow-scripts"); - * document.body.appendChild(iframe); - * - * // Create transport BEFORE loading content - * const transport = PostMessageTransport.forHostIframe(iframe); - * await bridge.connect(transport); - * - * // NOW load the view — ui/initialize will be received - * iframe.srcdoc = "..."; - * ``` */ static forHostIframe(iframe: HTMLIFrameElement): PostMessageTransport { if (!iframe.isConnected) {