Skip to content

Commit 589dda9

Browse files
author
Natallia Harshunova
committed
Implement list_extensions tool for local unpacked extensions
1 parent a6cd2cd commit 589dda9

5 files changed

Lines changed: 130 additions & 3 deletions

File tree

src/McpContext.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {takeSnapshot} from './tools/snapshot.js';
3535
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
3636
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
3737
import type {TraceResult} from './trace-processing/parse.js';
38+
import { ExtensionRegistry, type InstalledExtension } from './utils/ExtensionRegistry.js';
3839
import {WaitForHelper} from './WaitForHelper.js';
3940

4041
export interface TextSnapshotNode extends SerializedAXNode {
@@ -112,6 +113,7 @@ export class McpContext implements Context {
112113
#networkCollector: NetworkCollector;
113114
#consoleCollector: ConsoleCollector;
114115
#devtoolsUniverseManager: UniverseManager;
116+
#extensionRegistry = new ExtensionRegistry();
115117

116118
#isRunningTrace = false;
117119
#networkConditionsMap = new WeakMap<Page, string>();
@@ -753,11 +755,19 @@ export class McpContext implements Context {
753755
await this.#networkCollector.init(await this.browser.pages());
754756
}
755757

756-
async installExtension(path: string): Promise<string> {
757-
return this.browser.installExtension(path);
758+
async installExtension(extensionPath: string): Promise<string> {
759+
const id = await this.browser.installExtension(extensionPath);
760+
await this.#extensionRegistry.registerExtension(id, extensionPath);
761+
return id;
758762
}
759763

760764
async uninstallExtension(id: string): Promise<void> {
761-
return this.browser.uninstallExtension(id);
765+
await this.browser.uninstallExtension(id);
766+
this.#extensionRegistry.remove(id);
767+
}
768+
769+
listExtensions(): InstalledExtension[] {
770+
return this.#extensionRegistry.list();
762771
}
763772
}
773+

src/tools/ToolDefinition.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
Viewport,
1414
} from '../third_party/index.js';
1515
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
16+
import type { InstalledExtension } from '../utils/ExtensionRegistry.js';
1617
import type {PaginationOptions} from '../utils/types.js';
1718

1819
import type {ToolCategory} from './categories.js';
@@ -141,6 +142,7 @@ export type Context = Readonly<{
141142
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
142143
installExtension(path: string): Promise<string>;
143144
uninstallExtension(id: string): Promise<void>;
145+
listExtensions(): InstalledExtension[];
144146
}>;
145147

146148
export function defineTool<Schema extends zod.ZodRawShape>(

src/tools/extensions.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const installExtension = defineTool({
2222
schema: {
2323
path: zod
2424
.string()
25+
.trim()
2526
.describe('Absolute path to the unpacked extension folder.'),
2627
},
2728
handler: async (request, response, context) => {
@@ -48,3 +49,29 @@ export const uninstallExtension = defineTool({
4849
response.appendResponseLine(`Extension uninstalled. Id: ${id}`);
4950
},
5051
});
52+
53+
export const listExtensions = defineTool({
54+
name: 'list_extensions',
55+
description:
56+
'Lists all installed extensions, including their name, ID, version, and enabled status.',
57+
annotations: {
58+
category: ToolCategory.EXTENSIONS,
59+
readOnlyHint: true,
60+
conditions: [EXTENSIONS_CONDITION],
61+
},
62+
schema: {},
63+
handler: async (request, response, context) => {
64+
const extensions = context.listExtensions();
65+
if (extensions.length === 0) {
66+
response.appendResponseLine('No extensions installed.');
67+
return;
68+
}
69+
const table = extensions.map(ext => ({
70+
Name: ext.name,
71+
ID: ext.id,
72+
Version: ext.version,
73+
Enabled: ext.isEnabled ? 'Yes' : 'No',
74+
}));
75+
response.appendResponseLine(JSON.stringify(table, null, 2));
76+
},
77+
});

src/utils/ExtensionRegistry.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs/promises';
8+
import path from 'node:path';
9+
10+
export interface InstalledExtension {
11+
id: string;
12+
name: string;
13+
version: string;
14+
isEnabled: boolean;
15+
path: string;
16+
}
17+
18+
export class ExtensionRegistry {
19+
#extensions = new Map<string, InstalledExtension>();
20+
21+
async registerExtension(id: string, extensionPath: string): Promise<InstalledExtension> {
22+
const manifestPath = path.join(extensionPath, 'manifest.json');
23+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
24+
const manifest = JSON.parse(manifestContent);
25+
const name = manifest.name ?? 'Unknown';
26+
const version = manifest.version ?? 'Unknown';
27+
28+
const extension: InstalledExtension = {
29+
id,
30+
name,
31+
version,
32+
isEnabled: true,
33+
path: extensionPath,
34+
};
35+
this.add(extension);
36+
return extension;
37+
}
38+
39+
add(extension: InstalledExtension): void {
40+
this.#extensions.set(extension.id, extension);
41+
}
42+
43+
remove(id: string): void {
44+
this.#extensions.delete(id);
45+
}
46+
47+
list(): InstalledExtension[] {
48+
return Array.from(this.#extensions.values());
49+
}
50+
51+
getById(id: string): InstalledExtension | undefined {
52+
return this.#extensions.get(id);
53+
}
54+
}
55+

tests/tools/extensions.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {describe, it} from 'node:test';
1111
import {
1212
installExtension,
1313
uninstallExtension,
14+
listExtensions,
1415
} from '../../src/tools/extensions.js';
1516
import {withMcpContext} from '../utils.js';
1617

@@ -71,4 +72,36 @@ describe('extension', () => {
7172
);
7273
});
7374
});
75+
it('lists installed extensions', async () => {
76+
await withMcpContext(async (response, context) => {
77+
await installExtension.handler(
78+
{ params: { path: EXTENSION_PATH } },
79+
response,
80+
context,
81+
);
82+
83+
await listExtensions.handler({ params: {} }, response, context);
84+
85+
const listResponseLine = response.responseLines[1];
86+
assert.ok(listResponseLine, 'Response should not be empty');
87+
const extensions = JSON.parse(listResponseLine);
88+
assert.strictEqual(extensions.length, 1);
89+
assert.strictEqual(extensions[0].Name, 'Test Extension');
90+
assert.strictEqual(extensions[0].Version, '1.0');
91+
assert.strictEqual(extensions[0].Enabled, 'Yes');
92+
93+
const extensionId = extensions[0].ID;
94+
await uninstallExtension.handler(
95+
{ params: { id: extensionId } },
96+
response,
97+
context,
98+
);
99+
100+
response.resetResponseLineForTesting();
101+
await listExtensions.handler({ params: {} }, response, context);
102+
103+
const emptyListResponse = response.responseLines[0];
104+
assert.strictEqual(emptyListResponse, 'No extensions installed.');
105+
});
106+
});
74107
});

0 commit comments

Comments
 (0)