Skip to content
Merged
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
31 changes: 31 additions & 0 deletions scripts/eval_scenarios/isolated_context_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @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',
);
},
};
89 changes: 73 additions & 16 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,17 @@ 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.
#textSnapshot: TextSnapshot | null = null;
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
Expand Down Expand Up @@ -187,6 +194,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 +280,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,6 +308,7 @@ export class McpContext implements Context {
}
const page = this.getPageById(pageId);
await page.close({runBeforeUnload: false});
this.#pageToIsolatedContextName.delete(page);
}

getNetworkRequestById(reqid: number): HTTPRequest {
Expand Down Expand Up @@ -558,13 +584,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 +594,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 +612,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 +681,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 @@ -857,7 +913,8 @@ export class McpContext implements Context {
},
} as ListenerMap;
});
await this.#networkCollector.init(await this.browser.pages());
const pages = await this.#getAllPages();
await this.#networkCollector.init(pages);
}

async installExtension(extensionPath: string): Promise<string> {
Expand Down
18 changes: 16 additions & 2 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,17 +504,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
3 changes: 2 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ export type Context = Readonly<{
getPageById(pageId: number): Page;
getPageId(page: Page): number | undefined;
isPageSelected(page: Page): boolean;
newPage(background?: boolean): Promise<Page>;
newPage(background?: boolean, isolatedContextName?: string): Promise<Page>;
closePage(pageId: number): Promise<void>;
selectPage(page: Page): void;
getIsolatedContextName(page: Page): string | undefined;
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
emulate(options: {
Expand Down
13 changes: 12 additions & 1 deletion src/tools/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,21 @@ export const newPage = defineTool({
.describe(
'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
),
isolatedContext: zod
.string()
.optional()
.describe(
'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.',
),
...timeoutSchema,
},
handler: async (request, response, context) => {
const page = await context.newPage(request.params.background);
const page = await context.newPage(
request.params.background,
request.params.isolatedContext,
);

await context.waitForEventsAfterAction(
async () => {
Expand Down
103 changes: 103 additions & 0 deletions tests/tools/pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,109 @@ describe('pages', () => {
});
});
});
describe('new_page with isolatedContext', () => {
it('creates a page in an isolated context', async () => {
await withMcpContext(async (response, context) => {
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const page = context.getSelectedPage();
assert.strictEqual(context.getIsolatedContextName(page), 'session-a');
assert.ok(response.includePages);
});
});

it('reuses the same context for the same isolatedContext name', async () => {
await withMcpContext(async (response, context) => {
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const page1 = context.getSelectedPage();
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const page2 = context.getSelectedPage();
assert.notStrictEqual(page1, page2);
assert.strictEqual(context.getIsolatedContextName(page1), 'session-a');
assert.strictEqual(context.getIsolatedContextName(page2), 'session-a');
assert.strictEqual(page1.browserContext(), page2.browserContext());
});
});

it('creates separate contexts for different isolatedContext names', async () => {
await withMcpContext(async (response, context) => {
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const pageA = context.getSelectedPage();
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-b'}},
response,
context,
);
const pageB = context.getSelectedPage();
assert.strictEqual(context.getIsolatedContextName(pageA), 'session-a');
assert.strictEqual(context.getIsolatedContextName(pageB), 'session-b');
assert.notStrictEqual(pageA.browserContext(), pageB.browserContext());
});
});

it('includes isolatedContext in page listing', async () => {
await withMcpContext(async (response, context) => {
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const result = await response.handle('new_page', context);
const pages = (
result.structuredContent as {pages: Array<{isolatedContext?: string}>}
).pages;
const isolatedPage = pages.find(p => p.isolatedContext === 'session-a');
assert.ok(isolatedPage);
});
});

it('does not set isolatedContext for pages in the default context', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPage();
assert.strictEqual(context.getIsolatedContextName(page), undefined);
await newPage.handler(
{params: {url: 'about:blank'}},
response,
context,
);
assert.strictEqual(
context.getIsolatedContextName(context.getSelectedPage()),
undefined,
);
});
});

it('closes an isolated page without errors', async () => {
await withMcpContext(async (response, context) => {
await newPage.handler(
{params: {url: 'about:blank', isolatedContext: 'session-a'}},
response,
context,
);
const page = context.getSelectedPage();
const pageId = context.getPageId(page)!;
assert.ok(!page.isClosed());
await closePage.handler({params: {pageId}}, response, context);
assert.ok(page.isClosed());
});
});
});

describe('close_page', () => {
it('closes a page', async () => {
await withMcpContext(async (response, context) => {
Expand Down