diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 3294fd4ab..4cfd316cc 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -278,9 +278,15 @@ async function generateToolDocumentation(): Promise { // Convert ToolDefinitions to ToolWithAnnotations const toolsWithAnnotations: ToolWithAnnotations[] = tools .filter(tool => { + // 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/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/browser.ts b/src/browser.ts index 628007f6b..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,6 +202,7 @@ export async function launch(options: McpLaunchOptions): Promise { ignoreDefaultArgs: ignoreDefaultArgs, acceptInsecureCerts: options.acceptInsecureCerts, handleDevToolsAsPage: true, + enableExtensions: options.enableExtensions, }); 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 24d5efba2..e617c31eb 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.', }, + categoryExtensions: { + type: 'boolean', + default: false, + 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. diff --git a/src/main.ts b/src/main.ts index a4976c0fb..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) { @@ -139,6 +140,12 @@ function registerTool(tool: ToolDefinition): void { ) { return; } + if ( + tool.annotations.category === ToolCategory.EXTENSIONS && + args.categoryExtensions === 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..9e3512689 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -11,6 +11,7 @@ export enum ToolCategory { PERFORMANCE = 'performance', NETWORK = 'network', DEBUGGING = 'debugging', + EXTENSIONS = 'extensions', } export const labels = { @@ -20,4 +21,5 @@ export const labels = { [ToolCategory.PERFORMANCE]: 'Performance', [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', + [ToolCategory.EXTENSIONS]: 'Extensions', }; diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts new file mode 100644 index 000000000..245c25b7a --- /dev/null +++ b/src/tools/extensions.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 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 installExtension = defineTool({ + name: 'install_extension', + description: 'Installs a Chrome extension from the given path.', + annotations: { + category: ToolCategory.EXTENSIONS, + readOnlyHint: false, + }, + schema: { + path: zod + .string() + .describe('Absolute path to the unpacked extension folder.'), + }, + handler: async (request, response, context) => { + const {path} = request.params; + const id = await context.installExtension(path); + response.appendResponseLine(`Extension installed. Id: ${id}`); + }, +}); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 227fb0d42..140edf61c 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 './extensions.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/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/extensions.test.ts b/tests/tools/extensions.test.ts new file mode 100644 index 000000000..f1ed6566f --- /dev/null +++ b/tests/tools/extensions.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import {describe, it} from 'node:test'; + +import {installExtension} from '../../src/tools/extensions.js'; +import {withMcpContext} from '../utils.js'; + +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}}, + 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`, + ); + }); + }); +}); 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..c5c00a395 --- /dev/null +++ b/tests/tools/fixtures/extension/popup.html @@ -0,0 +1,6 @@ + + + +

Test Popup

+ + 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);