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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,9 @@ build/

log.txt

.DS_Store
.DS_Store

# Local development directories
.ai/
spike/
test-extension/
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
- **Network** (2 tools)
- [`get_network_request`](docs/tool-reference.md#get_network_request)
- [`list_network_requests`](docs/tool-reference.md#list_network_requests)
- **Debugging** (5 tools)
- **Debugging** (6 tools)
- [`evaluate_script`](docs/tool-reference.md#evaluate_script)
- [`get_console_message`](docs/tool-reference.md#get_console_message)
- [`list_console_messages`](docs/tool-reference.md#list_console_messages)
- [`open_extension_sidepanel`](docs/tool-reference.md#open_extension_sidepanel)
- [`take_screenshot`](docs/tool-reference.md#take_screenshot)
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)

Expand Down Expand Up @@ -443,6 +444,11 @@ The Chrome DevTools MCP server supports the following configuration option:
- **Type:** boolean
- **Default:** `true`

- **`--enableExtensions`/ `--enable-extensions`**
Enable extension debugging support. When enabled, extension contexts (sidepanels, popups, service workers) will be visible and interactable.
- **Type:** boolean
- **Default:** `false`

<!-- END AUTO GENERATED OPTIONS -->

Pass them via the `args` property in the JSON configuration. For example:
Expand Down
22 changes: 19 additions & 3 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@
- **[Network](#network)** (2 tools)
- [`get_network_request`](#get_network_request)
- [`list_network_requests`](#list_network_requests)
- **[Debugging](#debugging)** (5 tools)
- **[Debugging](#debugging)** (6 tools)
- [`evaluate_script`](#evaluate_script)
- [`get_console_message`](#get_console_message)
- [`list_console_messages`](#list_console_messages)
- [`open_extension_sidepanel`](#open_extension_sidepanel)
- [`take_screenshot`](#take_screenshot)
- [`take_snapshot`](#take_snapshot)

Expand Down Expand Up @@ -280,12 +281,12 @@ so returned values have to 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 Down Expand Up @@ -316,6 +317,21 @@ so returned values have to JSON-serializable.

---

### `open_extension_sidepanel`

**Description:** Opens an extension's sidepanel for debugging. Due to Chrome security restrictions,
the sidepanel opens in a detached popup window rather than docked to the browser sidebar.
This provides full debugging capabilities (DOM inspection, console access, script evaluation)
with identical code execution to docked mode. Only visual docking/layout differs.

After opening, use [`list_pages`](#list_pages) to see the sidepanel and [`select_page`](#select_page) to interact with it.

**Parameters:**

- **extensionId** (string) **(required)**: The ID of the extension whose sidepanel should be opened. Find extension IDs at chrome://extensions or from [`list_pages`](#list_pages) service worker URLs.

---

### `take_screenshot`

**Description:** Take a screenshot of the page or element.
Expand Down
113 changes: 113 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import {isExtensionDebuggingEnabled} from './browser.js';
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
import type {ListenerMap} from './PageCollector.js';
import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
Expand All @@ -23,6 +24,7 @@ import type {
Page,
SerializedAXNode,
PredefinedNetworkConditions,
Target,
} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {takeSnapshot} from './tools/snapshot.js';
Expand Down Expand Up @@ -53,6 +55,19 @@ export interface TextSnapshot {
verbose: boolean;
}

export interface ServiceWorkerInfo {
type: 'service_worker';
url: string;
target: Target;
}

export interface OpenSidepanelResult {
success: boolean;
url: string;
windowId: number;
note: string;
}

interface McpContextOptions {
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
experimentalDevToolsDebugging: boolean;
Expand Down Expand Up @@ -486,6 +501,104 @@ export class McpContext implements Context {
return this.#pages;
}

/**
* Gets extension service workers when extension debugging is enabled.
* Service workers don't have associated Page objects, so we return Target info.
*/
getServiceWorkers(): ServiceWorkerInfo[] {
if (!isExtensionDebuggingEnabled()) {
return [];
}

const allTargets = this.browser.targets();
const serviceWorkers: ServiceWorkerInfo[] = [];

for (const target of allTargets) {
if (target.type() === 'service_worker') {
const url = target.url();
// Only include extension service workers when extension debugging is enabled
if (url.startsWith('chrome-extension://')) {
serviceWorkers.push({
type: 'service_worker',
url,
target,
});
}
}
}

return serviceWorkers;
}

/**
* Opens an extension's sidepanel in a detached popup window.
* Due to Chrome security requirements, chrome.sidePanel.open() requires a user gesture
* and cannot be triggered programmatically. This method uses chrome.windows.create()
* as the standard workaround for automated extension testing.
*/
async openExtensionSidepanel(extensionId: string): Promise<OpenSidepanelResult> {
if (!isExtensionDebuggingEnabled()) {
throw new Error('Extension debugging is not enabled. Use --enableExtensions flag.');
}

// Find the extension's service worker
const serviceWorkers = this.getServiceWorkers();
const extensionWorker = serviceWorkers.find(sw =>
sw.url.includes(`chrome-extension://${extensionId}/`)
);

if (!extensionWorker) {
throw new Error(
`Service worker not found for extension: ${extensionId}. ` +
`Make sure the extension is installed and has a service worker.`
);
}

const worker = await extensionWorker.target.worker();
if (!worker) {
throw new Error(`Could not get worker instance for extension: ${extensionId}`);
}

// Open the sidepanel via chrome.windows.create() in the service worker context
// The chrome.* APIs are available in the extension's service worker context
const result = await worker.evaluate(async () => {
// @ts-expect-error chrome is available in extension service worker context
const chromeApi = chrome;
const manifest = chromeApi.runtime.getManifest();
const sidePanelPath = manifest.side_panel?.default_path;

if (!sidePanelPath) {
throw new Error('Extension does not have a side_panel.default_path in manifest.json');
}

const url = chromeApi.runtime.getURL(sidePanelPath);

// Open as detached popup window (no address bar, minimal chrome UI)
const window = await chromeApi.windows.create({
url: url,
type: 'popup',
width: 400,
height: 700,
focused: true,
});

return {
url: url,
windowId: window.id ?? -1,
};
});

// Refresh pages to include the new sidepanel window
await this.createPagesSnapshot();

return {
success: true,
url: result.url,
windowId: result.windowId,
note: 'Sidepanel opened in detached popup window for debugging. Extension code executes identically to docked mode.',
};
}

getDevToolsPage(page: Page): Page | undefined {
return this.#pageToDevToolsPage.get(page);
}
Expand Down
11 changes: 11 additions & 0 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,17 @@ Call ${handleDialog.name} to handle it before continuing.`);
`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}`,
);
}

// Include service workers when extension debugging is enabled
const serviceWorkers = context.getServiceWorkers();
if (serviceWorkers.length > 0) {
parts.push('');
parts.push('## Service Workers');
for (const sw of serviceWorkers) {
parts.push(`[service_worker] ${sw.url}`);
}
}

response.push(...parts);
}

Expand Down
21 changes: 20 additions & 1 deletion src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,28 @@ import {puppeteer} from './third_party/index.js';

let browser: Browser | undefined;

// Global flag to enable extension debugging
let extensionDebuggingEnabled = false;

export function setExtensionDebuggingEnabled(enabled: boolean): void {
extensionDebuggingEnabled = enabled;
}

export function isExtensionDebuggingEnabled(): boolean {
return extensionDebuggingEnabled;
}

function makeTargetFilter() {
const ignoredPrefixes = new Set([
'chrome://',
'chrome-extension://',
'chrome-untrusted://',
]);

// Only filter out chrome-extension:// if extension debugging is disabled
if (!extensionDebuggingEnabled) {
ignoredPrefixes.add('chrome-extension://');
}

return function targetFilter(target: Target): boolean {
if (target.url() === 'chrome://newtab/') {
return true;
Expand All @@ -34,6 +49,10 @@ function makeTargetFilter() {
if (target.url().startsWith('chrome://inspect')) {
return true;
}
// Allow extension targets when extension debugging is enabled
if (extensionDebuggingEnabled && target.url().startsWith('chrome-extension://')) {
return true;
}
for (const prefix of ignoredPrefixes) {
if (target.url().startsWith(prefix)) {
return false;
Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export const cliOptions = {
default: true,
describe: 'Set to false to exclude tools related to network.',
},
enableExtensions: {
type: 'boolean',
default: false,
describe:
'Enable extension debugging support. When enabled, extension contexts (sidepanels, popups, service workers) will be visible and interactable.',
},
} satisfies Record<string, YargsOptions>;

export function parseArguments(version: string, argv = process.argv) {
Expand Down
12 changes: 11 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import './polyfill.js';
import process from 'node:process';

import type {Channel} from './browser.js';
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
import {
ensureBrowserConnected,
ensureBrowserLaunched,
setExtensionDebuggingEnabled,
} from './browser.js';
import {parseArguments} from './cli.js';
import {loadIssueDescriptions} from './issue-descriptions.js';
import {logger, saveLogsToFile} from './logger.js';
Expand All @@ -33,6 +37,12 @@ const VERSION = '0.12.1';

export const args = parseArguments(VERSION);

// Enable extension debugging if requested
if (args.enableExtensions) {
setExtensionDebuggingEnabled(true);
logger('Extension debugging enabled');
}

const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;

process.on('unhandledRejection', (reason, promise) => {
Expand Down
6 changes: 5 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {TextSnapshotNode, GeolocationOptions} from '../McpContext.js';
import type {TextSnapshotNode, GeolocationOptions, OpenSidepanelResult} from '../McpContext.js';
import {zod} from '../third_party/index.js';
import type {Dialog, ElementHandle, Page} from '../third_party/index.js';
import type {TraceResult} from '../trace-processing/parse.js';
Expand Down Expand Up @@ -119,6 +119,10 @@ export type Context = Readonly<{
* Returns a reqid for a cdpRequestId.
*/
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
/**
* Opens an extension's sidepanel in a detached popup window.
*/
openExtensionSidepanel(extensionId: string): Promise<OpenSidepanelResult>;
}>;

export function defineTool<Schema extends zod.ZodRawShape>(
Expand Down
Loading