Skip to content
Draft
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
8 changes: 4 additions & 4 deletions 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 gen' to update-->

# Chrome DevTools MCP Tool Reference (~7005 cl100k_base tokens)
# Chrome DevTools MCP Tool Reference (~7003 cl100k_base tokens)

- **[Input automation](#input-automation)** (9 tools)
- [`click`](#click)
Expand Down Expand Up @@ -314,12 +314,12 @@ so returned values have to be JSON-serializable.
**Parameters:**

- **function** (string) **(required)**: A JavaScript function declaration to be executed by the tool in the currently selected page.
Example without arguments: `() => {
Example without arguments: `() => {
return document.title
}` or `async () => {
return await fetch("example.com")
}`.
Example with arguments: `(el) => {
Example with arguments: `(el) => {
return el.innerText;
}`

Expand All @@ -330,7 +330,7 @@ so returned values have to be JSON-serializable.

### `get_console_message`

**Description:** Gets a console message by its ID. You can get all messages by calling [`list_console_messages`](#list_console_messages).
**Description:** Gets a console message by its ID. You can get all messages by calling .

**Parameters:**

Expand Down
17 changes: 15 additions & 2 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
import type {DevTools} from './third_party/index.js';
import type {
Browser,
BrowserContext,
Expand All @@ -31,7 +33,6 @@ import type {
Target,
Extension,
} from './third_party/index.js';
import type {DevTools} from './third_party/index.js';
import {Locator} from './third_party/index.js';
import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
Expand Down Expand Up @@ -81,6 +82,7 @@ export class McpContext implements Context {
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;
#serviceWorkerConsoleCollector: ServiceWorkerConsoleCollector;

#isRunningTrace = false;
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
Expand Down Expand Up @@ -125,21 +127,26 @@ export class McpContext implements Context {
},
} as ListenerMap;
});
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(
this.browser,
);
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
}

async #init() {
const pages = await this.createPagesSnapshot();
await this.createExtensionServiceWorkersSnapshot();
const workers = await this.createExtensionServiceWorkersSnapshot();
await this.#networkCollector.init(pages);
await this.#consoleCollector.init(pages);
await this.#devtoolsUniverseManager.init(pages);
await this.#serviceWorkerConsoleCollector.init(workers);
}

dispose() {
this.#networkCollector.dispose();
this.#consoleCollector.dispose();
this.#devtoolsUniverseManager.dispose();
this.#serviceWorkerConsoleCollector.dispose();
for (const mcpPage of this.#mcpPages.values()) {
mcpPage.dispose();
}
Expand Down Expand Up @@ -515,6 +522,12 @@ export class McpContext implements Context {
return this.#extensionServiceWorkers;
}

getServiceWorkerConsoleData(
extensionId: string,
): Array<ConsoleMessage | UncaughtError> {
return this.#serviceWorkerConsoleCollector.getData(extensionId);
}

async createPagesSnapshot(): Promise<Page[]> {
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();

Expand Down
30 changes: 22 additions & 8 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export class McpResponse implements Response {
pagination?: PaginationOptions;
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
};
#listExtensions?: boolean;
#listInPageTools?: boolean;
Expand Down Expand Up @@ -283,6 +284,7 @@ export class McpResponse implements Response {
options?: PaginationOptions & {
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
},
): void {
if (!value) {
Expand All @@ -301,6 +303,7 @@ export class McpResponse implements Response {
: undefined,
types: options?.types,
includePreservedMessages: options?.includePreservedMessages,
serviceWorkerId: options?.serviceWorkerId,
};
}

Expand Down Expand Up @@ -547,14 +550,23 @@ export class McpResponse implements Response {

let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;
if (this.#consoleDataOptions?.include) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
let messages;
let page: McpPage | undefined;

if (this.#consoleDataOptions.serviceWorkerId) {
messages = context.getServiceWorkerConsoleData(
this.#consoleDataOptions.serviceWorkerId,
);
} else {
page = this.#page;
if (!page) {
throw new Error(`Response must have an McpPage`);
}
messages = context.getConsoleData(
page,
this.#consoleDataOptions.includePreservedMessages,
);
}
const page = this.#page;
let messages = context.getConsoleData(
this.#page,
this.#consoleDataOptions.includePreservedMessages,
);

if (this.#consoleDataOptions.types?.length) {
const normalizedTypes = new Set(this.#consoleDataOptions.types);
Expand All @@ -577,7 +589,9 @@ export class McpResponse implements Response {
context.getConsoleMessageStableId(item);
if ('args' in item || item instanceof UncaughtError) {
const consoleMessage = item as ConsoleMessage | UncaughtError;
const devTools = context.getDevToolsUniverse(page);
const devTools = page
? context.getDevToolsUniverse(page)
: null;
return await ConsoleFormatter.from(consoleMessage, {
id: consoleMessageStableId,
fetchDetailedData: false,
Expand Down
6 changes: 3 additions & 3 deletions src/PageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,11 @@ export class PageCollector<T> {

const item = this.find(page, item => item[stableIdSymbol] === stableId);

if (item) {
return item;
if (!item) {
throw new Error('Request not found for selected page');
}

throw new Error('Request not found for selected page');
return item;
}

find(
Expand Down
202 changes: 202 additions & 0 deletions src/ServiceWorkerCollector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {UncaughtError} from './PageCollector.js';
import type {
ConsoleMessage,
WebWorker,
Target,
CDPSession,
Protocol,
Browser,
} from './third_party/index.js';
import type {ExtensionServiceWorker} from './types.js';
import type {WithSymbolId} from './utils/id.js';
import {createIdGenerator, stableIdSymbol} from './utils/id.js';

const CHROME_EXTENSION_PREFIX = 'chrome-extension://';

export class ServiceWorkerSubscriber {
#target: Target;
#callback: (item: ConsoleMessage | UncaughtError) => void;
#session?: CDPSession;
#worker?: WebWorker;

constructor(
target: Target,
callback: (item: ConsoleMessage | UncaughtError) => void,
) {
this.#target = target;
this.#callback = callback;
}

async subscribe() {
this.#session = await this.#target.createCDPSession();
await this.#session.send('Runtime.enable');
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);

this.#worker = (await this.#target.worker()) ?? undefined;
if (this.#worker) {
this.#worker.on('console', this.#onConsole);
}
}

async unsubscribe() {
if (this.#worker) {
this.#worker.off('console', this.#onConsole);
}
if (this.#session) {
this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown);
await this.#session.send('Runtime.disable');
}
}

#onConsole = (message: ConsoleMessage) => {
this.#callback(message);
};

#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => {
const url = this.#target.url();

const extensionId = extractExtensionId(url);

if (extensionId) {
this.#callback(new UncaughtError(event.exceptionDetails, extensionId));
}
};
}

export class ServiceWorkerConsoleCollector {
#storage = new Map<
string,
Array<WithSymbolId<ConsoleMessage | UncaughtError>>
>();
#maxLogs: number;
#browser?: Browser;
#serviceWorkerSubscribers = new Map<Target, ServiceWorkerSubscriber>();
#idGenerator = createIdGenerator();

constructor(browser?: Browser, maxLogs = 1000) {
this.#browser = browser;
this.#maxLogs = maxLogs;
}

async init(workers: ExtensionServiceWorker[]) {
if (!this.#browser) {
return;
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);

for (const worker of workers) {
void this.#onTargetCreated(worker.target);
}
}

dispose() {
if (!this.#browser) {
return;
}
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
for (const subscriber of this.#serviceWorkerSubscribers.values()) {
void subscriber.unsubscribe();
}
this.#serviceWorkerSubscribers.clear();
}

#onTargetCreated = async (target: Target) => {
if (this.#serviceWorkerSubscribers.has(target)) {
return;
}
const origin = target.url();
if (target.type() === 'service_worker' && isExtensionOrigin(origin)) {
const extensionId = extractExtensionId(origin);

if (!extensionId) {
return;
}

const subscriber = new ServiceWorkerSubscriber(target, item => {
this.addLog(extensionId, item);
});
void subscriber.subscribe();
this.#serviceWorkerSubscribers.set(target, subscriber);
}
};

#onTargetDestroyed = async (target: Target) => {
const subscriber = this.#serviceWorkerSubscribers.get(target);
if (subscriber) {
void subscriber.unsubscribe();
this.#serviceWorkerSubscribers.delete(target);
}
};

addLog(extensionId: string, log: ConsoleMessage | UncaughtError) {
const logs = this.#storage.get(extensionId) ?? [];
const withId = log as WithSymbolId<ConsoleMessage | UncaughtError>;
withId[stableIdSymbol] = this.#idGenerator();
logs.push(withId);
if (logs.length > this.#maxLogs) {
logs.shift();
}
this.#storage.set(extensionId, logs);
}

getData(
extensionId: string,
): Array<WithSymbolId<ConsoleMessage | UncaughtError>> {
return this.#storage.get(extensionId) ?? [];
}

getById(
extensionId: string,
stableId: number,
): WithSymbolId<ConsoleMessage | UncaughtError> {
const logs = this.#storage.get(extensionId);
if (!logs) {
throw new Error('No logs found for selected extension');
}
const item = logs.find(item => item[stableIdSymbol] === stableId);
if (item) {
return item;
}
throw new Error('Log not found for selected extension');
}

find(
extensionId: string,
filter: (item: WithSymbolId<ConsoleMessage | UncaughtError>) => boolean,
): WithSymbolId<ConsoleMessage | UncaughtError> | undefined {
const logs = this.#storage.get(extensionId);
if (!logs) {
return;
}
return logs.find(filter);
}

clearLogs(extensionId: string) {
this.#storage.delete(extensionId);
}
}

function extractExtensionId(origin: string): string | null {
if (!origin || !isExtensionOrigin(origin)) {
return null;
}

const pathPart = origin.substring(CHROME_EXTENSION_PREFIX.length);
const slashIndex = pathPart.indexOf('/');

// if there's no / it means that pathPart is now the extensionId, otherwise
// we take everything until the first /
return slashIndex === -1 ? pathPart : pathPart.substring(0, slashIndex);
}

function isExtensionOrigin(origin: string) {
return origin.startsWith(CHROME_EXTENSION_PREFIX);
}
1 change: 1 addition & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export interface Response {
options?: PaginationOptions & {
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
},
): void;
includeSnapshot(params?: SnapshotParams): void;
Expand Down
Loading
Loading