Skip to content

Commit 1257f02

Browse files
committed
refactor: add request-scoped page routing, assertPageIsFocused, and Context cleanup
- Add #requestPage / #resolveTargetPage() on McpContext so data-retrieval methods (console, network, emulation getters, DevTools data) automatically resolve the correct page for pageScoped tool requests under toolMutex. - Mark console and network tools pageScoped: true so they receive pageId routing like other page-aware tools. - Add assertPageIsFocused() for keyboard tools (press_key, type_text, click_at) to detect when a page is not the active page in its browser context and throw an actionable error with the correct pageId. - Merge getElementByUid and assertUidOnSelectedPage into a single method with optional page parameter for scoped search (pageScoped tools) vs cross-page search with context-focus validation (uid-based tools). - Remove unused Context interface methods: resolvePageById, resolveCdpElementId, and 6 emulation getters already removed upstream. - Clean up orphaned #mcpPages and #focusedPagePerContext entries in createPagesSnapshot(). - Remove dead code: fillFormElement page parameter made required since all callers now provide it. - Regenerate tool-reference.md. - Add unit tests for page-scoped getElementByUid and context-focus validation, plus eval scenario for assertPageIsFocused recovery flow.
1 parent d35bbf9 commit 1257f02

13 files changed

Lines changed: 579 additions & 124 deletions

File tree

docs/tool-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run docs' to update-->
22

3-
# Chrome DevTools MCP Tool Reference (~7472 cl100k_base tokens)
3+
# Chrome DevTools MCP Tool Reference (~7634 cl100k_base tokens)
44

