Skip to content

Commit 84d18b1

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 1bcf223 commit 84d18b1

13 files changed

Lines changed: 582 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 (~7624 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
@@ -129,6 +129,8 @@ export class McpContext implements Context {
129129
null;
130130
#focusedPagePerContext = new Map<BrowserContext, Page>();
131131

132+
#requestPage?: Page;
133+
132134
#nextPageId = 1;
133135

134136
#extensionServiceWorkerMap = new WeakMap<Target, string>();
@@ -203,8 +205,16 @@ export class McpContext implements Context {
203205
return context;
204206
}
205207

208+
setRequestPage(page?: Page): void {
209+
this.#requestPage = page;
210+
}
211+
212+
#resolveTargetPage(): Page {
213+
return this.#requestPage ?? this.getSelectedPage();
214+
}
215+
206216
resolveCdpRequestId(cdpRequestId: string): number | undefined {
207-
const selectedPage = this.getSelectedPage();
217+
const selectedPage = this.#resolveTargetPage();
208218
if (!cdpRequestId) {
209219
this.logger('no network request');
210220
return;
@@ -252,19 +262,19 @@ export class McpContext implements Context {
252262
}
253263

254264
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
255-
const page = this.getSelectedPage();
265+
const page = this.#resolveTargetPage();
256266
return this.#networkCollector.getData(page, includePreservedRequests);
257267
}
258268

259269
getConsoleData(
260270
includePreservedMessages?: boolean,
261271
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
262-
const page = this.getSelectedPage();
272+
const page = this.#resolveTargetPage();
263273
return this.#consoleCollector.getData(page, includePreservedMessages);
264274
}
265275

266276
getDevToolsUniverse(): TargetUniverse | null {
267-
return this.#devtoolsUniverseManager.get(this.getSelectedPage());
277+
return this.#devtoolsUniverseManager.get(this.#resolveTargetPage());
268278
}
269279

270280
getConsoleMessageStableId(
@@ -276,7 +286,7 @@ export class McpContext implements Context {
276286
getConsoleMessageById(
277287
id: number,
278288
): ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError {
279-
return this.#consoleCollector.getById(this.getSelectedPage(), id);
289+
return this.#consoleCollector.getById(this.#resolveTargetPage(), id);
280290
}
281291

282292
async newPage(
@@ -318,7 +328,7 @@ export class McpContext implements Context {
318328
}
319329

320330
getNetworkRequestById(reqid: number): HTTPRequest {
321-
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
331+
return this.#networkCollector.getById(this.#resolveTargetPage(), reqid);
322332
}
323333

324334
async emulate(
@@ -435,27 +445,27 @@ export class McpContext implements Context {
435445
}
436446

437447
getNetworkConditions(): string | null {
438-
return this.#getSelectedMcpPage().networkConditions;
448+
return this.#getMcpPage(this.#resolveTargetPage()).networkConditions;
439449
}
440450

441451
getCpuThrottlingRate(): number {
442-
return this.#getSelectedMcpPage().cpuThrottlingRate;
452+
return this.#getMcpPage(this.#resolveTargetPage()).cpuThrottlingRate;
443453
}
444454

445455
getGeolocation(): GeolocationOptions | null {
446-
return this.#getSelectedMcpPage().geolocation;
456+
return this.#getMcpPage(this.#resolveTargetPage()).geolocation;
447457
}
448458

449459
getViewport(): Viewport | null {
450-
return this.#getSelectedMcpPage().viewport;
460+
return this.#getMcpPage(this.#resolveTargetPage()).viewport;
451461
}
452462

453463
getUserAgent(): string | null {
454-
return this.#getSelectedMcpPage().userAgent;
464+
return this.#getMcpPage(this.#resolveTargetPage()).userAgent;
455465
}
456466

457467
getColorScheme(): 'dark' | 'light' | null {
458-
return this.#getSelectedMcpPage().colorScheme;
468+
return this.#getMcpPage(this.#resolveTargetPage()).colorScheme;
459469
}
460470

461471
setIsRunningPerformanceTrace(x: boolean): void {
@@ -481,7 +491,7 @@ export class McpContext implements Context {
481491
}
482492

483493
getDialog(page?: Page): Dialog | undefined {
484-
const targetPage = page ?? this.#selectedPage;
494+
const targetPage = page ?? this.#requestPage ?? this.#selectedPage;
485495
if (!targetPage) {
486496
return undefined;
487497
}
@@ -543,6 +553,19 @@ export class McpContext implements Context {
543553
return this.#selectedPage === page;
544554
}
545555

556+
assertPageIsFocused(page: Page): void {
557+
const ctx = page.browserContext();
558+
const focused = this.#focusedPagePerContext.get(ctx);
559+
if (focused && focused !== page) {
560+
const targetId = this.#mcpPages.get(page)?.id ?? '?';
561+
const focusedId = this.#mcpPages.get(focused)?.id ?? '?';
562+
throw new Error(
563+
`Page ${targetId} is not the active page in its browser context (page ${focusedId} is). ` +
564+
`Call select_page with pageId ${targetId} first.`,
565+
);
566+
}
567+
}
568+
546569
selectPage(newPage: Page): void {
547570
const ctx = newPage.browserContext();
548571
const oldFocused = this.#focusedPagePerContext.get(ctx);
@@ -575,7 +598,7 @@ export class McpContext implements Context {
575598
}
576599

577600
getNavigationTimeout() {
578-
const page = this.getSelectedPage();
601+
const page = this.#resolveTargetPage();
579602
return page.getDefaultNavigationTimeout();
580603
}
581604

@@ -592,12 +615,39 @@ export class McpContext implements Context {
592615
return undefined;
593616
}
594617

595-
assertUidOnSelectedPage(uid: string): void {
596-
for (const [page, mcpPage] of this.#mcpPages.entries()) {
597-
if (mcpPage.textSnapshot?.idToNode.has(uid)) {
598-
const ctx = page.browserContext();
618+
async getElementByUid(
619+
uid: string,
620+
page?: Page,
621+
): Promise<ElementHandle<Element>> {
622+
if (page) {
623+
// Scoped search: only look in the target page's snapshot.
624+
const mcpPage = this.#mcpPages.get(page);
625+
if (!mcpPage?.textSnapshot) {
626+
throw new Error(
627+
`No snapshot found for page ${mcpPage?.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
628+
);
629+
}
630+
const node = mcpPage.textSnapshot.idToNode.get(uid);
631+
if (!node) {
632+
throw new Error(
633+
`Element uid "${uid}" not found on page ${mcpPage.id}.`,
634+
);
635+
}
636+
return this.#resolveElementHandle(node, uid);
637+
}
638+
639+
// Cross-page search with context-focus validation.
640+
let anySnapshot = false;
641+
for (const [searchPage, mcpPage] of this.#mcpPages.entries()) {
642+
if (!mcpPage.textSnapshot) {
643+
continue;
644+
}
645+
anySnapshot = true;
646+
const node = mcpPage.textSnapshot.idToNode.get(uid);
647+
if (node) {
648+
const ctx = searchPage.browserContext();
599649
const contextSelectedPage = this.#focusedPagePerContext.get(ctx);
600-
if (contextSelectedPage !== page) {
650+
if (contextSelectedPage !== searchPage) {
601651
const targetId = mcpPage.id;
602652
const selectedId = contextSelectedPage
603653
? this.#mcpPages.get(contextSelectedPage)?.id
@@ -608,37 +658,10 @@ export class McpContext implements Context {
608658
);
609659
}
610660
// Align global #selectedPage for waitForEventsAfterAction etc.
611-
if (this.#selectedPage !== page) {
612-
this.#selectedPage = page;
613-
}
614-
return;
615-
}
616-
}
617-
throw new Error('No such element found in any snapshot.');
618-
}
619-
620-
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
621-
let anySnapshot = false;
622-
// Search across all per-page snapshots for the uid.
623-
for (const mcpPage of this.#mcpPages.values()) {
624-
if (!mcpPage.textSnapshot) {
625-
continue;
626-
}
627-
anySnapshot = true;
628-
const node = mcpPage.textSnapshot.idToNode.get(uid);
629-
if (node) {
630-
const message = `Element with uid ${uid} no longer exists on the page.`;
631-
try {
632-
const handle = await node.elementHandle();
633-
if (!handle) {
634-
throw new Error(message);
635-
}
636-
return handle;
637-
} catch (error) {
638-
throw new Error(message, {
639-
cause: error,
640-
});
661+
if (this.#selectedPage !== searchPage) {
662+
this.#selectedPage = searchPage;
641663
}
664+
return this.#resolveElementHandle(node, uid);
642665
}
643666
}
644667
if (!anySnapshot) {
@@ -684,6 +707,24 @@ export class McpContext implements Context {
684707
return this.#extensionServiceWorkers;
685708
}
686709

710+
async #resolveElementHandle(
711+
node: TextSnapshotNode,
712+
uid: string,
713+
): Promise<ElementHandle<Element>> {
714+
const message = `Element with uid ${uid} no longer exists on the page.`;
715+
try {
716+
const handle = await node.elementHandle();
717+
if (!handle) {
718+
throw new Error(message);
719+
}
720+
return handle;
721+
} catch (error) {
722+
throw new Error(message, {
723+
cause: error,
724+
});
725+
}
726+
}
727+
687728
async createPagesSnapshot(): Promise<Page[]> {
688729
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();
689730

@@ -696,6 +737,21 @@ export class McpContext implements Context {
696737
mcpPage.isolatedContextName = isolatedContextNames.get(page);
697738
}
698739

740+
// Prune orphaned #mcpPages entries (pages that no longer exist).
741+
const currentPages = new Set(allPages);
742+
for (const [page, mcpPage] of this.#mcpPages) {
743+
if (!currentPages.has(page)) {
744+
mcpPage.dispose();
745+
this.#mcpPages.delete(page);
746+
}
747+
}
748+
// Prune stale #focusedPagePerContext entries.
749+
for (const [ctx, page] of this.#focusedPagePerContext) {
750+
if (!currentPages.has(page)) {
751+
this.#focusedPagePerContext.delete(ctx);
752+
}
753+
}
754+
699755
this.#pages = allPages.filter(page => {
700756
return (
701757
this.#options.experimentalDevToolsDebugging ||
@@ -815,7 +871,7 @@ export class McpContext implements Context {
815871
async getDevToolsData(): Promise<DevToolsData> {
816872
try {
817873
this.logger('Getting DevTools UI data');
818-
const selectedPage = this.getSelectedPage();
874+
const selectedPage = this.#resolveTargetPage();
819875
const devtoolsPage = this.getDevToolsPage(selectedPage);
820876
if (!devtoolsPage) {
821877
this.logger('No DevTools page detected');

0 commit comments

Comments
 (0)