Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run docs' to update-->

# Chrome DevTools MCP Tool Reference (~6661 cl100k_base tokens)
# Chrome DevTools MCP Tool Reference (~6719 cl100k_base tokens)

- **[Input automation](#input-automation)** (8 tools)
- [`click`](#click)
Expand Down Expand Up @@ -172,6 +172,7 @@

- **url** (string) **(required)**: URL to load in a new page.
- **background** (boolean) _(optional)_: Whether to open the page in the background without bringing it to the front. Default is false (foreground).
- **isolatedContext** (string) _(optional)_: If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.

---
Expand Down
28 changes: 28 additions & 0 deletions scripts/eval_scenarios/isolated_context_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';

import type {TestScenario} from '../eval_gemini.ts';

export const scenario: TestScenario = {
prompt:
'Create a new page <TEST_URL> in an isolated context called contextB. Take a screenshot there.',
maxTurns: 3,
htmlRoute: {
path: '/test.html',
htmlContent: `
<h1>test</h1>
`,
},
expectations: calls => {
console.log(JSON.stringify(calls, null, 2))
assert.strictEqual(calls.length, 2);
assert.ok(calls[0].name === 'new_page', 'First call should be navigation');
assert.deepStrictEqual(calls[0].args.isolatedContext, "contextB");
assert.ok(calls[1].name === 'take_screenshot', 'Second call should be a screenshot');
},
};
164 changes: 136 additions & 28 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
import type {DevTools} from './third_party/index.js';
import type {
Browser,
BrowserContext,
ConsoleMessage,
Debugger,
Dialog,
Expand Down Expand Up @@ -119,11 +120,19 @@ export class McpContext implements Context {
browser: Browser;
logger: Debugger;

// The most recent page state.
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
#isolatedContexts = new Map<string, BrowserContext>();
// Reverse lookup: Page → isolatedContext name (for snapshot labeling).
// WeakMap so closed pages are garbage-collected automatically.
#pageToIsolatedContextName = new WeakMap<Page, string>();
// Auto-generated name counter for when no name is provided.
#nextIsolatedContextId = 1;

#pages: Page[] = [];
#pageToDevToolsPage = new Map<Page, Page>();
#selectedPage?: Page;
// The most recent snapshot.
// Per-context selected page tracking for parallel agent support.
#contextSelectedPage = new Map<string, Page>();
#textSnapshot: TextSnapshot | null = null;
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
Expand Down Expand Up @@ -187,6 +196,10 @@ export class McpContext implements Context {
this.#networkCollector.dispose();
this.#consoleCollector.dispose();
this.#devtoolsUniverseManager.dispose();
// Isolated contexts are intentionally not closed here.
// Either the entire browser will be closed or we disconnect
// without destroying browser state.
this.#isolatedContexts.clear();
}

static async from(
Expand Down Expand Up @@ -269,8 +282,22 @@ export class McpContext implements Context {
return this.#consoleCollector.getById(this.getSelectedPage(), id);
}

async newPage(background?: boolean): Promise<Page> {
const page = await this.browser.newPage({background});
async newPage(
background?: boolean,
isolatedContextName?: string,
): Promise<Page> {
let page: Page;
if (isolatedContextName !== undefined) {
let ctx = this.#isolatedContexts.get(isolatedContextName);
if (!ctx) {
ctx = await this.browser.createBrowserContext();
this.#isolatedContexts.set(isolatedContextName, ctx);
}
page = await ctx.newPage();
this.#pageToIsolatedContextName.set(page, isolatedContextName);
} else {
page = await this.browser.newPage({background});
}
await this.createPagesSnapshot();
this.selectPage(page);
this.#networkCollector.addPage(page);
Expand All @@ -283,21 +310,25 @@ export class McpContext implements Context {
}
const page = this.getPageById(pageId);
await page.close({runBeforeUnload: false});
this.#pageToIsolatedContextName.delete(page);
}

getNetworkRequestById(reqid: number): HTTPRequest {
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
}

async emulate(options: {
networkConditions?: string | null;
cpuThrottlingRate?: number | null;
geolocation?: GeolocationOptions | null;
userAgent?: string | null;
colorScheme?: 'dark' | 'light' | 'auto' | null;
viewport?: Viewport | null;
}): Promise<void> {
const page = this.getSelectedPage();
async emulate(
options: {
networkConditions?: string | null;
cpuThrottlingRate?: number | null;
geolocation?: GeolocationOptions | null;
userAgent?: string | null;
colorScheme?: 'dark' | 'light' | 'auto' | null;
viewport?: Viewport | null;
},
targetPage?: Page,
): Promise<void> {
const page = targetPage ?? this.getSelectedPage();
const currentSettings = this.#emulationSettingsMap.get(page) ?? {};
const newSettings: EmulationSettings = {...currentSettings};
let timeoutsNeedUpdate = false;
Expand Down Expand Up @@ -474,6 +505,41 @@ export class McpContext implements Context {
return page;
}

resolvePageByContext(isolatedContext?: string): Page {
if (isolatedContext === undefined) {
return this.getSelectedPage();
}

// Try the per-context selected page first.
const tracked = this.#contextSelectedPage.get(isolatedContext);
if (tracked && !tracked.isClosed()) {
return tracked;
}

// Fall back: find any non-closed page in the context.
const ctx = this.#isolatedContexts.get(isolatedContext);
if (!ctx) {
throw new Error(
`No isolated context named "${isolatedContext}" exists. ` +
`Create one first with new_page(isolatedContext: "${isolatedContext}").`,
);
}

for (const page of this.#pages) {
if (
!page.isClosed() &&
this.#pageToIsolatedContextName.get(page) === isolatedContext
) {
this.#contextSelectedPage.set(isolatedContext, page);
return page;
}
}

throw new Error(
`No open page found in isolated context "${isolatedContext}".`,
);
}

getPageById(pageId: number): Page {
const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
if (!page) {
Expand Down Expand Up @@ -508,6 +574,12 @@ export class McpContext implements Context {
void newPage.emulateFocusedPage(true).catch(error => {
this.logger('Error turning on focused page emulation', error);
});

// Track per-context selected page for parallel agent routing.
const contextName = this.#pageToIsolatedContextName.get(newPage);
if (contextName) {
this.#contextSelectedPage.set(contextName, newPage);
}
}

#updateSelectedPageTimeouts() {
Expand Down Expand Up @@ -558,13 +630,8 @@ export class McpContext implements Context {
}
}

/**
* Creates a snapshot of the pages.
*/
async createPagesSnapshot(): Promise<Page[]> {
const allPages = await this.browser.pages(
this.#options.experimentalIncludeAllPages,
);
const allPages = await this.#getAllPages();

for (const page of allPages) {
if (!this.#pageIdMap.has(page)) {
Expand All @@ -573,8 +640,6 @@ export class McpContext implements Context {
}

this.#pages = allPages.filter(page => {
// If we allow debugging DevTools windows, return all pages.
// If we are in regular mode, the user should only see non-DevTools page.
return (
this.#options.experimentalDevToolsDebugging ||
!page.url().startsWith('devtools://')
Expand All @@ -593,11 +658,44 @@ export class McpContext implements Context {
return this.#pages;
}

async detectOpenDevToolsWindows() {
this.logger('Detecting open DevTools windows');
const pages = await this.browser.pages(
async #getAllPages(): Promise<Page[]> {
const defaultCtx = this.browser.defaultBrowserContext();
const allPages = await this.browser.pages(
this.#options.experimentalIncludeAllPages,
);

// Build a reverse lookup from BrowserContext instance → name.
const contextToName = new Map<BrowserContext, string>();
for (const [name, ctx] of this.#isolatedContexts) {
contextToName.set(ctx, name);
}

// Auto-discover BrowserContexts not in our mapping (e.g., externally
// created incognito contexts) and assign generated names.
const knownContexts = new Set(this.#isolatedContexts.values());
for (const ctx of this.browser.browserContexts()) {
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
this.#isolatedContexts.set(name, ctx);
contextToName.set(ctx, name);
}
}

// Use page.browserContext() to determine each page's context membership.
for (const page of allPages) {
const ctx = page.browserContext();
const name = contextToName.get(ctx);
if (name) {
this.#pageToIsolatedContextName.set(page, name);
}
}

return allPages;
}

async detectOpenDevToolsWindows() {
this.logger('Detecting open DevTools windows');
const pages = await this.#getAllPages();
this.#pageToDevToolsPage = new Map<Page, Page>();
for (const devToolsPage of pages) {
if (devToolsPage.url().startsWith('devtools://')) {
Expand Down Expand Up @@ -629,6 +727,10 @@ export class McpContext implements Context {
return this.#pages;
}

getIsolatedContextName(page: Page): string | undefined {
return this.#pageToIsolatedContextName.get(page);
}

getDevToolsPage(page: Page): Page | undefined {
return this.#pageToDevToolsPage.get(page);
}
Expand Down Expand Up @@ -673,8 +775,9 @@ export class McpContext implements Context {
async createTextSnapshot(
verbose = false,
devtoolsData: DevToolsData | undefined = undefined,
targetPage?: Page,
): Promise<void> {
const page = this.getSelectedPage();
const page = targetPage ?? this.getSelectedPage();
const rootNode = await page.accessibility.snapshot({
includeIframes: true,
interestingOnly: !verbose,
Expand Down Expand Up @@ -825,8 +928,12 @@ export class McpContext implements Context {
return this.#networkCollector.getIdForResource(request);
}

waitForTextOnPage(text: string, timeout?: number): Promise<Element> {
const page = this.getSelectedPage();
waitForTextOnPage(
text: string,
timeout?: number,
targetPage?: Page,
): Promise<Element> {
const page = targetPage ?? this.getSelectedPage();
const frames = page.frames();

let locator = this.#locatorClass.race(
Expand Down Expand Up @@ -857,7 +964,8 @@ export class McpContext implements Context {
},
} as ListenerMap;
});
await this.#networkCollector.init(await this.browser.pages());
const pages = await this.browser.pages();
await this.#networkCollector.init(pages);
}

async installExtension(extensionPath: string): Promise<string> {
Expand Down
19 changes: 17 additions & 2 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export class McpResponse implements Response {
await context.createTextSnapshot(
this.#snapshotParams.verbose,
this.#devToolsData,
this.#snapshotParams.page,
);
const textSnapshot = context.getTextSnapshot();
if (textSnapshot) {
Expand Down Expand Up @@ -504,17 +505,31 @@ Call ${handleDialog.name} to handle it before continuing.`);
if (this.#includePages) {
const parts = [`## Pages`];
for (const page of context.getPages()) {
const isolatedContextName = context.getIsolatedContextName(page);
const contextLabel = isolatedContextName
? ` isolatedContext=${isolatedContextName}`
: '';
parts.push(
`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`,
`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`,
);
}
response.push(...parts);
structuredContent.pages = context.getPages().map(page => {
return {
const isolatedContextName = context.getIsolatedContextName(page);
const entry: {
id: number | undefined;
url: string;
selected: boolean;
isolatedContext?: string;
} = {
id: context.getPageId(page),
url: page.url(),
selected: context.isPageSelected(page),
};
if (isolatedContextName) {
entry.isolatedContext = isolatedContextName;
}
return entry;
});
}

Expand Down
Loading
Loading