Skip to content

Commit 853b5cb

Browse files
committed
feat: add isolatedContext routing for parallel agent page resolution
Add optional `isolatedContext` parameter to all page-dependent tools so parallel agents can resolve pages by context name instead of relying on the global selected-page pointer. When an agent creates a page with `new_page(isolatedContext: "my-agent")`, all subsequent tool calls can pass `isolatedContext: "my-agent"` to operate on the correct page without race conditions from other agents calling `select_page` concurrently. McpContext tracks per-context selected pages and resolvePageByContext() looks up the right page by context name. When the parameter is omitted, tools fall back to getSelectedPage() (fully backward compatible). Updated tools: take_screenshot, take_snapshot, wait_for, navigate_page, resize_page, emulate, click_at, fill, fill_form, upload_file, press_key, evaluate_script, performance_start_trace, performance_stop_trace, screencast_start.
1 parent 75b31c9 commit 853b5cb

11 files changed

Lines changed: 154 additions & 25 deletions

File tree

src/McpContext.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export class McpContext implements Context {
131131
#pages: Page[] = [];
132132
#pageToDevToolsPage = new Map<Page, Page>();
133133
#selectedPage?: Page;
134+
// Per-context selected page tracking for parallel agent support.
135+
#contextSelectedPage = new Map<string, Page>();
134136
#textSnapshot: TextSnapshot | null = null;
135137
#networkCollector: NetworkCollector;
136138
#consoleCollector: ConsoleCollector;
@@ -499,6 +501,41 @@ export class McpContext implements Context {
499501
return page;
500502
}
501503

504+
resolvePageByContext(isolatedContext?: string): Page {
505+
if (isolatedContext === undefined) {
506+
return this.getSelectedPage();
507+
}
508+
509+
// Try the per-context selected page first.
510+
const tracked = this.#contextSelectedPage.get(isolatedContext);
511+
if (tracked && !tracked.isClosed()) {
512+
return tracked;
513+
}
514+
515+
// Fall back: find any non-closed page in the context.
516+
const ctx = this.#isolatedContexts.get(isolatedContext);
517+
if (!ctx) {
518+
throw new Error(
519+
`No isolated context named "${isolatedContext}" exists. ` +
520+
`Create one first with new_page(isolatedContext: "${isolatedContext}").`,
521+
);
522+
}
523+
524+
for (const page of this.#pages) {
525+
if (
526+
!page.isClosed() &&
527+
this.#pageToIsolatedContextName.get(page) === isolatedContext
528+
) {
529+
this.#contextSelectedPage.set(isolatedContext, page);
530+
return page;
531+
}
532+
}
533+
534+
throw new Error(
535+
`No open page found in isolated context "${isolatedContext}".`,
536+
);
537+
}
538+
502539
getPageById(pageId: number): Page {
503540
const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
504541
if (!page) {
@@ -533,6 +570,12 @@ export class McpContext implements Context {
533570
void newPage.emulateFocusedPage(true).catch(error => {
534571
this.logger('Error turning on focused page emulation', error);
535572
});
573+
574+
// Track per-context selected page for parallel agent routing.
575+
const contextName = this.#pageToIsolatedContextName.get(newPage);
576+
if (contextName) {
577+
this.#contextSelectedPage.set(contextName, newPage);
578+
}
536579
}
537580

538581
#updateSelectedPageTimeouts() {
@@ -728,8 +771,9 @@ export class McpContext implements Context {
728771
async createTextSnapshot(
729772
verbose = false,
730773
devtoolsData: DevToolsData | undefined = undefined,
774+
targetPage?: Page,
731775
): Promise<void> {
732-
const page = this.getSelectedPage();
776+
const page = targetPage ?? this.getSelectedPage();
733777
const rootNode = await page.accessibility.snapshot({
734778
includeIframes: true,
735779
interestingOnly: !verbose,
@@ -880,8 +924,12 @@ export class McpContext implements Context {
880924
return this.#networkCollector.getIdForResource(request);
881925
}
882926

883-
waitForTextOnPage(text: string, timeout?: number): Promise<Element> {
884-
const page = this.getSelectedPage();
927+
waitForTextOnPage(
928+
text: string,
929+
timeout?: number,
930+
targetPage?: Page,
931+
): Promise<Element> {
932+
const page = targetPage ?? this.getSelectedPage();
885933
const frames = page.frames();
886934

887935
let locator = this.#locatorClass.race(

src/McpResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export class McpResponse implements Response {
238238
await context.createTextSnapshot(
239239
this.#snapshotParams.verbose,
240240
this.#devToolsData,
241+
this.#snapshotParams.page,
241242
);
242243
const textSnapshot = context.getTextSnapshot();
243244
if (textSnapshot) {

src/tools/ToolDefinition.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface ImageContentData {
5353
export interface SnapshotParams {
5454
verbose?: boolean;
5555
filePath?: string;
56+
page?: Page;
5657
}
5758

5859
export interface DevToolsData {
@@ -107,6 +108,7 @@ export type Context = Readonly<{
107108
recordedTraces(): TraceResult[];
108109
storeTraceRecording(result: TraceResult): void;
109110
getSelectedPage(): Page;
111+
resolvePageByContext(isolatedContext?: string): Page;
110112
getDialog(): Dialog | undefined;
111113
clearDialog(): void;
112114
getPageById(pageId: number): Page;
@@ -144,7 +146,11 @@ export type Context = Readonly<{
144146
action: () => Promise<unknown>,
145147
options?: {timeout?: number},
146148
): Promise<void>;
147-
waitForTextOnPage(text: string, timeout?: number): Promise<Element>;
149+
waitForTextOnPage(
150+
text: string,
151+
timeout?: number,
152+
page?: Page,
153+
): Promise<Element>;
148154
getDevToolsData(): Promise<DevToolsData>;
149155
/**
150156
* Returns a reqid for a cdpRequestId.
@@ -173,6 +179,18 @@ export function defineTool<Schema extends zod.ZodRawShape>(
173179
export const CLOSE_PAGE_ERROR =
174180
'The last open page cannot be closed. It is fine to keep it open.';
175181

182+
export const isolatedContextSchema = {
183+
isolatedContext: zod
184+
.string()
185+
.optional()
186+
.describe(
187+
'The name of the isolated browser context to resolve the page from. ' +
188+
'When provided, the tool operates on the page belonging to this context ' +
189+
'instead of the globally selected page. ' +
190+
'Use this to avoid race conditions when multiple agents work in parallel.',
191+
),
192+
};
193+
176194
export const timeoutSchema = {
177195
timeout: zod
178196
.number()

src/tools/emulation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
99

1010
import {ToolCategory} from './categories.js';
11-
import {defineTool} from './ToolDefinition.js';
11+
import {defineTool, isolatedContextSchema} from './ToolDefinition.js';
1212

1313
const throttlingOptions: [string, ...string[]] = [
1414
'No emulation',
@@ -24,6 +24,7 @@ export const emulate = defineTool({
2424
readOnlyHint: false,
2525
},
2626
schema: {
27+
...isolatedContextSchema,
2728
networkConditions: zod
2829
.enum(throttlingOptions)
2930
.optional()

src/tools/input.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
import {logger} from '../logger.js';
88
import type {McpContext, TextSnapshotNode} from '../McpContext.js';
99
import {zod} from '../third_party/index.js';
10-
import type {ElementHandle} from '../third_party/index.js';
10+
import type {ElementHandle, Page} from '../third_party/index.js';
1111
import {parseKey} from '../utils/keyboard.js';
1212

1313
import {ToolCategory} from './categories.js';
14-
import {defineTool} from './ToolDefinition.js';
14+
import {defineTool, isolatedContextSchema} from './ToolDefinition.js';
1515

1616
const dblClickSchema = zod
1717
.boolean()
@@ -83,13 +83,16 @@ export const clickAt = defineTool({
8383
conditions: ['computerVision'],
8484
},
8585
schema: {
86+
...isolatedContextSchema,
8687
x: zod.number().describe('The x coordinate'),
8788
y: zod.number().describe('The y coordinate'),
8889
dblClick: dblClickSchema,
8990
includeSnapshot: includeSnapshotSchema,
9091
},
9192
handler: async (request, response, context) => {
92-
const page = context.getSelectedPage();
93+
const page = context.resolvePageByContext(
94+
request.params.isolatedContext,
95+
);
9396
await context.waitForEventsAfterAction(async () => {
9497
await page.mouse.click(request.params.x, request.params.y, {
9598
clickCount: request.params.dblClick ? 2 : 1,
@@ -185,6 +188,7 @@ async function fillFormElement(
185188
uid: string,
186189
value: string,
187190
context: McpContext,
191+
page?: Page,
188192
) {
189193
const handle = await context.getElementByUid(uid);
190194
try {
@@ -196,8 +200,9 @@ async function fillFormElement(
196200
} else {
197201
// Increase timeout for longer input values.
198202
const timeoutPerChar = 10; // ms
203+
const targetPage = page ?? context.getSelectedPage();
199204
const fillTimeout =
200-
context.getSelectedPage().getDefaultTimeout() +
205+
targetPage.getDefaultTimeout() +
201206
value.length * timeoutPerChar;
202207
await handle.asLocator().setTimeout(fillTimeout).fill(value);
203208
}
@@ -216,6 +221,7 @@ export const fill = defineTool({
216221
readOnlyHint: false,
217222
},
218223
schema: {
224+
...isolatedContextSchema,
219225
uid: zod
220226
.string()
221227
.describe(
@@ -225,11 +231,15 @@ export const fill = defineTool({
225231
includeSnapshot: includeSnapshotSchema,
226232
},
227233
handler: async (request, response, context) => {
234+
const page = context.resolvePageByContext(
235+
request.params.isolatedContext,
236+
);
228237
await context.waitForEventsAfterAction(async () => {
229238
await fillFormElement(
230239
request.params.uid,
231240
request.params.value,
232241
context as McpContext,
242+
page,
233243
);
234244
});
235245
response.appendResponseLine(`Successfully filled out the element`);
@@ -279,6 +289,7 @@ export const fillForm = defineTool({
279289
readOnlyHint: false,
280290
},
281291
schema: {
292+
...isolatedContextSchema,
282293
elements: zod
283294
.array(
284295
zod.object({
@@ -290,12 +301,16 @@ export const fillForm = defineTool({
290301
includeSnapshot: includeSnapshotSchema,
291302
},
292303
handler: async (request, response, context) => {
304+
const page = context.resolvePageByContext(
305+
request.params.isolatedContext,
306+
);
293307
for (const element of request.params.elements) {
294308
await context.waitForEventsAfterAction(async () => {
295309
await fillFormElement(
296310
element.uid,
297311
element.value,
298312
context as McpContext,
313+
page,
299314
);
300315
});
301316
}
@@ -314,6 +329,7 @@ export const uploadFile = defineTool({
314329
readOnlyHint: false,
315330
},
316331
schema: {
332+
...isolatedContextSchema,
317333
uid: zod
318334
.string()
319335
.describe(
@@ -335,7 +351,9 @@ export const uploadFile = defineTool({
335351
// a type=file element. In this case, we want to default to
336352
// Page.waitForFileChooser() and upload the file this way.
337353
try {
338-
const page = context.getSelectedPage();
354+
const page = context.resolvePageByContext(
355+
request.params.isolatedContext,
356+
);
339357
const [fileChooser] = await Promise.all([
340358
page.waitForFileChooser({timeout: 3000}),
341359
handle.asLocator().click(),
@@ -365,6 +383,7 @@ export const pressKey = defineTool({
365383
readOnlyHint: false,
366384
},
367385
schema: {
386+
...isolatedContextSchema,
368387
key: zod
369388
.string()
370389
.describe(
@@ -373,7 +392,9 @@ export const pressKey = defineTool({
373392
includeSnapshot: includeSnapshotSchema,
374393
},
375394
handler: async (request, response, context) => {
376-
const page = context.getSelectedPage();
395+
const page = context.resolvePageByContext(
396+
request.params.isolatedContext,
397+
);
377398
const tokens = parseKey(request.params.key);
378399
const [key, ...modifiers] = tokens;
379400

src/tools/pages.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import type {Dialog} from '../third_party/index.js';
99
import {zod} from '../third_party/index.js';
1010

1111
import {ToolCategory} from './categories.js';
12-
import {CLOSE_PAGE_ERROR, defineTool, timeoutSchema} from './ToolDefinition.js';
12+
import {
13+
CLOSE_PAGE_ERROR,
14+
defineTool,
15+
isolatedContextSchema,
16+
timeoutSchema,
17+
} from './ToolDefinition.js';
1318

1419
export const listPages = defineTool({
1520
name: 'list_pages',
@@ -130,6 +135,7 @@ export const navigatePage = defineTool({
130135
readOnlyHint: false,
131136
},
132137
schema: {
138+
...isolatedContextSchema,
133139
type: zod
134140
.enum(['url', 'back', 'forward', 'reload'])
135141
.optional()
@@ -156,7 +162,9 @@ export const navigatePage = defineTool({
156162
...timeoutSchema,
157163
},
158164
handler: async (request, response, context) => {
159-
const page = context.getSelectedPage();
165+
const page = context.resolvePageByContext(
166+
request.params.isolatedContext,
167+
);
160168
const options = {
161169
timeout: request.params.timeout,
162170
};
@@ -279,11 +287,14 @@ export const resizePage = defineTool({
279287
readOnlyHint: false,
280288
},
281289
schema: {
290+
...isolatedContextSchema,
282291
width: zod.number().describe('Page width'),
283292
height: zod.number().describe('Page height'),
284293
},
285294
handler: async (request, response, context) => {
286-
const page = context.getSelectedPage();
295+
const page = context.resolvePageByContext(
296+
request.params.isolatedContext,
297+
);
287298

288299
try {
289300
const browser = page.browser();

src/tools/performance.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717

1818
import {ToolCategory} from './categories.js';
1919
import type {Context, Response} from './ToolDefinition.js';
20-
import {defineTool} from './ToolDefinition.js';
20+
import {defineTool, isolatedContextSchema} from './ToolDefinition.js';
2121

2222
const filePathSchema = zod
2323
.string()
@@ -34,6 +34,7 @@ export const startTrace = defineTool({
3434
readOnlyHint: false,
3535
},
3636
schema: {
37+
...isolatedContextSchema,
3738
reload: zod
3839
.boolean()
3940
.describe(
@@ -55,7 +56,9 @@ export const startTrace = defineTool({
5556
}
5657
context.setIsRunningPerformanceTrace(true);
5758

58-
const page = context.getSelectedPage();
59+
const page = context.resolvePageByContext(
60+
request.params.isolatedContext,
61+
);
5962
const pageUrlForTracing = page.url();
6063

6164
if (request.params.reload) {
@@ -121,13 +124,16 @@ export const stopTrace = defineTool({
121124
readOnlyHint: false,
122125
},
123126
schema: {
127+
...isolatedContextSchema,
124128
filePath: filePathSchema,
125129
},
126130
handler: async (request, response, context) => {
127131
if (!context.isRunningPerformanceTrace()) {
128132
return;
129133
}
130-
const page = context.getSelectedPage();
134+
const page = context.resolvePageByContext(
135+
request.params.isolatedContext,
136+
);
131137
await stopTracingAndAppendOutput(
132138
page,
133139
response,

0 commit comments

Comments
 (0)