From e31370283814140cf1cd4a54e48b3dd49b08682e Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Tue, 13 Jan 2026 16:06:07 +0000 Subject: [PATCH 1/9] Implement install_extension command --- src/McpContext.ts | 4 +++ src/cli.ts | 7 ++++++ src/main.ts | 49 ++++++++++++++++++++++--------------- src/tools/ToolDefinition.ts | 1 + src/tools/categories.ts | 2 ++ src/tools/extension.ts | 35 ++++++++++++++++++++++++++ src/tools/tools.ts | 2 ++ 7 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 src/tools/extension.ts diff --git a/src/McpContext.ts b/src/McpContext.ts index aa848493d..05bf6f16f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -690,4 +690,8 @@ export class McpContext implements Context { }); await this.#networkCollector.init(await this.browser.pages()); } + + async installExtension(path: string): Promise { + return this.browser.installExtension(path); + } } diff --git a/src/cli.ts b/src/cli.ts index 24d5efba2..0e9dcf3ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -198,6 +198,12 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, + categoryExtension: { + type: 'boolean', + default: true, + hidden: true, + describe: 'Set to false to exclude tools related to extensions.', + }, usageStatistics: { type: 'boolean', // Marked as `false` until the feature is ready to be enabled by default. @@ -261,6 +267,7 @@ export function parseArguments(version: string, argv = process.argv) { 'Disable tools in the performance category', ], ['$0 --no-category-network', 'Disable tools in the network category'], + ['$0 --no-category-extension', 'Disable tools in the extension category'], [ '$0 --user-data-dir=/tmp/user-data-dir', 'Use a custom user data directory', diff --git a/src/main.ts b/src/main.ts index a4976c0fb..0aedf8dcd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,6 +64,9 @@ async function getContext(): Promise { const ignoreDefaultChromeArgs: string[] = ( args.ignoreDefaultChromeArg ?? [] ).map(String); + if (args.categoryExtension) { + chromeArgs.push('--enable-unsafe-extension-debugging'); + } if (args.proxyServer) { chromeArgs.push(`--proxy-server=${args.proxyServer}`); } @@ -71,27 +74,27 @@ async function getContext(): Promise { const browser = args.browserUrl || args.wsEndpoint || args.autoConnect ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - wsEndpoint: args.wsEndpoint, - wsHeaders: args.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: args.autoConnect ? (args.channel as Channel) : undefined, - userDataDir: args.userDataDir, - devtools, - }) + browserURL: args.browserUrl, + wsEndpoint: args.wsEndpoint, + wsHeaders: args.wsHeaders, + // Important: only pass channel, if autoConnect is true. + channel: args.autoConnect ? (args.channel as Channel) : undefined, + userDataDir: args.userDataDir, + devtools, + }) : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated ?? false, - userDataDir: args.userDataDir, - logFile, - viewport: args.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - }); + headless: args.headless, + executablePath: args.executablePath, + channel: args.channel as Channel, + isolated: args.isolated ?? false, + userDataDir: args.userDataDir, + logFile, + viewport: args.viewport, + chromeArgs, + ignoreDefaultChromeArgs, + acceptInsecureCerts: args.acceptInsecureCerts, + devtools, + }); if (context?.browser !== browser) { context = await McpContext.from(browser, logger, { @@ -139,6 +142,12 @@ function registerTool(tool: ToolDefinition): void { ) { return; } + if ( + tool.annotations.category === ToolCategory.EXTENSION && + args.categoryExtension === false + ) { + return; + } if ( tool.annotations.conditions?.includes('computerVision') && !args.experimentalVision diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index feae3c7be..f2b699021 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -121,6 +121,7 @@ export type Context = Readonly<{ * Returns a reqid for a cdpRequestId. */ resolveCdpElementId(cdpBackendNodeId: number): string | undefined; + installExtension(path: string): Promise; }>; export function defineTool( diff --git a/src/tools/categories.ts b/src/tools/categories.ts index f27a80361..161ff55b1 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -11,6 +11,7 @@ export enum ToolCategory { PERFORMANCE = 'performance', NETWORK = 'network', DEBUGGING = 'debugging', + EXTENSION = 'extension', } export const labels = { @@ -20,4 +21,5 @@ export const labels = { [ToolCategory.PERFORMANCE]: 'Performance', [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', + [ToolCategory.EXTENSION]: 'Extensions', }; diff --git a/src/tools/extension.ts b/src/tools/extension.ts new file mode 100644 index 000000000..f23d490a7 --- /dev/null +++ b/src/tools/extension.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { logger } from '../logger.js'; +import { zod } from '../third_party/index.js'; + +import { ToolCategory } from './categories.js'; +import { defineTool } from './ToolDefinition.js'; + +export const installExtension = defineTool({ + name: 'install_extension', + description: 'Installs a Chrome extension from the given path.', + annotations: { + category: ToolCategory.EXTENSION, + readOnlyHint: false, + }, + schema: { + path: zod + .string() + .describe('Absolute path to the unpacked extension folder.'), + }, + handler: async (request, response, context) => { + const { path } = request.params; + try { + const id = await context.installExtension(path); + response.appendResponseLine(`Extension installed: ${id}`); + } catch (error) { + logger('Extension installation error: ', error); + throw error; + } + }, +}); 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), From aa0875892077f0e517f1e34bb2bd15635973d0fa Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Wed, 14 Jan 2026 17:23:34 +0000 Subject: [PATCH 2/9] Implement install_extension command Fix pr comments --- README.md | 2 + docs/tool-reference.md | 14 +++++ src/browser.ts | 1 + src/cli.ts | 5 +- src/main.ts | 47 ++++++++------- src/tools/categories.ts | 4 +- src/tools/{extension.ts => extensions.ts} | 14 ++--- src/tools/tools.ts | 2 +- tests/cli.test.ts | 2 + tests/index.test.ts | 14 +++++ tests/tools/extension.test.ts | 68 ++++++++++++++++++++++ tests/tools/extensions.test.ts | 69 +++++++++++++++++++++++ tests/utils.ts | 1 + 13 files changed, 205 insertions(+), 38 deletions(-) rename src/tools/{extension.ts => extensions.ts} (65%) create mode 100644 tests/tools/extension.test.ts create mode 100644 tests/tools/extensions.test.ts diff --git a/README.md b/README.md index 1f663eb60..8b1681af9 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,8 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) +- **Extensions** (1 tools) + - [`install_extension`](docs/tool-reference.md#install_extension) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ac8ab1f53..60895c56b 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -34,6 +34,8 @@ - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) +- **[Extensions](#extensions)** (1 tools) + - [`install_extension`](#install_extension) ## Input automation @@ -345,3 +347,15 @@ in the DevTools Elements panel (if any). - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- + +## Extensions + +### `install_extension` + +**Description:** Installs a Chrome extension from the given path. + +**Parameters:** + +- **path** (string) **(required)**: Absolute path to the unpacked extension folder. + +--- diff --git a/src/browser.ts b/src/browser.ts index 628007f6b..71e9376ca 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -201,6 +201,7 @@ export async function launch(options: McpLaunchOptions): Promise { ignoreDefaultArgs: ignoreDefaultArgs, acceptInsecureCerts: options.acceptInsecureCerts, handleDevToolsAsPage: true, + enableExtensions: true, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We diff --git a/src/cli.ts b/src/cli.ts index 0e9dcf3ea..e617c31eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -198,9 +198,9 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, - categoryExtension: { + categoryExtensions: { type: 'boolean', - default: true, + default: false, hidden: true, describe: 'Set to false to exclude tools related to extensions.', }, @@ -267,7 +267,6 @@ export function parseArguments(version: string, argv = process.argv) { 'Disable tools in the performance category', ], ['$0 --no-category-network', 'Disable tools in the network category'], - ['$0 --no-category-extension', 'Disable tools in the extension category'], [ '$0 --user-data-dir=/tmp/user-data-dir', 'Use a custom user data directory', diff --git a/src/main.ts b/src/main.ts index 0aedf8dcd..665dd1cf7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -64,9 +64,6 @@ async function getContext(): Promise { const ignoreDefaultChromeArgs: string[] = ( args.ignoreDefaultChromeArg ?? [] ).map(String); - if (args.categoryExtension) { - chromeArgs.push('--enable-unsafe-extension-debugging'); - } if (args.proxyServer) { chromeArgs.push(`--proxy-server=${args.proxyServer}`); } @@ -74,27 +71,27 @@ async function getContext(): Promise { const browser = args.browserUrl || args.wsEndpoint || args.autoConnect ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - wsEndpoint: args.wsEndpoint, - wsHeaders: args.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: args.autoConnect ? (args.channel as Channel) : undefined, - userDataDir: args.userDataDir, - devtools, - }) + browserURL: args.browserUrl, + wsEndpoint: args.wsEndpoint, + wsHeaders: args.wsHeaders, + // Important: only pass channel, if autoConnect is true. + channel: args.autoConnect ? (args.channel as Channel) : undefined, + userDataDir: args.userDataDir, + devtools, + }) : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated ?? false, - userDataDir: args.userDataDir, - logFile, - viewport: args.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - }); + headless: args.headless, + executablePath: args.executablePath, + channel: args.channel as Channel, + isolated: args.isolated ?? false, + userDataDir: args.userDataDir, + logFile, + viewport: args.viewport, + chromeArgs, + ignoreDefaultChromeArgs, + acceptInsecureCerts: args.acceptInsecureCerts, + devtools, + }); if (context?.browser !== browser) { context = await McpContext.from(browser, logger, { @@ -143,8 +140,8 @@ function registerTool(tool: ToolDefinition): void { return; } if ( - tool.annotations.category === ToolCategory.EXTENSION && - args.categoryExtension === false + tool.annotations.category === ToolCategory.EXTENSIONS && + args.categoryExtensions === false ) { return; } diff --git a/src/tools/categories.ts b/src/tools/categories.ts index 161ff55b1..9e3512689 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -11,7 +11,7 @@ export enum ToolCategory { PERFORMANCE = 'performance', NETWORK = 'network', DEBUGGING = 'debugging', - EXTENSION = 'extension', + EXTENSIONS = 'extensions', } export const labels = { @@ -21,5 +21,5 @@ export const labels = { [ToolCategory.PERFORMANCE]: 'Performance', [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', - [ToolCategory.EXTENSION]: 'Extensions', + [ToolCategory.EXTENSIONS]: 'Extensions', }; diff --git a/src/tools/extension.ts b/src/tools/extensions.ts similarity index 65% rename from src/tools/extension.ts rename to src/tools/extensions.ts index f23d490a7..25537bd2a 100644 --- a/src/tools/extension.ts +++ b/src/tools/extensions.ts @@ -4,17 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { logger } from '../logger.js'; -import { zod } from '../third_party/index.js'; +import {logger} from '../logger.js'; +import {zod} from '../third_party/index.js'; -import { ToolCategory } from './categories.js'; -import { defineTool } from './ToolDefinition.js'; +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; export const installExtension = defineTool({ name: 'install_extension', description: 'Installs a Chrome extension from the given path.', annotations: { - category: ToolCategory.EXTENSION, + category: ToolCategory.EXTENSIONS, readOnlyHint: false, }, schema: { @@ -23,10 +23,10 @@ export const installExtension = defineTool({ .describe('Absolute path to the unpacked extension folder.'), }, handler: async (request, response, context) => { - const { path } = request.params; + const {path} = request.params; try { const id = await context.installExtension(path); - response.appendResponseLine(`Extension installed: ${id}`); + response.appendResponseLine(`Extension installed. Id: ${id}`); } catch (error) { logger('Extension installation error: ', error); throw error; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index b670226ab..140edf61c 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -5,7 +5,7 @@ */ import * as consoleTools from './console.js'; import * as emulationTools from './emulation.js'; -import * as extensionTools from './extension.js'; +import * as extensionTools from './extensions.js'; import * as inputTools from './input.js'; import * as networkTools from './network.js'; import * as pagesTools from './pages.js'; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index ccbeac8cd..add84e328 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -15,6 +15,8 @@ describe('cli args parsing', () => { categoryEmulation: true, 'category-performance': true, categoryPerformance: true, + 'category-extensions': false, + categoryExtensions: false, 'category-network': true, categoryNetwork: true, 'auto-connect': undefined, diff --git a/tests/index.test.ts b/tests/index.test.ts index 021567a80..0490af334 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -108,6 +108,9 @@ describe('e2e', () => { ) { continue; } + if (maybeTool.name === 'install_extension') { + continue; + } definedNames.push(maybeTool.name); } } @@ -117,6 +120,17 @@ describe('e2e', () => { }); }); + it('has experimental extensions tools', async () => { + await withClient( + async client => { + const {tools} = await client.listTools(); + const clickAt = tools.find(t => t.name === 'install_extension'); + assert.ok(clickAt); + }, + ['--category-extensions'], + ); + }); + it('has experimental vision tools', async () => { await withClient( async client => { diff --git a/tests/tools/extension.test.ts b/tests/tools/extension.test.ts new file mode 100644 index 000000000..4ccd508c4 --- /dev/null +++ b/tests/tools/extension.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {describe, it, after} from 'node:test'; + +import {installExtension} from '../../src/tools/extensions.js'; +import {withMcpContext} from '../utils.js'; + +describe('extension', () => { + let tmpDir: string; + + it('installs an extension and verifies it is listed in chrome://extensions', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-test-')); + fs.writeFileSync( + path.join(tmpDir, 'manifest.json'), + JSON.stringify({ + manifest_version: 3, + name: 'Test Extension', + version: '1.0', + action: { + default_popup: 'popup.html', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'popup.html'), + '

