diff --git a/src/McpContext.ts b/src/McpContext.ts index 557f012fa..7335a1308 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -35,6 +35,10 @@ import {takeSnapshot} from './tools/snapshot.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; import type {Context, DevToolsData} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; +import { + ExtensionRegistry, + type InstalledExtension, +} from './utils/ExtensionRegistry.js'; import {WaitForHelper} from './WaitForHelper.js'; export interface TextSnapshotNode extends SerializedAXNode { @@ -112,6 +116,7 @@ export class McpContext implements Context { #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #devtoolsUniverseManager: UniverseManager; + #extensionRegistry = new ExtensionRegistry(); #isRunningTrace = false; #networkConditionsMap = new WeakMap(); @@ -753,11 +758,18 @@ export class McpContext implements Context { await this.#networkCollector.init(await this.browser.pages()); } - async installExtension(path: string): Promise { - return this.browser.installExtension(path); + async installExtension(extensionPath: string): Promise { + const id = await this.browser.installExtension(extensionPath); + await this.#extensionRegistry.registerExtension(id, extensionPath); + return id; } async uninstallExtension(id: string): Promise { - return this.browser.uninstallExtension(id); + await this.browser.uninstallExtension(id); + this.#extensionRegistry.remove(id); + } + + listExtensions(): InstalledExtension[] { + return this.#extensionRegistry.list(); } } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index d4f538a62..7ed2e7577 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -25,6 +25,7 @@ import type { } from './tools/ToolDefinition.js'; import type {InsightName, TraceResult} from './trace-processing/parse.js'; import {getInsightOutput, getTraceSummary} from './trace-processing/parse.js'; +import type {InstalledExtension} from './utils/ExtensionRegistry.js'; import {paginate} from './utils/pagination.js'; import type {PaginationOptions} from './utils/types.js'; @@ -60,6 +61,7 @@ export class McpResponse implements Response { types?: string[]; includePreservedMessages?: boolean; }; + #listExtensions?: boolean; #devToolsData?: DevToolsData; #tabId?: string; @@ -81,6 +83,10 @@ export class McpResponse implements Response { }; } + setListExtensions(): void { + this.#listExtensions = true; + } + setIncludeNetworkRequests( value: boolean, options?: PaginationOptions & { @@ -297,6 +303,11 @@ export class McpResponse implements Response { } } + let extensions: InstalledExtension[] | undefined; + if (this.#listExtensions) { + extensions = context.listExtensions(); + } + let consoleMessages: Array | undefined; if (this.#consoleDataOptions?.include) { let messages = context.getConsoleData( @@ -395,6 +406,7 @@ export class McpResponse implements Response { networkRequests, traceInsight: this.#attachedTraceInsight, traceSummary: this.#attachedTraceSummary, + extensions, }); } @@ -409,6 +421,7 @@ export class McpResponse implements Response { networkRequests?: NetworkFormatter[]; traceSummary?: TraceResult; traceInsight?: TraceInsightData; + extensions?: InstalledExtension[]; }, ): {content: Array; structuredContent: object} { const response = [`# ${toolName} response`]; @@ -474,6 +487,7 @@ Call ${handleDialog.name} to handle it before continuing.`); consoleMessages?: object[]; traceSummary?: string; traceInsights?: Array<{insightName: string; insightKey: string}>; + extensions?: object[]; } = {}; if (this.#tabId) { @@ -531,6 +545,21 @@ Call ${handleDialog.name} to handle it before continuing.`); data.detailedConsoleMessage.toJSONDetailed(); } + if (data.extensions) { + structuredContent.extensions = data.extensions; + response.push('## Extensions'); + if (data.extensions.length === 0) { + response.push('No extensions installed.'); + } else { + const extensionsMessage = data.extensions + .map(extension => { + return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.isEnabled ? 'Enabled' : 'Disabled'}`; + }) + .join('\n'); + response.push(extensionsMessage); + } + } + if (this.#networkRequestsOptions?.include) { let requests = context.getNetworkRequests( this.#networkRequestsOptions?.includePreservedRequests, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 1cfa9751f..11b01a44b 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -13,6 +13,7 @@ import type { Viewport, } from '../third_party/index.js'; import type {InsightName, TraceResult} from '../trace-processing/parse.js'; +import type {InstalledExtension} from '../utils/ExtensionRegistry.js'; import type {PaginationOptions} from '../utils/types.js'; import type {ToolCategory} from './categories.js'; @@ -92,6 +93,7 @@ export interface Response { insightSetId: string, insightName: InsightName, ): void; + setListExtensions(): void; } /** @@ -141,6 +143,7 @@ export type Context = Readonly<{ resolveCdpElementId(cdpBackendNodeId: number): string | undefined; installExtension(path: string): Promise; uninstallExtension(id: string): Promise; + listExtensions(): InstalledExtension[]; }>; export function defineTool( diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 42ca85f43..431fb9f1b 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -48,3 +48,18 @@ export const uninstallExtension = defineTool({ response.appendResponseLine(`Extension uninstalled. Id: ${id}`); }, }); + +export const listExtensions = defineTool({ + name: 'list_extensions', + description: + 'Lists all extensions via this server, including their name, ID, version, and enabled status.', + annotations: { + category: ToolCategory.EXTENSIONS, + readOnlyHint: true, + conditions: [EXTENSIONS_CONDITION], + }, + schema: {}, + handler: async (_request, response, _context) => { + response.setListExtensions(); + }, +}); diff --git a/src/utils/ExtensionRegistry.ts b/src/utils/ExtensionRegistry.ts new file mode 100644 index 000000000..e652d64fe --- /dev/null +++ b/src/utils/ExtensionRegistry.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface InstalledExtension { + id: string; + name: string; + version: string; + isEnabled: boolean; + path: string; +} + +export class ExtensionRegistry { + #extensions = new Map(); + + async registerExtension( + id: string, + extensionPath: string, + ): Promise { + const manifestPath = path.join(extensionPath, 'manifest.json'); + const manifestContent = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestContent); + const name = manifest.name ?? 'Unknown'; + const version = manifest.version ?? 'Unknown'; + + const extension = { + id, + name, + version, + isEnabled: true, + path: extensionPath, + }; + this.#extensions.set(extension.id, extension); + return extension; + } + + remove(id: string): void { + this.#extensions.delete(id); + } + + list(): InstalledExtension[] { + return Array.from(this.#extensions.values()); + } + + getById(id: string): InstalledExtension | undefined { + return this.#extensions.get(id); + } +} diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index c2034ee7d..6e12fff21 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -480,3 +480,31 @@ exports[`McpResponse network request filtering > shows no requests when filter m ## Network requests No requests found. `; + +exports[`extensions > lists extensions 1`] = ` +# test response +## Extensions +id=id1 "Extension 1" v1.0 Enabled +id=id2 "Extension 2" v2.0 Disabled +`; + +exports[`extensions > lists extensions 2`] = ` +{ + "extensions": [ + { + "id": "id1", + "name": "Extension 1", + "version": "1.0", + "isEnabled": true, + "path": "/path/to/ext1" + }, + { + "id": "id2", + "name": "Extension 2", + "version": "2.0", + "isEnabled": false, + "path": "/path/to/ext2" + } + ] +} +`; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 574b1309b..68eac2f0b 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -713,3 +713,45 @@ describe('McpResponse network pagination', () => { }); }); }); + +describe('extensions', () => { + it('lists extensions', async t => { + await withMcpContext(async (response, context) => { + response.setListExtensions(); + // Empty state testing + const emptyResult = await response.handle('test', context); + const emptyText = getTextContent(emptyResult.content[0]); + assert.ok( + emptyText.includes('No extensions installed.'), + 'Should show message for ampty extensions', + ); + + response.resetResponseLineForTesting(); + // Testing with extensions + context.listExtensions = () => [ + { + id: 'id1', + name: 'Extension 1', + version: '1.0', + isEnabled: true, + path: '/path/to/ext1', + }, + { + id: 'id2', + name: 'Extension 2', + version: '2.0', + isEnabled: false, + path: '/path/to/ext2', + }, + ]; + response.setListExtensions(); + const {content, structuredContent} = await response.handle( + 'test', + context, + ); + + t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); + }); + }); +}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index effaf4807..0405c5f8b 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -8,9 +8,12 @@ import assert from 'node:assert'; import path from 'node:path'; import {describe, it} from 'node:test'; +import sinon from 'sinon'; + import { installExtension, uninstallExtension, + listExtensions, } from '../../src/tools/extensions.js'; import {withMcpContext} from '../utils.js'; @@ -71,4 +74,14 @@ describe('extension', () => { ); }); }); + it('lists installed extensions', async () => { + await withMcpContext(async (response, context) => { + const setListExtensionsSpy = sinon.spy(response, 'setListExtensions'); + await listExtensions.handler({params: {}}, response, context); + assert.ok( + setListExtensionsSpy.calledOnce, + 'setListExtensions should be called', + ); + }); + }); });