/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
import type {ListenerMap} from './PageCollector.js';
import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
import {Locator} from './third_party/index.js';
import type {DevTools} from './third_party/index.js';
import type {
Browser,
ConsoleMessage,
Debugger,
Dialog,
ElementHandle,
HTTPRequest,
Page,
SerializedAXNode,
PredefinedNetworkConditions,
} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {takeSnapshot} from './tools/snapshot.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import {WaitForHelper} from './WaitForHelper.js';
export interface TextSnapshotNode extends SerializedAXNode {
id: string;
backendNodeId?: number;
children: TextSnapshotNode[];
}
export interface GeolocationOptions {
latitude: number;
longitude: number;
}
export interface TextSnapshot {
root: TextSnapshotNode;
idToNode: Map<string, TextSnapshotNode>;
snapshotId: string;
selectedElementUid?: string;
// It might happen that there is a selected element, but it is not part of the
// snapshot. This flag indicates if there is any selected element.
hasSelectedElement: boolean;
verbose: boolean;
}
interface McpContextOptions {
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
experimentalDevToolsDebugging: boolean;
// Whether all page-like targets are exposed as pages.
experimentalIncludeAllPages?: boolean;
}
const DEFAULT_TIMEOUT = 5_000;
const NAVIGATION_TIMEOUT = 10_000;
function getNetworkMultiplierFromString(condition: string | null): number {
const puppeteerCondition =
condition as keyof typeof PredefinedNetworkConditions;
switch (puppeteerCondition) {
case 'Fast 4G':
return 1;
case 'Slow 4G':
return 2.5;
case 'Fast 3G':
return 5;
case 'Slow 3G':
return 10;
}
return 1;
}
function getExtensionFromMimeType(mimeType: string) {
switch (mimeType) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpeg';
case 'image/webp':
return 'webp';
}
throw new Error(`No mapping for Mime type ${mimeType}.`);
}
export class McpContext implements Context {
browser: Browser;
logger: Debugger;
// The most recent page state.
#pages: Page[] = [];
#pageToDevToolsPage = new Map<Page, Page>();
#selectedPage?: Page;
// The most recent snapshot.
#textSnapshot: TextSnapshot | null = null;
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#isRunningTrace = false;
#networkConditionsMap = new WeakMap<Page, string>();
#cpuThrottlingRateMap = new WeakMap<Page, number>();
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
#dialog?: Dialog;
#nextSnapshotId = 1;
#traceResults: TraceResult[] = [];
#locatorClass: typeof Locator;
#options: McpContextOptions;
private constructor(
browser: Browser,
logger: Debugger,
options: McpContextOptions,
locatorClass: typeof Locator,
) {
this.browser = browser;
this.logger = logger;
this.#locatorClass = locatorClass;
this.#options = options;
this.#networkCollector = new NetworkCollector(this.browser);
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
return {
console: event => {
collect(event);
},
pageerror: event => {
if (event instanceof Error) {
collect(event);
} else {
const error = new Error(`${event}`);
error.stack = undefined;
collect(error);
}
},
issue: event => {
collect(event);
},
} as ListenerMap;
});
}
async #init() {
const pages = await this.createPagesSnapshot();
await this.#networkCollector.init(pages);
await this.#consoleCollector.init(pages);
}
dispose() {
this.#networkCollector.dispose();
this.#consoleCollector.dispose();
}
static async from(
browser: Browser,
logger: Debugger,
opts: McpContextOptions,
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
locatorClass: typeof Locator = Locator,
) {
const context = new McpContext(browser, logger, opts, locatorClass);
await context.#init();
return context;
}
resolveCdpRequestId(cdpRequestId: string): number | undefined {
const selectedPage = this.getSelectedPage();
if (!cdpRequestId) {
this.logger('no network request');
return;
}
const request = this.#networkCollector.find(selectedPage, request => {
// @ts-expect-error id is internal.
return request.id === cdpRequestId;
});
if (!request) {
this.logger('no network request for ' + cdpRequestId);
return;
}
return this.#networkCollector.getIdForResource(request);
}
resolveCdpElementId(cdpBackendNodeId: number): string | undefined {
if (!cdpBackendNodeId) {
this.logger('no cdpBackendNodeId');
return;
}
if (this.#textSnapshot === null) {
this.logger('no text snapshot');
return;
}
// TODO: index by backendNodeId instead.
const queue = [this.#textSnapshot.root];
while (queue.length) {
const current = queue.pop()!;
if (current.backendNodeId === cdpBackendNodeId) {
return current.id;
}
for (const child of current.children) {
queue.push(child);
}
}
return;
}
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
const page = this.getSelectedPage();
return this.#networkCollector.getData(page, includePreservedRequests);
}
getConsoleData(
includePreservedMessages?: boolean,
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue> {
const page = this.getSelectedPage();
return this.#consoleCollector.getData(page, includePreservedMessages);
}
getConsoleMessageStableId(
message: ConsoleMessage | Error | DevTools.AggregatedIssue,
): number {
return this.#consoleCollector.getIdForResource(message);
}
getConsoleMessageById(
id: number,
): ConsoleMessage | Error | DevTools.AggregatedIssue {
return this.#consoleCollector.getById(this.getSelectedPage(), id);
}
async newPage(): Promise<Page> {
const page = await this.browser.newPage();
await this.createPagesSnapshot();
this.selectPage(page);
this.#networkCollector.addPage(page);
this.#consoleCollector.addPage(page);
return page;
}
async closePage(pageIdx: number): Promise<void> {
if (this.#pages.length === 1) {
throw new Error(CLOSE_PAGE_ERROR);
}
const page = this.getPageByIdx(pageIdx);
await page.close({runBeforeUnload: false});
}
getNetworkRequestById(reqid: number): HTTPRequest {
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
}
setNetworkConditions(conditions: string | null): void {
const page = this.getSelectedPage();
if (conditions === null) {
this.#networkConditionsMap.delete(page);
} else {
this.#networkConditionsMap.set(page, conditions);
}
this.#updateSelectedPageTimeouts();
}
getNetworkConditions(): string | null {
const page = this.getSelectedPage();
return this.#networkConditionsMap.get(page) ?? null;
}
setCpuThrottlingRate(rate: number): void {
const page = this.getSelectedPage();
this.#cpuThrottlingRateMap.set(page, rate);
this.#updateSelectedPageTimeouts();
}
getCpuThrottlingRate(): number {
const page = this.getSelectedPage();
return this.#cpuThrottlingRateMap.get(page) ?? 1;
}
setGeolocation(geolocation: GeolocationOptions | null): void {
const page = this.getSelectedPage();
if (geolocation === null) {
this.#geolocationMap.delete(page);
} else {
this.#geolocationMap.set(page, geolocation);
}
}
getGeolocation(): GeolocationOptions | null {
const page = this.getSelectedPage();
return this.#geolocationMap.get(page) ?? null;
}
setIsRunningPerformanceTrace(x: boolean): void {
this.#isRunningTrace = x;
}
isRunningPerformanceTrace(): boolean {
return this.#isRunningTrace;
}
getDialog(): Dialog | undefined {
return this.#dialog;
}
clearDialog(): void {
this.#dialog = undefined;
}
getSelectedPage(): Page {
const page = this.#selectedPage;
if (!page) {
throw new Error('No page selected');
}
if (page.isClosed()) {
throw new Error(
`The selected page has been closed. Call ${listPages.name} to see open pages.`,
);
}
return page;
}
getPageByIdx(idx: number): Page {
const pages = this.#pages;
const page = pages[idx];
if (!page) {
throw new Error('No page found');
}
return page;
}
#dialogHandler = (dialog: Dialog): void => {
this.#dialog = dialog;
};
isPageSelected(page: Page): boolean {
return this.#selectedPage === page;
}
selectPage(newPage: Page): void {
const oldPage = this.#selectedPage;
if (oldPage) {
oldPage.off('dialog', this.#dialogHandler);
void oldPage.emulateFocusedPage(false).catch(error => {
this.logger('Error turning off focused page emulation', error);
});
}
this.#selectedPage = newPage;
newPage.on('dialog', this.#dialogHandler);
this.#updateSelectedPageTimeouts();
void newPage.emulateFocusedPage(true).catch(error => {
this.logger('Error turning on focused page emulation', error);
});
}
#updateSelectedPageTimeouts() {
const page = this.getSelectedPage();
// For waiters 5sec timeout should be sufficient.
// Increased in case we throttle the CPU
const cpuMultiplier = this.getCpuThrottlingRate();
page.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
// 10sec should be enough for the load event to be emitted during
// navigations.
// Increased in case we throttle the network requests
const networkMultiplier = getNetworkMultiplierFromString(
this.getNetworkConditions(),
);
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier);
}
getNavigationTimeout() {
const page = this.getSelectedPage();
return page.getDefaultNavigationTimeout();
}
getAXNodeByUid(uid: string) {
return this.#textSnapshot?.idToNode.get(uid);
}
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
if (!this.#textSnapshot?.idToNode.size) {
throw new Error(
`No snapshot found. Use ${takeSnapshot.name} to capture one.`,
);
}
const [snapshotId] = uid.split('_');
if (this.#textSnapshot.snapshotId !== snapshotId) {
throw new Error(
'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot.',
);
}
const node = this.#textSnapshot?.idToNode.get(uid);
if (!node) {
throw new Error('No such element found in the snapshot');
}
const handle = await node.elementHandle();
if (!handle) {
throw new Error('No such element found in the snapshot');
}
return handle;
}
/**
* Creates a snapshot of the pages.
*/
async createPagesSnapshot(): Promise<Page[]> {
const allPages = await this.browser.pages(
this.#options.experimentalIncludeAllPages,
);
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://')
);
});
if (
(!this.#selectedPage || this.#pages.indexOf(this.#selectedPage) === -1) &&
this.#pages[0]
) {
this.selectPage(this.#pages[0]);
}
await this.detectOpenDevToolsWindows();
return this.#pages;
}
async detectOpenDevToolsWindows() {
this.logger('Detecting open DevTools windows');
const pages = await this.browser.pages(
this.#options.experimentalIncludeAllPages,
);
this.#pageToDevToolsPage = new Map<Page, Page>();
for (const devToolsPage of pages) {
if (devToolsPage.url().startsWith('devtools://')) {
try {
this.logger('Calling getTargetInfo for ' + devToolsPage.url());
const data = await devToolsPage
// @ts-expect-error no types for _client().
._client()
.send('Target.getTargetInfo');
const devtoolsPageTitle = data.targetInfo.title;
const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle);
if (!urlLike) {
continue;
}
// TODO: lookup without a loop.
for (const page of this.#pages) {
if (urlsEqual(page.url(), urlLike)) {
this.#pageToDevToolsPage.set(page, devToolsPage);
}
}
} catch (error) {
this.logger('Issue occurred while trying to find DevTools', error);
}
}
}
}
getPages(): Page[] {
return this.#pages;
}
getDevToolsPage(page: Page): Page | undefined {
return this.#pageToDevToolsPage.get(page);
}
async getDevToolsData(): Promise<DevToolsData> {
try {
this.logger('Getting DevTools UI data');
const selectedPage = this.getSelectedPage();
const devtoolsPage = this.getDevToolsPage(selectedPage);
if (!devtoolsPage) {
this.logger('No DevTools page detected');
return {};
}
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
async () => {
// @ts-expect-error no types
const UI = await import('/bundled/ui/legacy/legacy.js');
// @ts-expect-error no types
const SDK = await import('/bundled/core/sdk/sdk.js');
const request = UI.Context.Context.instance().flavor(
SDK.NetworkRequest.NetworkRequest,
);
const node = UI.Context.Context.instance().flavor(
SDK.DOMModel.DOMNode,
);
return {
cdpRequestId: request?.requestId(),
cdpBackendNodeId: node?.backendNodeId(),
};
},
);
return {cdpBackendNodeId, cdpRequestId};
} catch (err) {
this.logger('error getting devtools data', err);
}
return {};
}
/**
* Creates a text snapshot of a page.
*/
async createTextSnapshot(
verbose = false,
devtoolsData: DevToolsData | undefined = undefined,
): Promise<void> {
const page = this.getSelectedPage();
const rootNode = await page.accessibility.snapshot({
includeIframes: true,
interestingOnly: !verbose,
});
if (!rootNode) {
return;
}
const snapshotId = this.#nextSnapshotId++;
// Iterate through the whole accessibility node tree and assign node ids that
// will be used for the tree serialization and mapping ids back to nodes.
let idCounter = 0;
const idToNode = new Map<string, TextSnapshotNode>();
const assignIds = (node: SerializedAXNode): TextSnapshotNode => {
const nodeWithId: TextSnapshotNode = {
...node,
id: `${snapshotId}_${idCounter++}`,
children: node.children
? node.children.map(child => assignIds(child))
: [],
};
// The AXNode for an option doesn't contain its `value`.
// Therefore, set text content of the option as value.
if (node.role === 'option') {
const optionText = node.name;
if (optionText) {
nodeWithId.value = optionText.toString();
}
}
idToNode.set(nodeWithId.id, nodeWithId);
return nodeWithId;
};
const rootNodeWithId = assignIds(rootNode);
this.#textSnapshot = {
root: rootNodeWithId,
snapshotId: String(snapshotId),
idToNode,
hasSelectedElement: false,
verbose,
};
const data = devtoolsData ?? (await this.getDevToolsData());
if (data?.cdpBackendNodeId) {
this.#textSnapshot.hasSelectedElement = true;
this.#textSnapshot.selectedElementUid = this.resolveCdpElementId(
data?.cdpBackendNodeId,
);
}
}
getTextSnapshot(): TextSnapshot | null {
return this.#textSnapshot;
}
async saveTemporaryFile(
data: Uint8Array<ArrayBufferLike>,
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',
): Promise<{filename: string}> {
try {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), 'chrome-devtools-mcp-'),
);
const filename = path.join(
dir,
`screenshot.${getExtensionFromMimeType(mimeType)}`,
);
await fs.writeFile(filename, data);
return {filename};
} catch (err) {
this.logger(err);
throw new Error('Could not save a screenshot to a file', {cause: err});
}
}
async saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
): Promise<{filename: string}> {
try {
const filePath = path.resolve(filename);
await fs.writeFile(filePath, data);
return {filename};
} catch (err) {
this.logger(err);
throw new Error('Could not save a screenshot to a file', {cause: err});
}
}
storeTraceRecording(result: TraceResult): void {
this.#traceResults.push(result);
}
recordedTraces(): TraceResult[] {
return this.#traceResults;
}
getWaitForHelper(
page: Page,
cpuMultiplier: number,
networkMultiplier: number,
) {
return new WaitForHelper(page, cpuMultiplier, networkMultiplier);
}
waitForEventsAfterAction(action: () => Promise<unknown>): Promise<void> {
const page = this.getSelectedPage();
const cpuMultiplier = this.getCpuThrottlingRate();
const networkMultiplier = getNetworkMultiplierFromString(
this.getNetworkConditions(),
);
const waitForHelper = this.getWaitForHelper(
page,
cpuMultiplier,
networkMultiplier,
);
return waitForHelper.waitForEventsAfterAction(action);
}
getNetworkRequestStableId(request: HTTPRequest): number {
return this.#networkCollector.getIdForResource(request);
}
waitForTextOnPage(text: string, timeout?: number): Promise<Element> {
const page = this.getSelectedPage();
const frames = page.frames();
let locator = this.#locatorClass.race(
frames.flatMap(frame => [
frame.locator(`aria/${text}`),
frame.locator(`text/${text}`),
]),
);
if (timeout) {
locator = locator.setTimeout(timeout);
}
return locator.wait();
}
/**
* We need to ignore favicon request as they make our test flaky
*/
async setUpNetworkCollectorForTesting() {
this.#networkCollector = new NetworkCollector(this.browser, collect => {
return {
request: req => {
if (req.url().includes('favicon.ico')) {
return;
}
collect(req);
},
} as ListenerMap;
});
await this.#networkCollector.init(await this.browser.pages());
}
}