Test Popup

', + ); + + await withMcpContext(async (response, context) => { + await installExtension.handler( + {params: {path: tmpDir}}, + response, + context, + ); + + const responseLine = response.responseLines[0]; + assert.ok(responseLine, 'Response should not be empty'); + const match = responseLine.match(/Extension installed\. Id: (.+)/); + const extensionId = match ? match[1] : null; + assert.ok(extensionId, 'Response should contain a valid key'); + + const page = context.getSelectedPage(); + await page.goto('chrome://extensions'); + + const element = await page.waitForSelector( + `extensions-manager >>> extensions-item[id="${extensionId}"]`, + ); + assert.ok( + element, + `Extension with ID "${extensionId}" should be visible on chrome://extensions`, + ); + }); + }); + + after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, {recursive: true, force: true}); + } + }); +}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts new file mode 100644 index 000000000..dfd48dc95 --- /dev/null +++ b/tests/tools/extensions.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import {installExtension} from '../../src/tools/extensions.js'; +import {withMcpContext} from '../utils.js'; + +const EXTENSION_PATH = '/usr/local/google/home/nharshunova/test/extensions'; +const EXTENSION_ID = 'emhhlofcjnaambdnpppkpbcimdeaccnn'; + +describe('extension', () => { + it('installs an extension and verifies it is listed in chrome://extensions', async () => { + await withMcpContext(async (response, context) => { + // 1. Install the extension + await installExtension.handler( + {params: {path: EXTENSION_PATH}}, + response, + context, + ); + + // 2. Verify response + assert.ok( + response.responseLines[0]?.includes(EXTENSION_ID), + `Response should include extension ID ${EXTENSION_ID}`, + ); + + // 3. Verify extension is accessible by navigating to its popup + const page = context.getSelectedPage(); + await page.goto(`chrome-extension://${EXTENSION_ID}/popup.html`); + const popupContent = await page.content(); + assert.ok( + popupContent.includes('Popup Action'), + 'Extension popup should be accessible', + ); + + // 4. Verify extension presence in chrome://extensions UI + // 4. Verify extension presence in chrome://extensions UI + await page.goto('chrome://extensions'); + + // Wait for usage to ensure page is loaded + await new Promise(r => setTimeout(r, 2000)); + + const EXTENSION_NAME = 'Simple Popup Action'; + const found = await page.evaluate(extName => { + function deepSearch(root: Element | ShadowRoot): boolean { + if (root.textContent?.includes(extName)) return true; + if ('shadowRoot' in root && root.shadowRoot) { + if (deepSearch(root.shadowRoot)) return true; + } + for (const child of Array.from(root.children)) { + if (deepSearch(child)) return true; + } + return false; + } + return deepSearch(document.body); + }, EXTENSION_NAME); + + assert.ok( + found, + `Extension "${EXTENSION_NAME}" should be visible on chrome://extensions`, + ); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index f571ee721..d95384bcf 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -56,6 +56,7 @@ export async function withBrowser( pipe: true, handleDevToolsAsPage: true, args: ['--screen-info={3840x2160}'], + enableExtensions: true, }; const key = JSON.stringify(launchOptions); From 2d7a8dcfcfcb4511839544f9fc330db935b46755 Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 13:23:31 +0000 Subject: [PATCH 3/9] Fix extension tests --- tests/tools/extension.test.ts | 68 ---------------------------- tests/tools/extensions.test.ts | 81 +++++++++++++++++----------------- 2 files changed, 40 insertions(+), 109 deletions(-) delete mode 100644 tests/tools/extension.test.ts diff --git a/tests/tools/extension.test.ts b/tests/tools/extension.test.ts deleted file mode 100644 index 4ccd508c4..000000000 --- a/tests/tools/extension.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import {describe, it, after} from 'node:test'; - -import {installExtension} from '../../src/tools/extensions.js'; -import {withMcpContext} from '../utils.js'; - -describe('extension', () => { - let tmpDir: string; - - it('installs an extension and verifies it is listed in chrome://extensions', async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-test-')); - fs.writeFileSync( - path.join(tmpDir, 'manifest.json'), - JSON.stringify({ - manifest_version: 3, - name: 'Test Extension', - version: '1.0', - action: { - default_popup: 'popup.html', - }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, 'popup.html'), - '

