Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 15 additions & 3 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -112,6 +116,7 @@ export class McpContext implements Context {
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;
#extensionRegistry = new ExtensionRegistry();

#isRunningTrace = false;
#networkConditionsMap = new WeakMap<Page, string>();
Expand Down Expand Up @@ -753,11 +758,18 @@ export class McpContext implements Context {
await this.#networkCollector.init(await this.browser.pages());
}

async installExtension(path: string): Promise<string> {
return this.browser.installExtension(path);
async installExtension(extensionPath: string): Promise<string> {
const id = await this.browser.installExtension(extensionPath);
await this.#extensionRegistry.registerExtension(id, extensionPath);
return id;
}

async uninstallExtension(id: string): Promise<void> {
return this.browser.uninstallExtension(id);
await this.browser.uninstallExtension(id);
this.#extensionRegistry.remove(id);
}

listExtensions(): InstalledExtension[] {
return this.#extensionRegistry.list();
}
}
29 changes: 29 additions & 0 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -60,6 +61,7 @@ export class McpResponse implements Response {
types?: string[];
includePreservedMessages?: boolean;
};
#listExtensions?: boolean;
#devToolsData?: DevToolsData;
#tabId?: string;

Expand All @@ -81,6 +83,10 @@ export class McpResponse implements Response {
};
}

setListExtensions(): void {
this.#listExtensions = true;
}

setIncludeNetworkRequests(
value: boolean,
options?: PaginationOptions & {
Expand Down Expand Up @@ -297,6 +303,11 @@ export class McpResponse implements Response {
}
}

let extensions: InstalledExtension[] | undefined;
if (this.#listExtensions) {
extensions = context.listExtensions();
}

let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;
if (this.#consoleDataOptions?.include) {
let messages = context.getConsoleData(
Expand Down Expand Up @@ -395,6 +406,7 @@ export class McpResponse implements Response {
networkRequests,
traceInsight: this.#attachedTraceInsight,
traceSummary: this.#attachedTraceSummary,
extensions,
});
}

Expand All @@ -409,6 +421,7 @@ export class McpResponse implements Response {
networkRequests?: NetworkFormatter[];
traceSummary?: TraceResult;
traceInsight?: TraceInsightData;
extensions?: InstalledExtension[];
},
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
const response = [`# ${toolName} response`];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -92,6 +93,7 @@ export interface Response {
insightSetId: string,
insightName: InsightName,
): void;
setListExtensions(): void;
}

/**
Expand Down Expand Up @@ -141,6 +143,7 @@ export type Context = Readonly<{
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
installExtension(path: string): Promise<string>;
uninstallExtension(id: string): Promise<void>;
listExtensions(): InstalledExtension[];
}>;

export function defineTool<Schema extends zod.ZodRawShape>(
Expand Down
15 changes: 15 additions & 0 deletions src/tools/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,18 @@ export const uninstallExtension = defineTool({
response.appendResponseLine(`Extension uninstalled. Id: ${id}`);
},
});

export const listExtensions = defineTool({
name: 'list_extensions',
description:
'Lists all installed extensions, 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();
},
});
53 changes: 53 additions & 0 deletions src/utils/ExtensionRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<string, InstalledExtension>();

async registerExtension(
id: string,
extensionPath: string,
): Promise<InstalledExtension> {
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);
}
}
28 changes: 28 additions & 0 deletions tests/McpResponse.test.js.snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
`;
42 changes: 42 additions & 0 deletions tests/McpResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
});
13 changes: 13 additions & 0 deletions tests/tools/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
);
});
});
});
Loading