Skip to content

Commit a8609c8

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 d0622d5 commit a8609c8

15 files changed

Lines changed: 479 additions & 49 deletions

src/McpContext.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export class McpContext implements Context {
140140

141141
#pageToDevToolsPage = new Map<Page, Page>();
142142
#selectedPage?: Page;
143+
// Per-context selected page tracking for parallel agent support.
144+
#contextSelectedPage = new Map<string, Page>();
143145
#textSnapshot: TextSnapshot | null = null;
144146
#networkCollector: NetworkCollector;
145147
#consoleCollector: ConsoleCollector;
@@ -328,15 +330,18 @@ export class McpContext implements Context {
328330
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
329331
}
330332

331-
async emulate(options: {
332-
networkConditions?: string | null;
333-
cpuThrottlingRate?: number | null;
334-
geolocation?: GeolocationOptions | null;
335-
userAgent?: string | null;
336-
colorScheme?: 'dark' | 'light' | 'auto' | null;
337-
viewport?: Viewport | null;
338-
}): Promise<void> {
339-
const page = this.getSelectedPage();
333+
async emulate(
334+
options: {
335+
networkConditions?: string | null;
336+
cpuThrottlingRate?: number | null;
337+
geolocation?: GeolocationOptions | null;
338+
userAgent?: string | null;
339+
colorScheme?: 'dark' | 'light' | 'auto' | null;
340+
viewport?: Viewport | null;
341+
},
342+
targetPage?: Page,
343+
): Promise<void> {
344+
const page = targetPage ?? this.getSelectedPage();
340345
const currentSettings = this.#emulationSettingsMap.get(page) ?? {};
341346
const newSettings: EmulationSettings = {...currentSettings};
342347
let timeoutsNeedUpdate = false;
@@ -513,6 +518,41 @@ export class McpContext implements Context {
513518
return page;
514519
}
515520

521+
resolvePageByContext(isolatedContext?: string): Page {
522+
if (isolatedContext === undefined) {
523+
return this.getSelectedPage();
524+
}
525+
526+
// Try the per-context selected page first.
527+
const tracked = this.#contextSelectedPage.get(isolatedContext);
528+
if (tracked && !tracked.isClosed()) {
529+
return tracked;
530+
}
531+
532+
// Fall back: find any non-closed page in the context.
533+
const ctx = this.#isolatedContexts.get(isolatedContext);
534+
if (!ctx) {
535+
throw new Error(
536+
`No isolated context named "${isolatedContext}" exists. ` +
537+
`Create one first with new_page(isolatedContext: "${isolatedContext}").`,
538+
);
539+
}
540+
541+
for (const page of this.#pages) {
542+
if (
543+
!page.isClosed() &&
544+
this.#pageToIsolatedContextName.get(page) === isolatedContext
545+
) {
546+
this.#contextSelectedPage.set(isolatedContext, page);
547+
return page;
548+
}
549+
}
550+
551+
throw new Error(
552+
`No open page found in isolated context "${isolatedContext}".`,
553+
);
554+
}
555+
516556
getPageById(pageId: number): Page {
517557
const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
518558
if (!page) {
@@ -547,6 +587,12 @@ export class McpContext implements Context {
547587
void newPage.emulateFocusedPage(true).catch(error => {
548588
this.logger('Error turning on focused page emulation', error);
549589
});
590+
591+
// Track per-context selected page for parallel agent routing.
592+
const contextName = this.#pageToIsolatedContextName.get(newPage);
593+
if (contextName) {
594+
this.#contextSelectedPage.set(contextName, newPage);
595+
}
550596
}
551597

552598
#updateSelectedPageTimeouts() {
@@ -787,8 +833,9 @@ export class McpContext implements Context {
787833
async createTextSnapshot(
788834
verbose = false,
789835
devtoolsData: DevToolsData | undefined = undefined,
836+
targetPage?: Page,
790837
): Promise<void> {
791-
const page = this.getSelectedPage();
838+
const page = targetPage ?? this.getSelectedPage();
792839
const rootNode = await page.accessibility.snapshot({
793840
includeIframes: true,
794841
interestingOnly: !verbose,
@@ -939,8 +986,12 @@ export class McpContext implements Context {
939986
return this.#networkCollector.getIdForResource(request);
940987
}
941988

942-
waitForTextOnPage(text: string[], timeout?: number): Promise<Element> {
943-
const page = this.getSelectedPage();
989+
waitForTextOnPage(
990+
text: string[],
991+
timeout?: number,
992+
targetPage?: Page,
993+
): Promise<Element> {
994+
const page = targetPage ?? this.getSelectedPage();
944995
const frames = page.frames();
945996

946997
let locator = this.#locatorClass.race(

src/McpResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export class McpResponse implements Response {
253253
await context.createTextSnapshot(
254254
this.#snapshotParams.verbose,
255255
this.#devToolsData,
256+
this.#snapshotParams.page,
256257
);
257258
const textSnapshot = context.getTextSnapshot();
258259
if (textSnapshot) {

src/tools/ToolDefinition.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ImageContentData {
5454
export interface SnapshotParams {
5555
verbose?: boolean;
5656
filePath?: string;
57+
page?: Page;
5758
}
5859

5960
export interface DevToolsData {
@@ -108,6 +109,7 @@ export type Context = Readonly<{
108109
recordedTraces(): TraceResult[];
109110
storeTraceRecording(result: TraceResult): void;
110111
getSelectedPage(): Page;
112+
resolvePageByContext(isolatedContext?: string): Page;
111113
getDialog(): Dialog | undefined;
112114
clearDialog(): void;
113115
getPageById(pageId: number): Page;
@@ -116,14 +118,23 @@ export type Context = Readonly<{
116118
selectPage(page: Page): void;
117119
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
118120
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
119-
emulate(options: {
120-
networkConditions?: string | null;
121-
cpuThrottlingRate?: number | null;
122-
geolocation?: GeolocationOptions | null;
123-
userAgent?: string | null;
124-
colorScheme?: 'dark' | 'light' | 'auto' | null;
125-
viewport?: Viewport | null;
126-
}): Promise<void>;
121+
emulate(
122+
options: {
123+
networkConditions?: string | null;
124+
cpuThrottlingRate?: number | null;
125+
geolocation?: GeolocationOptions | null;
126+
userAgent?: string | null;
127+
colorScheme?: 'dark' | 'light' | 'auto' | null;
128+
viewport?: Viewport | null;
129+
},
130+
targetPage?: Page,
131+
): Promise<void>;
132+
getNetworkConditions(): string | null;
133+
getCpuThrottlingRate(): number;
134+
getGeolocation(): GeolocationOptions | null;
135+
getViewport(): Viewport | null;
136+
getUserAgent(): string | null;
137+
getColorScheme(): 'dark' | 'light' | null;
127138
saveTemporaryFile(
128139
data: Uint8Array<ArrayBufferLike>,
129140
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
@@ -136,7 +147,11 @@ export type Context = Readonly<{
136147
action: () => Promise<unknown>,
137148
options?: {timeout?: number},
138149
): Promise<void>;
139-
waitForTextOnPage(text: string[], timeout?: number): Promise<Element>;
150+
waitForTextOnPage(
151+
text: string[],
152+
timeout?: number,
153+
page?: Page,
154+
): Promise<Element>;
140155
getDevToolsData(): Promise<DevToolsData>;
141156
/**
142157
* Returns a reqid for a cdpRequestId.
@@ -181,6 +196,18 @@ export function defineTool<
181196
export const CLOSE_PAGE_ERROR =
182197
'The last open page cannot be closed. It is fine to keep it open.';
183198

199+
export const isolatedContextSchema = {
200+
isolatedContext: zod
201+
.string()
202+
.optional()
203+
.describe(
204+
'The name of the isolated browser context to resolve the page from. ' +
205+
'When provided, the tool operates on the page belonging to this context ' +
206+
'instead of the globally selected page. ' +
207+
'Use this to avoid race conditions when multiple agents work in parallel.',
208+
),
209+
};
210+
184211
export const timeoutSchema = {
185212
timeout: zod
186213
.number()

src/tools/emulation.ts

Lines changed: 6 additions & 2 deletions
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()
@@ -104,6 +105,9 @@ export const emulate = defineTool({
104105
),
105106
},
106107
handler: async (request, _response, context) => {
107-
await context.emulate(request.params);
108+
const page = context.resolvePageByContext(
109+
request.params.isolatedContext,
110+
);
111+
await context.emulate(request.params, page);
108112
},
109113
});

0 commit comments

Comments
 (0)