Test Popup

', - ); - - await withMcpContext(async (response, context) => { - await installExtension.handler( - {params: {path: tmpDir}}, - response, - context, - ); - - const responseLine = response.responseLines[0]; - assert.ok(responseLine, 'Response should not be empty'); - const match = responseLine.match(/Extension installed\. Id: (.+)/); - const extensionId = match ? match[1] : null; - assert.ok(extensionId, 'Response should contain a valid key'); - - const page = context.getSelectedPage(); - await page.goto('chrome://extensions'); - - const element = await page.waitForSelector( - `extensions-manager >>> extensions-item[id="${extensionId}"]`, - ); - assert.ok( - element, - `Extension with ID "${extensionId}" should be visible on chrome://extensions`, - ); - }); - }); - - after(() => { - if (tmpDir) { - fs.rmSync(tmpDir, {recursive: true, force: true}); - } - }); -}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index dfd48dc95..dc4a37297 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -5,65 +5,64 @@ */ import assert from 'node:assert'; -import {describe, it} from 'node:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, after } from 'node:test'; import {installExtension} from '../../src/tools/extensions.js'; import {withMcpContext} from '../utils.js'; -const EXTENSION_PATH = '/usr/local/google/home/nharshunova/test/extensions'; -const EXTENSION_ID = 'emhhlofcjnaambdnpppkpbcimdeaccnn'; - describe('extension', () => { + let tmpDir: string; + it('installs an extension and verifies it is listed in chrome://extensions', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-test-')); + fs.writeFileSync( + path.join(tmpDir, 'manifest.json'), + JSON.stringify({ + manifest_version: 3, + name: 'Test Extension', + version: '1.0', + action: { + default_popup: 'popup.html', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'popup.html'), + '