55
- **[Input automation](#input-automation)** (9 tools)
66
- [`click`](#click)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import type {TestScenario} from '../eval_gemini.ts';
10+
11+
export const scenario: TestScenario = {
12+
prompt: `Open two pages in the same isolated context "session":
13+
- Page 1 at data:text/html,<textarea id="ta"></textarea>
14+
- Page 2 at data:text/html,<h1>Other</h1>
15+
16+
Now press_key "a" on Page 1 without selecting it first. If you encounter any errors, recover from them.`,
17+
maxTurns: 10,
18+
expectations: calls => {
19+
// Should open 2 pages in the same context.
20+
const newPages = calls.filter(c => c.name === 'new_page');
21+
assert.strictEqual(newPages.length, 2, 'Should open 2 pages');
22+
assert.strictEqual(newPages[0].args.isolatedContext, 'session');
23+
assert.strictEqual(newPages[1].args.isolatedContext, 'session');
24+
25+
// Should attempt press_key at least once.
26+
const pressKeys = calls.filter(c => c.name === 'press_key');
27+
assert.ok(pressKeys.length >= 1, 'Should attempt press_key');
28+
29+
// Should call select_page to recover after the error.
30+
const selectPages = calls.filter(c => c.name === 'select_page');
31+
assert.ok(
32+
selectPages.length >= 1,
33+
'Should select_page to recover from the focus error',
34+
);
35+
36+
const firstPressKeyIndex = calls.indexOf(pressKeys[0]);
37+
const firstSelectPageIndex = calls.indexOf(selectPages[0]);
38+
39+
if (firstPressKeyIndex < firstSelectPageIndex) {
40+
// Error path: press_key was attempted first and failed.
41+
// Verify recovery: must have a second press_key after select_page.
42+
assert.ok(
43+
pressKeys.length >= 2,
44+
'Should retry press_key after error recovery',
45+
);
46+
const lastPressKeyIndex = calls.lastIndexOf(pressKeys.at(-1)!);
47+
assert.ok(
48+
firstSelectPageIndex < lastPressKeyIndex,
49+
'select_page should precede the successful press_key',
50+
);
51+
} else {
52+
// Proactive path: model selected page first.
53+
// Verify select_page came before press_key.
54+
assert.ok(
55+
firstSelectPageIndex < firstPressKeyIndex,
56+
'select_page should precede press_key',
57+
);
58+
}
59+
},
60+
};

src/McpContext.ts

Lines changed: 106 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export class McpContext implements Context {
120120
null;
121121
#focusedPagePerContext = new Map<BrowserContext, Page>();
122122

123+
#requestPage?: Page;
124+
123125
#nextPageId = 1;
124126

125127
#nextSnapshotId = 1;
@@ -190,8 +192,16 @@ export class McpContext implements Context {
190192
return context;
191193
}
192194

195+
setRequestPage(page?: Page): void {
196+
this.#requestPage = page;
197+
}
198+
199+
#resolveTargetPage(): Page {
200+
return this.#requestPage ?? this.getSelectedPage();
201+
}
202+
193203
resolveCdpRequestId(cdpRequestId: string): number | undefined {
194-
const selectedPage = this.getSelectedPage();
204+
const selectedPage = this.#resolveTargetPage();
195205
if (!cdpRequestId) {
196206
this.logger('no network request');
197207
return;
@@ -239,19 +249,19 @@ export class McpContext implements Context {
239249
}
240250

241251
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
242-
const page = this.getSelectedPage();
252+
const page = this.#resolveTargetPage();
243253
return this.#networkCollector.getData(page, includePreservedRequests);
244254
}
245255

246256
getConsoleData(
247257
includePreservedMessages?: boolean,
248258
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
249-
const page = this.getSelectedPage();
259+
const page = this.#resolveTargetPage();
250260
return this.#consoleCollector.getData(page, includePreservedMessages);
251261
}
252262

253263
getDevToolsUniverse(): TargetUniverse | null {
254-
return this.#devtoolsUniverseManager.get(this.getSelectedPage());
264+
return this.#devtoolsUniverseManager.get(this.#resolveTargetPage());
255265
}
256266

257267
getConsoleMessageStableId(
@@ -263,7 +273,7 @@ export class McpContext implements Context {
263273
getConsoleMessageById(
264274
id: number,
265275
): ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError {
266-
return this.#consoleCollector.getById(this.getSelectedPage(), id);
276+
return this.#consoleCollector.getById(this.#resolveTargetPage(), id);
267277
}
268278

269279
async newPage(
@@ -305,7 +315,7 @@ export class McpContext implements Context {
305315
}
306316

307317
getNetworkRequestById(reqid: number): HTTPRequest {
308-
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
318+
return this.#networkCollector.getById(this.#resolveTargetPage(), reqid);
309319
}
310320

311321
async emulate(
@@ -422,27 +432,27 @@ export class McpContext implements Context {
422432
}
423433

424434
getNetworkConditions(): string | null {
425-
return this.#getSelectedMcpPage().networkConditions;
435+
return this.#getMcpPage(this.#resolveTargetPage()).networkConditions;
426436
}
427437

428438
getCpuThrottlingRate(): number {
429-
return this.#getSelectedMcpPage().cpuThrottlingRate;
439+
return this.#getMcpPage(this.#resolveTargetPage()).cpuThrottlingRate;
430440
}
431441

432442
getGeolocation(): GeolocationOptions | null {
433-
return this.#getSelectedMcpPage().geolocation;
443+
return this.#getMcpPage(this.#resolveTargetPage()).geolocation;
434444
}
435445

436446
getViewport(): Viewport | null {
437-
return this.#getSelectedMcpPage().viewport;
447+
return this.#getMcpPage(this.#resolveTargetPage()).viewport;
438448
}
439449

440450
getUserAgent(): string | null {
441-
return this.#getSelectedMcpPage().userAgent;
451+
return this.#getMcpPage(this.#resolveTargetPage()).userAgent;
442452
}
443453

444454
getColorScheme(): 'dark' | 'light' | null {
445-
return this.#getSelectedMcpPage().colorScheme;
455+
return this.#getMcpPage(this.#resolveTargetPage()).colorScheme;
446456
}
447457

448458
setIsRunningPerformanceTrace(x: boolean): void {
@@ -468,7 +478,7 @@ export class McpContext implements Context {
468478
}
469479

470480
getDialog(page?: Page): Dialog | undefined {
471-
const targetPage = page ?? this.#selectedPage;
481+
const targetPage = page ?? this.#requestPage ?? this.#selectedPage;
472482
if (!targetPage) {
473483
return undefined;
474484
}
@@ -530,6 +540,19 @@ export class McpContext implements Context {
530540
return this.#selectedPage === page;
531541
}
532542

543+
assertPageIsFocused(page: Page): void {
544+
const ctx = page.browserContext();
545+
const focused = this.#focusedPagePerContext.get(ctx);
546+
if (focused && focused !== page) {
547+
const targetId = this.#mcpPages.get(page)?.id ?? '?';
548+
const focusedId = this.#mcpPages.get(focused)?.id ?? '?';
549+
throw new Error(
550+
`Page ${targetId} is not the active page in its browser context (page ${focusedId} is). ` +
551+
`Call select_page with pageId ${targetId} first.`,
552+
);
553+
}
554+
}
555+
533556
selectPage(newPage: Page): void {
534557
const ctx = newPage.browserContext();
535558
const oldFocused = this.#focusedPagePerContext.get(ctx);
@@ -562,7 +585,7 @@ export class McpContext implements Context {
562585
}
563586

564587
getNavigationTimeout() {
565-
const page = this.getSelectedPage();
588+
const page = this.#resolveTargetPage();
566589
return page.getDefaultNavigationTimeout();
567590
}
568591

@@ -579,12 +602,39 @@ export class McpContext implements Context {
579602
return undefined;
580603
}
581604

582-
assertUidOnSelectedPage(uid: string): void {
583-
for (const [page, mcpPage] of this.#mcpPages.entries()) {
584-
if (mcpPage.textSnapshot?.idToNode.has(uid)) {
585-
const ctx = page.browserContext();
605+
async getElementByUid(
606+
uid: string,
607+
page?: Page,
608+
): Promise<ElementHandle<Element>> {
609+
if (page) {
610+
// Scoped search: only look in the target page's snapshot.
611+
const mcpPage = this.#mcpPages.get(page);
612+
if (!mcpPage?.textSnapshot) {
613+
throw new Error(
614+
`No snapshot found for page ${mcpPage?.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
615+
);
616+
}
617+
const node = mcpPage.textSnapshot.idToNode.get(uid);
618+
if (!node) {
619+
throw new Error(
620+
`Element uid "${uid}" not found on page ${mcpPage.id}.`,
621+
);
622+
}
623+
return this.#resolveElementHandle(node, uid);
624+
}
625+
626+
// Cross-page search with context-focus validation.
627+
let anySnapshot = false;
628+
for (const [searchPage, mcpPage] of this.#mcpPages.entries()) {
629+
if (!mcpPage.textSnapshot) {
630+
continue;
631+
}
632+
anySnapshot = true;
633+
const node = mcpPage.textSnapshot.idToNode.get(uid);
634+
if (node) {
635+
const ctx = searchPage.browserContext();
586636
const contextSelectedPage = this.#focusedPagePerContext.get(ctx);
587-
if (contextSelectedPage !== page) {
637+
if (contextSelectedPage !== searchPage) {
588638
const targetId = mcpPage.id;
589639
const selectedId = contextSelectedPage
590640
? this.#mcpPages.get(contextSelectedPage)?.id
@@ -595,37 +645,10 @@ export class McpContext implements Context {
595645
);
596646
}
597647
// Align global #selectedPage for waitForEventsAfterAction etc.
598-
if (this.#selectedPage !== page) {
599-
this.#selectedPage = page;
600-
}
601-
return;
602-
}
603-
}
604-
throw new Error('No such element found in any snapshot.');
605-
}
606-
607-
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
608-
let anySnapshot = false;
609-
// Search across all per-page snapshots for the uid.
610-
for (const mcpPage of this.#mcpPages.values()) {
611-
if (!mcpPage.textSnapshot) {
612-
continue;
613-
}
614-
anySnapshot = true;
615-
const node = mcpPage.textSnapshot.idToNode.get(uid);
616-
if (node) {
617-
const message = `Element with uid ${uid} no longer exists on the page.`;
618-
try {
619-
const handle = await node.elementHandle();
620-
if (!handle) {
621-
throw new Error(message);
622-
}
623-
return handle;
624-
} catch (error) {
625-
throw new Error(message, {
626-
cause: error,
627-
});
648+
if (this.#selectedPage !== searchPage) {
649+
this.#selectedPage = searchPage;
628650
}
651+
return this.#resolveElementHandle(node, uid);
629652
}
630653
}
631654
if (!anySnapshot) {
@@ -636,6 +659,24 @@ export class McpContext implements Context {
636659
throw new Error('No such element found in any snapshot.');
637660
}
638661

662+
async #resolveElementHandle(
663+
node: TextSnapshotNode,
664+
uid: string,
665+
): Promise<ElementHandle<Element>> {
666+
const message = `Element with uid ${uid} no longer exists on the page.`;
667+
try {
668+
const handle = await node.elementHandle();
669+
if (!handle) {
670+
throw new Error(message);
671+
}
672+
return handle;
673+
} catch (error) {
674+
throw new Error(message, {
675+
cause: error,
676+
});
677+
}
678+
}
679+
639680
async createPagesSnapshot(): Promise<Page[]> {
640681
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();
641682

@@ -648,6 +689,21 @@ export class McpContext implements Context {
648689
mcpPage.isolatedContextName = isolatedContextNames.get(page);
649690
}
650691

692+
// Prune orphaned #mcpPages entries (pages that no longer exist).
693+
const currentPages = new Set(allPages);
694+
for (const [page, mcpPage] of this.#mcpPages) {
695+
if (!currentPages.has(page)) {
696+
mcpPage.dispose();
697+
this.#mcpPages.delete(page);
698+
}
699+
}
700+
// Prune stale #focusedPagePerContext entries.
701+
for (const [ctx, page] of this.#focusedPagePerContext) {
702+
if (!currentPages.has(page)) {
703+
this.#focusedPagePerContext.delete(ctx);
704+
}
705+
}
706+
651707
this.#pages = allPages.filter(page => {
652708
return (
653709
this.#options.experimentalDevToolsDebugging ||
@@ -757,7 +813,7 @@ export class McpContext implements Context {
757813
async getDevToolsData(): Promise<DevToolsData> {
758814
try {
759815
this.logger('Getting DevTools UI data');
760-
const selectedPage = this.getSelectedPage();
816+
const selectedPage = this.#resolveTargetPage();
761817
const devtoolsPage = this.getDevToolsPage(selectedPage);
762818
if (!devtoolsPage) {
763819
this.logger('No DevTools page detected');

0 commit comments

Comments
 (0)