Skip to content

Commit 50146e4

Browse files
[feat]: add support for WebMCP tool invokation in iframes (#2878)
# why before this PR, WebMCP tools registered inside of iframes were unable to be discovered or invoked # what changed - changed WebMCP discovery to enable and collect tools from the page's main CDP session and every adopted OOPIF session - routed invocation, response handling, & cancellation through the CDP session that owns the tool's frame - stale tools are now rejected instead of falling back to the main frame, & only affected invocations are cleaned up when an OOPIF detaches - added extension coverage for cross-session discovery, invocation, cancellation, response correlation, frame detachment, disposal, & lifecycle races # test plan - [x] Extension discovery tests cover combined main/OOPIF snapshots, child-session removals, stable session snapshots, next-call discovery after adoption, & listener cleanup after child enable failure. - [x] Extension invocation tests cover child-session invocation, response, cancellation, wrong-session events, targeted detach cleanup, detach during command setup, page disposal, duplicate invocation IDs, unknown frames, & failed-command listener cleanup. - [x] Browser integration coverage verifies distinct main/child CDP targets and sessions, successful iframe tool discovery and invocation, structured output, & stale-tool rejection after iframe removal. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic WebMCP tools registered in out-of-process iframes (OOPIFs) are now discoverable and invokable; previously iframe tools were invisible and unusable. Invocation, response handling, and cancellation use the CDP session that owns the frame, while stale or detached frames fail instead of falling back to the main frame. - Discovery enables WebMCP on the main session and adopted OOPIF sessions, then combines their snapshots. - Detaching an OOPIF rejects only its pending invocations and removes its listeners. - Added extension and SDK integration coverage for cross-session lifecycle, cleanup, and iframe removal. <sup>Written for commit 2a0744a. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/browserbase/stagehand/pull/2878?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent e2c8946 commit 50146e4

6 files changed

Lines changed: 584 additions & 69 deletions

File tree

‎.changeset/crisp-cats-cry.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@browserbasehq/stagehand-extension": patch
3+
"@browserbasehq/stagehand-go": patch
4+
"@browserbasehq/stagehand": patch
5+
"@browserbasehq/stagehand-python": patch
6+
---
7+
8+
support discovering and invoking WebMCP tools in out-of-process iframes (OOPIFs)

‎packages/extension/tests/page-webmcp-invocations.test.ts‎

Lines changed: 243 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { BrowserContext } from "../understudy/context.js";
77
import { Page } from "../understudy/page.js";
88

99
class FakeCDPSession implements CDPSessionLike {
10-
readonly id = "main";
1110
readonly calls: Array<{ method: string; params?: object }> = [];
1211
readonly handlers = new Map<string, Set<(params: unknown) => void>>();
1312

1413
constructor(
1514
readonly responses: Record<string, (session: FakeCDPSession, params?: object) => unknown> = {},
15+
readonly id: string = "main",
1616
) {}
1717

1818
async send<Result = unknown>(method: string, params?: object): Promise<Result> {
@@ -53,6 +53,14 @@ function createPage(session: FakeCDPSession): Page {
5353
return new Page(connection, session, "target-1", "frame-1", {} as StagehandLogger);
5454
}
5555

56+
function addSameProcessChild(page: Page, session: FakeCDPSession): void {
57+
page.onFrameAttached("frame-2", "frame-1", session);
58+
}
59+
60+
function adoptChildSession(page: Page, session: FakeCDPSession): void {
61+
page.adoptOopifSession(session, "frame-2");
62+
}
63+
5664
function createContextPage(session: FakeCDPSession): { context: BrowserContext; page: Page } {
5765
const connection = {
5866
connected: true,
@@ -78,6 +86,7 @@ describe("Page WebMCP invocation lifecycle", () => {
7886
"WebMCP.invokeTool": () => ({ invocationId: `invocation-${++invocation}` }),
7987
});
8088
const page = createPage(session);
89+
addSameProcessChild(page, session);
8190

8291
await expect(
8392
page.invokeWebMCPTool("frame-2", "search", {
@@ -117,6 +126,97 @@ describe("Page WebMCP invocation lifecycle", () => {
117126
expect(session.listenerCount("WebMCP.toolResponded")).toBe(1);
118127
});
119128

129+
it("routes an OOPIF invocation, response, and cancellation through its child session", async () => {
130+
const session = new FakeCDPSession();
131+
const childSession = new FakeCDPSession(
132+
{
133+
"WebMCP.invokeTool": () => ({ invocationId: "child-invocation" }),
134+
},
135+
"child",
136+
);
137+
const page = createPage(session);
138+
adoptChildSession(page, childSession);
139+
140+
await expect(
141+
page.invokeWebMCPTool("frame-2", "search", {
142+
input: { searchQuery: "Stagehand" },
143+
}),
144+
).resolves.toStrictEqual({
145+
invocationId: "child-invocation",
146+
toolName: "search",
147+
frameId: "frame-2",
148+
input: { searchQuery: "Stagehand" },
149+
});
150+
expect(session.callsFor("WebMCP.invokeTool")).toHaveLength(0);
151+
expect(session.listenerCount("WebMCP.toolResponded")).toBe(0);
152+
expect(childSession.callsFor("WebMCP.invokeTool")).toStrictEqual([
153+
{
154+
method: "WebMCP.invokeTool",
155+
params: {
156+
frameId: "frame-2",
157+
toolName: "search",
158+
input: { searchQuery: "Stagehand" },
159+
},
160+
},
161+
]);
162+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(1);
163+
164+
const result = page.waitForWebMCPInvocationResult("child-invocation");
165+
childSession.emit<Protocol.WebMCP.ToolRespondedEvent>("WebMCP.toolResponded", {
166+
invocationId: "child-invocation",
167+
status: "Completed",
168+
output: { source: "child" },
169+
});
170+
await expect(result).resolves.toStrictEqual({
171+
invocationId: "child-invocation",
172+
status: "Completed",
173+
output: { source: "child" },
174+
});
175+
176+
await page.cancelWebMCPInvocation("child-invocation");
177+
expect(session.callsFor("WebMCP.cancelInvocation")).toHaveLength(0);
178+
expect(childSession.callsFor("WebMCP.cancelInvocation")).toStrictEqual([
179+
{
180+
method: "WebMCP.cancelInvocation",
181+
params: { invocationId: "child-invocation" },
182+
},
183+
]);
184+
});
185+
186+
it("ignores a matching invocation response emitted by the wrong session", async () => {
187+
const session = new FakeCDPSession({
188+
"WebMCP.invokeTool": () => ({ invocationId: "main-invocation" }),
189+
});
190+
const childSession = new FakeCDPSession(
191+
{
192+
"WebMCP.invokeTool": () => ({ invocationId: "child-invocation" }),
193+
},
194+
"child",
195+
);
196+
const page = createPage(session);
197+
adoptChildSession(page, childSession);
198+
await page.invokeWebMCPTool("frame-1", "main");
199+
await page.invokeWebMCPTool("frame-2", "child");
200+
201+
const result = page.waitForWebMCPInvocationResult("child-invocation");
202+
session.emit<Protocol.WebMCP.ToolRespondedEvent>("WebMCP.toolResponded", {
203+
invocationId: "child-invocation",
204+
status: "Completed",
205+
output: { source: "wrong" },
206+
});
207+
childSession.emit<Protocol.WebMCP.ToolRespondedEvent>("WebMCP.toolResponded", {
208+
invocationId: "child-invocation",
209+
status: "Completed",
210+
output: { source: "child" },
211+
});
212+
213+
await expect(result).resolves.toStrictEqual({
214+
invocationId: "child-invocation",
215+
status: "Completed",
216+
output: { source: "child" },
217+
});
218+
});
219+
120220
it.each([
121221
{
122222
status: "Completed" as const,
@@ -323,15 +423,155 @@ describe("Page WebMCP invocation lifecycle", () => {
323423
expect(session.listenerCount("WebMCP.toolResponded")).toBe(0);
324424
});
325425

326-
it("removes an idle response listener when invocation fails", async () => {
426+
it("detaching an OOPIF rejects only that session's pending invocations", async () => {
427+
const session = new FakeCDPSession({
428+
"WebMCP.invokeTool": () => ({ invocationId: "main-invocation" }),
429+
});
430+
const childSession = new FakeCDPSession(
431+
{
432+
"WebMCP.invokeTool": () => ({ invocationId: "child-invocation" }),
433+
},
434+
"child",
435+
);
436+
const page = createPage(session);
437+
adoptChildSession(page, childSession);
438+
await page.invokeWebMCPTool("frame-1", "main");
439+
await page.invokeWebMCPTool("frame-2", "child");
440+
const mainResult = page.waitForWebMCPInvocationResult("main-invocation");
441+
const childResult = page.waitForWebMCPInvocationResult("child-invocation");
442+
const childRejection = expect(childResult).rejects.toThrow(
443+
'WebMCP invocation "child-invocation" was disposed before it completed because its frame detached from page "target-1".',
444+
);
445+
446+
page.detachOopifSession("child");
447+
448+
await childRejection;
449+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(0);
450+
expect(session.listenerCount("WebMCP.toolResponded")).toBe(1);
451+
await expect(page.waitForWebMCPInvocationResult("child-invocation")).rejects.toThrow(
452+
'WebMCP invocation "child-invocation" was not found on page "target-1".',
453+
);
454+
455+
session.emit<Protocol.WebMCP.ToolRespondedEvent>("WebMCP.toolResponded", {
456+
invocationId: "main-invocation",
457+
status: "Completed",
458+
});
459+
await expect(mainResult).resolves.toStrictEqual({
460+
invocationId: "main-invocation",
461+
status: "Completed",
462+
});
463+
});
464+
465+
it("does not register an invocation whose child session detached during the command", async () => {
466+
let resolveInvocation!: (response: Protocol.WebMCP.InvokeToolResponse) => void;
467+
const session = new FakeCDPSession();
468+
const childSession = new FakeCDPSession(
469+
{
470+
"WebMCP.invokeTool": () =>
471+
new Promise<Protocol.WebMCP.InvokeToolResponse>((resolve) => {
472+
resolveInvocation = resolve;
473+
}),
474+
},
475+
"child",
476+
);
477+
const page = createPage(session);
478+
adoptChildSession(page, childSession);
479+
const invocation = page.invokeWebMCPTool("frame-2", "child");
480+
await Promise.resolve();
481+
482+
page.detachOopifSession("child");
483+
resolveInvocation({ invocationId: "orphaned-invocation" });
484+
485+
await expect(invocation).rejects.toThrow(
486+
'WebMCP session for frame "frame-2" was disposed before invocation registration completed on page "target-1".',
487+
);
488+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(0);
489+
await expect(page.waitForWebMCPInvocationResult("orphaned-invocation")).rejects.toThrow(
490+
'WebMCP invocation "orphaned-invocation" was not found on page "target-1".',
491+
);
492+
});
493+
494+
it("page disposal rejects invocations and removes listeners across every session", async () => {
495+
const session = new FakeCDPSession({
496+
"WebMCP.invokeTool": () => ({ invocationId: "main-invocation" }),
497+
});
498+
const childSession = new FakeCDPSession(
499+
{
500+
"WebMCP.invokeTool": () => ({ invocationId: "child-invocation" }),
501+
},
502+
"child",
503+
);
504+
const page = createPage(session);
505+
adoptChildSession(page, childSession);
506+
await page.invokeWebMCPTool("frame-1", "main");
507+
await page.invokeWebMCPTool("frame-2", "child");
508+
const mainResult = page.waitForWebMCPInvocationResult("main-invocation");
509+
const childResult = page.waitForWebMCPInvocationResult("child-invocation");
510+
const mainRejection = expect(mainResult).rejects.toThrow(
511+
'WebMCP invocation "main-invocation" was disposed before it completed on page "target-1".',
512+
);
513+
const childRejection = expect(childResult).rejects.toThrow(
514+
'WebMCP invocation "child-invocation" was disposed before it completed on page "target-1".',
515+
);
516+
517+
page.dispose();
518+
519+
await Promise.all([mainRejection, childRejection]);
520+
expect(session.listenerCount("WebMCP.toolResponded")).toBe(0);
521+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(0);
522+
});
523+
524+
it("rejects duplicate invocation IDs returned by different sessions", async () => {
525+
const session = new FakeCDPSession({
526+
"WebMCP.invokeTool": () => ({ invocationId: "duplicate" }),
527+
});
528+
const childSession = new FakeCDPSession(
529+
{
530+
"WebMCP.invokeTool": () => ({ invocationId: "duplicate" }),
531+
},
532+
"child",
533+
);
534+
const page = createPage(session);
535+
adoptChildSession(page, childSession);
536+
await page.invokeWebMCPTool("frame-1", "main");
537+
538+
await expect(page.invokeWebMCPTool("frame-2", "child")).rejects.toThrow(
539+
'WebMCP returned duplicate invocation ID "duplicate".',
540+
);
541+
expect(session.listenerCount("WebMCP.toolResponded")).toBe(1);
542+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(0);
543+
});
544+
545+
it("rejects an unknown frame without installing a listener or sending an invocation", async () => {
327546
const session = new FakeCDPSession({
328547
"WebMCP.invokeTool": () => {
329548
throw new Error("Tool not found");
330549
},
331550
});
332551
const page = createPage(session);
333552

334-
await expect(page.invokeWebMCPTool("stale-frame", "search")).rejects.toThrow("Tool not found");
553+
await expect(page.invokeWebMCPTool("stale-frame", "search")).rejects.toThrow(
554+
'WebMCP frame "stale-frame" was not found on page "target-1" or has detached.',
555+
);
556+
expect(session.callsFor("WebMCP.invokeTool")).toHaveLength(0);
557+
expect(session.listenerCount("WebMCP.toolResponded")).toBe(0);
558+
});
559+
560+
it("removes an idle child-session response listener when invocation fails", async () => {
561+
const session = new FakeCDPSession();
562+
const childSession = new FakeCDPSession(
563+
{
564+
"WebMCP.invokeTool": () => {
565+
throw new Error("Tool not found");
566+
},
567+
},
568+
"child",
569+
);
570+
const page = createPage(session);
571+
adoptChildSession(page, childSession);
572+
573+
await expect(page.invokeWebMCPTool("frame-2", "search")).rejects.toThrow("Tool not found");
335574
expect(session.listenerCount("WebMCP.toolResponded")).toBe(0);
575+
expect(childSession.listenerCount("WebMCP.toolResponded")).toBe(0);
336576
});
337577
});

0 commit comments

Comments
 (0)