Test Popup

', + ); + await withMcpContext(async (response, context) => { - // 1. Install the extension await installExtension.handler( - {params: {path: EXTENSION_PATH}}, + { params: { path: tmpDir } }, response, context, ); - // 2. Verify response - assert.ok( - response.responseLines[0]?.includes(EXTENSION_ID), - `Response should include extension ID ${EXTENSION_ID}`, - ); + const responseLine = response.responseLines[0]; + assert.ok(responseLine, 'Response should not be empty'); + const match = responseLine.match(/Extension installed\. Id: (.+)/); + const extensionId = match ? match[1] : null; + assert.ok(extensionId, 'Response should contain a valid key'); - // 3. Verify extension is accessible by navigating to its popup const page = context.getSelectedPage(); - await page.goto(`chrome-extension://${EXTENSION_ID}/popup.html`); - const popupContent = await page.content(); - assert.ok( - popupContent.includes('Popup Action'), - 'Extension popup should be accessible', - ); - - // 4. Verify extension presence in chrome://extensions UI - // 4. Verify extension presence in chrome://extensions UI await page.goto('chrome://extensions'); - // Wait for usage to ensure page is loaded - await new Promise(r => setTimeout(r, 2000)); - - const EXTENSION_NAME = 'Simple Popup Action'; - const found = await page.evaluate(extName => { - function deepSearch(root: Element | ShadowRoot): boolean { - if (root.textContent?.includes(extName)) return true; - if ('shadowRoot' in root && root.shadowRoot) { - if (deepSearch(root.shadowRoot)) return true; - } - for (const child of Array.from(root.children)) { - if (deepSearch(child)) return true; - } - return false; - } - return deepSearch(document.body); - }, EXTENSION_NAME); - + const element = await page.waitForSelector( + `extensions-manager >>> extensions-item[id="${extensionId}"]`, + ); assert.ok( - found, - `Extension "${EXTENSION_NAME}" should be visible on chrome://extensions`, + element, + `Extension with ID "${extensionId}" should be visible on chrome://extensions`, ); }); }); + + after(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); From 181c46657974652d46eb7ad71849ed086078071e Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 13:38:04 +0000 Subject: [PATCH 4/9] Update formatting --- tests/tools/extensions.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index dc4a37297..4ccd508c4 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -8,7 +8,7 @@ import assert from 'node:assert'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, it, after } from 'node:test'; +import {describe, it, after} from 'node:test'; import {installExtension} from '../../src/tools/extensions.js'; import {withMcpContext} from '../utils.js'; @@ -36,7 +36,7 @@ describe('extension', () => { await withMcpContext(async (response, context) => { await installExtension.handler( - { params: { path: tmpDir } }, + {params: {path: tmpDir}}, response, context, ); @@ -62,7 +62,7 @@ describe('extension', () => { after(() => { if (tmpDir) { - fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(tmpDir, {recursive: true, force: true}); } }); }); From 4a2611deb536d76c9c7e862fd687bdf90c5c570c Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 15:33:09 +0000 Subject: [PATCH 5/9] Hide extensions from docs --- README.md | 2 -- docs/tool-reference.md | 18 ++---------------- scripts/generate-docs.ts | 6 ++++++ 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8b1681af9..1f663eb60 100644 --- a/README.md +++ b/README.md @@ -360,8 +360,6 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) -- **Extensions** (1 tools) - - [`install_extension`](docs/tool-reference.md#install_extension) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 60895c56b..fd6b84a5b 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -34,8 +34,6 @@ - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) -- **[Extensions](#extensions)** (1 tools) - - [`install_extension`](#install_extension) ## Input automation @@ -285,12 +283,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; }` @@ -347,15 +345,3 @@ in the DevTools Elements panel (if any). - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- - -## Extensions - -### `install_extension` - -**Description:** Installs a Chrome extension from the given path. - -**Parameters:** - -- **path** (string) **(required)**: Absolute path to the unpacked extension folder. - ---- diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 3294fd4ab..f98bacc94 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -281,6 +281,12 @@ async function generateToolDocumentation(): Promise { if (!tool.annotations.conditions) { return true; } + + // Filter out extension tools + if (tool.name === 'install_extension') { + return false; + } + // Only include unconditional tools. return tool.annotations.conditions.length === 0; }) From 25f85bb24a760550b554dffed8f5655347f6f0aa Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 16:13:23 +0000 Subject: [PATCH 6/9] Fix pr comments --- src/tools/extensions.ts | 14 +++----- tests/tools/extensions.test.ts | 37 ++++---------------- tests/tools/fixtures/extension/manifest.json | 8 +++++ tests/tools/fixtures/extension/popup.html | 1 + 4 files changed, 19 insertions(+), 41 deletions(-) create mode 100644 tests/tools/fixtures/extension/manifest.json create mode 100644 tests/tools/fixtures/extension/popup.html diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 25537bd2a..c16689b62 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -1,10 +1,9 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {logger} from '../logger.js'; import {zod} from '../third_party/index.js'; import {ToolCategory} from './categories.js'; @@ -23,13 +22,8 @@ export const installExtension = defineTool({ .describe('Absolute path to the unpacked extension folder.'), }, handler: async (request, response, context) => { - const {path} = request.params; - try { - const id = await context.installExtension(path); - response.appendResponseLine(`Extension installed. Id: ${id}`); - } catch (error) { - logger('Extension installation error: ', error); - throw error; - } + const { path } = request.params; + const id = await context.installExtension(path); + response.appendResponseLine(`Extension installed. Id: ${id}`); }, }); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 4ccd508c4..fd9a1a225 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -5,38 +5,19 @@ */ import assert from 'node:assert'; -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import {describe, it, after} from 'node:test'; +import { describe, it } from 'node:test'; -import {installExtension} from '../../src/tools/extensions.js'; -import {withMcpContext} from '../utils.js'; +import { installExtension } from '../../src/tools/extensions.js'; +import { withMcpContext } from '../utils.js'; -describe('extension', () => { - let tmpDir: string; +const EXTENSION_PATH = path.join(import.meta.dirname, '../../../tests/tools/fixtures/extension'); +describe('extension', () => { it('installs an extension and verifies it is listed in chrome://extensions', async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-test-')); - fs.writeFileSync( - path.join(tmpDir, 'manifest.json'), - JSON.stringify({ - manifest_version: 3, - name: 'Test Extension', - version: '1.0', - action: { - default_popup: 'popup.html', - }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, 'popup.html'), - '

