diff --git a/.gitignore b/.gitignore index 6043309f0..6f81f7032 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,9 @@ build/ log.txt -.DS_Store \ No newline at end of file +.DS_Store + +# Local development directories +.ai/ +spike/ +test-extension/ \ No newline at end of file diff --git a/README.md b/README.md index 437233389..c446f4bc2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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` + Pass them via the `args` property in the JSON configuration. For example: diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 8a5191ec6..da86edab4 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -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) @@ -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; }` @@ -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. diff --git a/src/McpContext.ts b/src/McpContext.ts index aa848493d..8782bce34 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -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'; @@ -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'; @@ -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; @@ -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 { + 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); } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 6db0d1831..56c20ec40 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -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); } diff --git a/src/browser.ts b/src/browser.ts index e7dffd8db..f9631970d 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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; @@ -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; diff --git a/src/cli.ts b/src/cli.ts index db2680587..f49652a5d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; export function parseArguments(version: string, argv = process.argv) { diff --git a/src/main.ts b/src/main.ts index 84bb6d9b5..aa891ea8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'; @@ -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) => { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index f3eaaa7eb..1beb0dea4 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -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'; @@ -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; }>; export function defineTool( diff --git a/src/tools/extension.ts b/src/tools/extension.ts new file mode 100644 index 000000000..a58be34eb --- /dev/null +++ b/src/tools/extension.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +export const openExtensionSidepanel = defineTool({ + name: '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 to see the sidepanel and select_page to interact with it.`, + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + }, + schema: { + extensionId: zod + .string() + .describe( + 'The ID of the extension whose sidepanel should be opened. ' + + 'Find extension IDs at chrome://extensions or from list_pages service worker URLs.', + ), + }, + handler: async (request, response, context) => { + try { + const result = await context.openExtensionSidepanel(request.params.extensionId); + + response.appendResponseLine(`# Sidepanel Opened Successfully`); + response.appendResponseLine(''); + response.appendResponseLine(`**URL:** ${result.url}`); + response.appendResponseLine(`**Window ID:** ${result.windowId}`); + response.appendResponseLine(''); + response.appendResponseLine(`> ${result.note}`); + response.appendResponseLine(''); + response.appendResponseLine('Use `list_pages` to see the sidepanel and `select_page` to interact with it.'); + + response.setIncludePages(true); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + response.appendResponseLine(`# Failed to Open Sidepanel`); + response.appendResponseLine(''); + response.appendResponseLine(`**Error:** ${errorMessage}`); + response.appendResponseLine(''); + response.appendResponseLine('**Troubleshooting:**'); + response.appendResponseLine('- Ensure the extension is installed and enabled'); + response.appendResponseLine('- Verify the extension has a `side_panel.default_path` in its manifest.json'); + response.appendResponseLine('- Check that the extension has a service worker running'); + response.appendResponseLine('- Use `list_pages` to see available service workers'); + } + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 227fb0d42..b670226ab 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -5,6 +5,7 @@ */ import * as consoleTools from './console.js'; import * as emulationTools from './emulation.js'; +import * as extensionTools from './extension.js'; import * as inputTools from './input.js'; import * as networkTools from './network.js'; import * as pagesTools from './pages.js'; @@ -17,6 +18,7 @@ import type {ToolDefinition} from './ToolDefinition.js'; const tools = [ ...Object.values(consoleTools), ...Object.values(emulationTools), + ...Object.values(extensionTools), ...Object.values(inputTools), ...Object.values(networkTools), ...Object.values(pagesTools), diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 45c09a3c2..7dd193865 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -11,7 +11,12 @@ import {describe, it} from 'node:test'; import {executablePath} from 'puppeteer'; -import {ensureBrowserConnected, launch} from '../src/browser.js'; +import { + ensureBrowserConnected, + launch, + setExtensionDebuggingEnabled, + isExtensionDebuggingEnabled, +} from '../src/browser.js'; describe('browser', () => { it('cannot launch multiple times with the same profile', async () => { @@ -97,3 +102,29 @@ describe('browser', () => { } }); }); + +describe('extension debugging', () => { + it('is disabled by default', () => { + // Reset to default state + setExtensionDebuggingEnabled(false); + assert.strictEqual(isExtensionDebuggingEnabled(), false); + }); + + it('can be enabled', () => { + setExtensionDebuggingEnabled(true); + assert.strictEqual(isExtensionDebuggingEnabled(), true); + // Reset for other tests + setExtensionDebuggingEnabled(false); + }); + + it('can be toggled', () => { + setExtensionDebuggingEnabled(false); + assert.strictEqual(isExtensionDebuggingEnabled(), false); + + setExtensionDebuggingEnabled(true); + assert.strictEqual(isExtensionDebuggingEnabled(), true); + + setExtensionDebuggingEnabled(false); + assert.strictEqual(isExtensionDebuggingEnabled(), false); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1312e630b..f40156107 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -19,6 +19,8 @@ describe('cli args parsing', () => { categoryNetwork: true, 'auto-connect': undefined, autoConnect: undefined, + 'enable-extensions': false, + enableExtensions: false, }; it('parses with default args', async () => { @@ -222,4 +224,20 @@ describe('cli args parsing', () => { autoConnect: true, }); }); + + it('parses --enableExtensions flag', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--enableExtensions', + ]); + assert.strictEqual(args.enableExtensions, true); + assert.strictEqual(args['enable-extensions'], true); + }); + + it('enableExtensions defaults to false', async () => { + const args = parseArguments('1.0.0', ['node', 'main.js']); + assert.strictEqual(args.enableExtensions, false); + assert.strictEqual(args['enable-extensions'], false); + }); });