Skip to content
Merged
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
6 changes: 6 additions & 0 deletions scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,15 @@ async function generateToolDocumentation(): Promise<void> {
// Convert ToolDefinitions to ToolWithAnnotations
const toolsWithAnnotations: ToolWithAnnotations[] = tools
.filter(tool => {
// Filter out extension tools
Comment thread
nattallius marked this conversation as resolved.
if (tool.name === 'install_extension') {
return false;
}

if (!tool.annotations.conditions) {
return true;
}

// Only include unconditional tools.
return tool.annotations.conditions.length === 0;
})
Expand Down
4 changes: 4 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,4 +690,8 @@ export class McpContext implements Context {
});
await this.#networkCollector.init(await this.browser.pages());
}

async installExtension(path: string): Promise<string> {
return this.browser.installExtension(path);
}
}
2 changes: 2 additions & 0 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ interface McpLaunchOptions {
chromeArgs?: string[];
ignoreDefaultChromeArgs?: string[];
devtools: boolean;
enableExtensions?: boolean;
}

export async function launch(options: McpLaunchOptions): Promise<Browser> {
Expand Down Expand Up @@ -201,6 +202,7 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
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
Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ async function getContext(): Promise<McpContext> {
ignoreDefaultChromeArgs,
acceptInsecureCerts: args.acceptInsecureCerts,
devtools,
enableExtensions: args.categoryExtensions,
});

if (context?.browser !== browser) {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type Context = Readonly<{
* Returns a reqid for a cdpRequestId.
*/
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
installExtension(path: string): Promise<string>;
}>;

export function defineTool<Schema extends zod.ZodRawShape>(
Expand Down
2 changes: 2 additions & 0 deletions src/tools/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum ToolCategory {
PERFORMANCE = 'performance',
NETWORK = 'network',
DEBUGGING = 'debugging',
EXTENSIONS = 'extensions',
}

export const labels = {
Expand All @@ -20,4 +21,5 @@ export const labels = {
[ToolCategory.PERFORMANCE]: 'Performance',
[ToolCategory.NETWORK]: 'Network',
[ToolCategory.DEBUGGING]: 'Debugging',
[ToolCategory.EXTENSIONS]: 'Extensions',
};
29 changes: 29 additions & 0 deletions src/tools/extensions.ts
Original file line number Diff line number Diff line change
@@ -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({
Comment thread
nattallius marked this conversation as resolved.
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}`);
},
});
2 changes: 2 additions & 0 deletions src/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ describe('e2e', () => {
) {
continue;
}
if (maybeTool.name === 'install_extension') {
continue;
}
definedNames.push(maybeTool.name);
}
}
Expand All @@ -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 => {
Expand Down
46 changes: 46 additions & 0 deletions tests/tools/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Comment thread
nattallius marked this conversation as resolved.
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`,
);
});
});
});
8 changes: 8 additions & 0 deletions tests/tools/fixtures/extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"manifest_version": 3,
"name": "Test Extension",
"version": "1.0",
"action": {
"default_popup": "popup.html"
}
}
6 changes: 6 additions & 0 deletions tests/tools/fixtures/extension/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
<h1>Test Popup</h1>
</body>
</html>
1 change: 1 addition & 0 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export async function withBrowser(
pipe: true,
handleDevToolsAsPage: true,
args: ['--screen-info={3840x2160}'],
enableExtensions: true,
};
const key = JSON.stringify(launchOptions);

Expand Down