Test Popup

', - ); - await withMcpContext(async (response, context) => { await installExtension.handler( - {params: {path: tmpDir}}, + { params: { path: EXTENSION_PATH } }, response, context, ); @@ -59,10 +40,4 @@ describe('extension', () => { ); }); }); - - after(() => { - if (tmpDir) { - fs.rmSync(tmpDir, {recursive: true, force: true}); - } - }); }); diff --git a/tests/tools/fixtures/extension/manifest.json b/tests/tools/fixtures/extension/manifest.json new file mode 100644 index 000000000..fc0304f0b --- /dev/null +++ b/tests/tools/fixtures/extension/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "Test Extension", + "version": "1.0", + "action": { + "default_popup": "popup.html" + } +} diff --git a/tests/tools/fixtures/extension/popup.html b/tests/tools/fixtures/extension/popup.html new file mode 100644 index 000000000..27f262c04 --- /dev/null +++ b/tests/tools/fixtures/extension/popup.html @@ -0,0 +1 @@ +

Test Popup

From be49b6ba4b0cf5caceaa158cf7841ed305597011 Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 16:29:33 +0000 Subject: [PATCH 7/9] Move extension test to fixtures --- docs/tool-reference.md | 4 ++-- scripts/generate-docs.ts | 10 +++++----- src/tools/extensions.ts | 2 +- tests/tools/extensions.test.ts | 13 ++++++++----- tests/tools/fixtures/extension/popup.html | 7 ++++++- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index fd6b84a5b..ac8ab1f53 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -283,12 +283,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; }` diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index f98bacc94..4cfd316cc 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -278,15 +278,15 @@ async function generateToolDocumentation(): Promise { // Convert ToolDefinitions to ToolWithAnnotations const toolsWithAnnotations: ToolWithAnnotations[] = tools .filter(tool => { - if (!tool.annotations.conditions) { - return true; - } - // Filter out extension tools if (tool.name === 'install_extension') { return false; } - + + if (!tool.annotations.conditions) { + return true; + } + // Only include unconditional tools. return tool.annotations.conditions.length === 0; }) diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index c16689b62..245c25b7a 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -22,7 +22,7 @@ export const installExtension = defineTool({ .describe('Absolute path to the unpacked extension folder.'), }, handler: async (request, response, context) => { - const { path } = request.params; + const {path} = request.params; const id = await context.installExtension(path); response.appendResponseLine(`Extension installed. Id: ${id}`); }, diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index fd9a1a225..cd754c29a 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -6,18 +6,21 @@ import assert from 'node:assert'; import path from 'node:path'; -import { describe, it } from 'node:test'; +import {describe, it} from 'node:test'; -import { installExtension } from '../../src/tools/extensions.js'; -import { withMcpContext } from '../utils.js'; +import {installExtension} from '../../src/tools/extensions.js'; +import {withMcpContext} from '../utils.js'; -const EXTENSION_PATH = path.join(import.meta.dirname, '../../../tests/tools/fixtures/extension'); +const EXTENSION_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension', +); describe('extension', () => { it('installs an extension and verifies it is listed in chrome://extensions', async () => { await withMcpContext(async (response, context) => { await installExtension.handler( - { params: { path: EXTENSION_PATH } }, + {params: {path: EXTENSION_PATH}}, response, context, ); diff --git a/tests/tools/fixtures/extension/popup.html b/tests/tools/fixtures/extension/popup.html index 27f262c04..72cb44d64 100644 --- a/tests/tools/fixtures/extension/popup.html +++ b/tests/tools/fixtures/extension/popup.html @@ -1 +1,6 @@ -

Test Popup

+ +

Test Popup

From f94c6aa8eaacea978d026cc206160ae0548b1bef Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Thu, 15 Jan 2026 16:38:03 +0000 Subject: [PATCH 8/9] Update license year --- tests/tools/extensions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index cd754c29a..f1ed6566f 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ From 6b7f2a939362f59f0f7be328e25d6118916ce6d2 Mon Sep 17 00:00:00 2001 From: Natallia Harshunova Date: Fri, 16 Jan 2026 11:02:08 +0000 Subject: [PATCH 9/9] pr comments --- src/browser.ts | 3 ++- src/main.ts | 1 + tests/tools/fixtures/extension/popup.html | 10 +++++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/browser.ts b/src/browser.ts index 71e9376ca..64db15681 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -145,6 +145,7 @@ interface McpLaunchOptions { chromeArgs?: string[]; ignoreDefaultChromeArgs?: string[]; devtools: boolean; + enableExtensions?: boolean; } export async function launch(options: McpLaunchOptions): Promise { @@ -201,7 +202,7 @@ export async function launch(options: McpLaunchOptions): Promise { ignoreDefaultArgs: ignoreDefaultArgs, acceptInsecureCerts: options.acceptInsecureCerts, handleDevToolsAsPage: true, - enableExtensions: true, + enableExtensions: options.enableExtensions, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We diff --git a/src/main.ts b/src/main.ts index 665dd1cf7..0c0dc9735 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,6 +91,7 @@ async function getContext(): Promise { ignoreDefaultChromeArgs, acceptInsecureCerts: args.acceptInsecureCerts, devtools, + enableExtensions: args.categoryExtensions, }); if (context?.browser !== browser) { diff --git a/tests/tools/fixtures/extension/popup.html b/tests/tools/fixtures/extension/popup.html index 72cb44d64..c5c00a395 100644 --- a/tests/tools/fixtures/extension/popup.html +++ b/tests/tools/fixtures/extension/popup.html @@ -1,6 +1,6 @@ -

Test Popup

+ + +

Test Popup

+ +