Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ so returned values have to be JSON-serializable.
- **includePreservedMessages** (boolean) _(optional)_: Set to true to return the preserved messages over the last 3 navigations.
- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page.
- **pageSize** (integer) _(optional)_: Maximum number of messages to return. When omitted, returns all messages.
- **serviceWorkerId** (string) _(optional)_: Filter messages to only return messages of the specified service worker.
- **types** (array) _(optional)_: Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.

---
Expand Down
45 changes: 29 additions & 16 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,23 @@ import {
type ListenerMap,
type UncaughtError,
} from './PageCollector.js';
import {
Locator,
PredefinedNetworkConditions,
type Browser,
type BrowserContext,
type ConsoleMessage,
type Debugger,
type HTTPRequest,
type Page,
type ScreenRecorder,
type Viewport,
type Target,
type Extension,
type Root,
type DevTools,
import {ServiceWorkerConsoleCollector} from './ServiceWorkerCollector.js';
import type {DevTools} from './third_party/index.js';
import type {
Browser,
BrowserContext,
ConsoleMessage,
Debugger,
HTTPRequest,
Page,
ScreenRecorder,
Viewport,
Target,
Extension,
Root,
} 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';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context, SupportedExtensions} from './tools/ToolDefinition.js';
Expand Down Expand Up @@ -77,6 +78,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 @@ -121,21 +123,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 @@ -524,6 +531,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 @@ -197,6 +197,7 @@ export class McpResponse implements Response {
pagination?: PaginationOptions;
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
};
#listExtensions?: boolean;
#listThirdPartyDeveloperTools?: boolean;
Expand Down Expand Up @@ -289,6 +290,7 @@ export class McpResponse implements Response {
options?: PaginationOptions & {
types?: string[];
includePreservedMessages?: boolean;
serviceWorkerId?: string;
},
): void {
if (!value) {
Expand All @@ -307,6 +309,7 @@ export class McpResponse implements Response {
: undefined,
types: options?.types,
includePreservedMessages: options?.includePreservedMessages,
serviceWorkerId: options?.serviceWorkerId,
};
}

Expand Down Expand Up @@ -582,14 +585,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 @@ -612,7 +624,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);
}
7 changes: 7 additions & 0 deletions src/bin/chrome-devtools-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,13 @@ export const commands: Commands = {
required: false,
default: false,
},
serviceWorkerId: {
name: 'serviceWorkerId',
type: 'string',
description:
'Filter messages to only return messages of the specified service worker.',
required: false,
},
},
},
list_extensions: {
Expand Down
Loading
Loading