Skip to content

Commit 4ad781f

Browse files
nattalliusNatallia HarshunovaOrKoN
authored
chore: implement list_extensions tool for local unpacked extensions (#819)
Co-authored-by: Natallia Harshunova <nharshunova@chromium.org> Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
1 parent aa0a367 commit 4ad781f

File tree

8 files changed

+198
-3
lines changed

8 files changed

+198
-3
lines changed

src/McpContext.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ 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 {
39+
ExtensionRegistry,
40+
type InstalledExtension,
41+
} from './utils/ExtensionRegistry.js';
3842
import {WaitForHelper} from './WaitForHelper.js';
3943

4044
export interface TextSnapshotNode extends SerializedAXNode {
@@ -112,6 +116,7 @@ export class McpContext implements Context {
112116
#networkCollector: NetworkCollector;
113117
#consoleCollector: ConsoleCollector;
114118
#devtoolsUniverseManager: UniverseManager;
119+
#extensionRegistry = new ExtensionRegistry();
115120

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

756-
async installExtension(path: string): Promise<string> {
757-
return this.browser.installExtension(path);
761+
async installExtension(extensionPath: string): Promise<string> {
762+
const id = await this.browser.installExtension(extensionPath);
763+
await this.#extensionRegistry.registerExtension(id, extensionPath);
764+
return id;
758765
}
759766

760767
async uninstallExtension(id: string): Promise<void> {
761-
return this.browser.uninstallExtension(id);
768+
await this.browser.uninstallExtension(id);
769+
this.#extensionRegistry.remove(id);
770+
}
771+
772+
listExtensions(): InstalledExtension[] {
773+
return this.#extensionRegistry.list();
762774
}
763775
}

src/McpResponse.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
} from './tools/ToolDefinition.js';
2626
import type {InsightName, TraceResult} from './trace-processing/parse.js';
2727
import {getInsightOutput, getTraceSummary} from './trace-processing/parse.js';
28+
import type {InstalledExtension} from './utils/ExtensionRegistry.js';
2829
import {paginate} from './utils/pagination.js';
2930
import type {PaginationOptions} from './utils/types.js';
3031

@@ -60,6 +61,7 @@ export class McpResponse implements Response {
6061
types?: string[];
6162
includePreservedMessages?: boolean;
6263
};
64+
#listExtensions?: boolean;
6365
#devToolsData?: DevToolsData;
6466
#tabId?: string;
6567

@@ -81,6 +83,10 @@ export class McpResponse implements Response {
8183
};
8284
}
8385

86+
setListExtensions(): void {
87+
this.#listExtensions = true;
88+
}
89+
8490
setIncludeNetworkRequests(
8591
value: boolean,
8692
options?: PaginationOptions & {
@@ -297,6 +303,11 @@ export class McpResponse implements Response {
297303
}
298304
}
299305

306+
let extensions: InstalledExtension[] | undefined;
307+
if (this.#listExtensions) {
308+
extensions = context.listExtensions();
309+
}
310+
300311
let consoleMessages: Array<ConsoleFormatter | IssueFormatter> | undefined;
301312
if (this.#consoleDataOptions?.include) {
302313
let messages = context.getConsoleData(
@@ -395,6 +406,7 @@ export class McpResponse implements Response {
395406
networkRequests,
396407
traceInsight: this.#attachedTraceInsight,
397408
traceSummary: this.#attachedTraceSummary,
409+
extensions,
398410
});
399411
}
400412

@@ -409,6 +421,7 @@ export class McpResponse implements Response {
409421
networkRequests?: NetworkFormatter[];
410422
traceSummary?: TraceResult;
411423
traceInsight?: TraceInsightData;
424+
extensions?: InstalledExtension[];
412425
},
413426
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
414427
const response = [`# ${toolName} response`];
@@ -474,6 +487,7 @@ Call ${handleDialog.name} to handle it before continuing.`);
474487
consoleMessages?: object[];
475488
traceSummary?: string;
476489
traceInsights?: Array<{insightName: string; insightKey: string}>;
490+
extensions?: object[];
477491
} = {};
478492

479493
if (this.#tabId) {
@@ -531,6 +545,21 @@ Call ${handleDialog.name} to handle it before continuing.`);
531545
data.detailedConsoleMessage.toJSONDetailed();
532546
}
533547

548+
if (data.extensions) {
549+
structuredContent.extensions = data.extensions;
550+
response.push('## Extensions');
551+
if (data.extensions.length === 0) {
552+
response.push('No extensions installed.');
553+
} else {
554+
const extensionsMessage = data.extensions
555+
.map(extension => {
556+
return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.isEnabled ? 'Enabled' : 'Disabled'}`;
557+
})
558+
.join('\n');
559+
response.push(extensionsMessage);
560+
}
561+
}
562+
534563
if (this.#networkRequestsOptions?.include) {
535564
let requests = context.getNetworkRequests(
536565
this.#networkRequestsOptions?.includePreservedRequests,

src/tools/ToolDefinition.ts

Lines changed: 3 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';
@@ -92,6 +93,7 @@ export interface Response {
9293
insightSetId: string,
9394
insightName: InsightName,
9495
): void;
96+
setListExtensions(): void;
9597
}
9698

9799
/**
@@ -141,6 +143,7 @@ export type Context = Readonly<{
141143
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
142144
installExtension(path: string): Promise<string>;
143145
uninstallExtension(id: string): Promise<void>;
146+
listExtensions(): InstalledExtension[];
144147
}>;
145148

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

src/tools/extensions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,18 @@ export const uninstallExtension = defineTool({
4848
response.appendResponseLine(`Extension uninstalled. Id: ${id}`);
4949
},
5050
});
51+
52+
export const listExtensions = defineTool({
53+
name: 'list_extensions',
54+
description:
55+
'Lists all extensions via this server, including their name, ID, version, and enabled status.',
56+
annotations: {
57+
category: ToolCategory.EXTENSIONS,
58+
readOnlyHint: true,
59+
conditions: [EXTENSIONS_CONDITION],
60+
},
61+
schema: {},
62+
handler: async (_request, response, _context) => {
63+
response.setListExtensions();
64+
},
65+
});

src/utils/ExtensionRegistry.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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(
22+
id: string,
23+
extensionPath: string,
24+
): Promise<InstalledExtension> {
25+
const manifestPath = path.join(extensionPath, 'manifest.json');
26+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
27+
const manifest = JSON.parse(manifestContent);
28+
const name = manifest.name ?? 'Unknown';
29+
const version = manifest.version ?? 'Unknown';
30+
31+
const extension = {
32+
id,
33+
name,
34+
version,
35+
isEnabled: true,
36+
path: extensionPath,
37+
};
38+
this.#extensions.set(extension.id, extension);
39+
return extension;
40+
}
41+
42+
remove(id: string): void {
43+
this.#extensions.delete(id);
44+
}
45+
46+
list(): InstalledExtension[] {
47+
return Array.from(this.#extensions.values());
48+
}
49+
50+
getById(id: string): InstalledExtension | undefined {
51+
return this.#extensions.get(id);
52+
}
53+
}

tests/McpResponse.test.js.snapshot

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,31 @@ exports[`McpResponse network request filtering > shows no requests when filter m
480480
## Network requests
481481
No requests found.
482482
`;
483+
484+
exports[`extensions > lists extensions 1`] = `
485+
# test response
486+
## Extensions
487+
id=id1 "Extension 1" v1.0 Enabled
488+
id=id2 "Extension 2" v2.0 Disabled
489+
`;
490+
491+
exports[`extensions > lists extensions 2`] = `
492+
{
493+
"extensions": [
494+
{
495+
"id": "id1",
496+
"name": "Extension 1",
497+
"version": "1.0",
498+
"isEnabled": true,
499+
"path": "/path/to/ext1"
500+
},
501+
{
502+
"id": "id2",
503+
"name": "Extension 2",
504+
"version": "2.0",
505+
"isEnabled": false,
506+
"path": "/path/to/ext2"
507+
}
508+
]
509+
}
510+
`;

tests/McpResponse.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,45 @@ describe('McpResponse network pagination', () => {
713713
});
714714
});
715715
});
716+
717+
describe('extensions', () => {
718+
it('lists extensions', async t => {
719+
await withMcpContext(async (response, context) => {
720+
response.setListExtensions();
721+
// Empty state testing
722+
const emptyResult = await response.handle('test', context);
723+
const emptyText = getTextContent(emptyResult.content[0]);
724+
assert.ok(
725+
emptyText.includes('No extensions installed.'),
726+
'Should show message for ampty extensions',
727+
);
728+
729+
response.resetResponseLineForTesting();
730+
// Testing with extensions
731+
context.listExtensions = () => [
732+
{
733+
id: 'id1',
734+
name: 'Extension 1',
735+
version: '1.0',
736+
isEnabled: true,
737+
path: '/path/to/ext1',
738+
},
739+
{
740+
id: 'id2',
741+
name: 'Extension 2',
742+
version: '2.0',
743+
isEnabled: false,
744+
path: '/path/to/ext2',
745+
},
746+
];
747+
response.setListExtensions();
748+
const {content, structuredContent} = await response.handle(
749+
'test',
750+
context,
751+
);
752+
753+
t.assert.snapshot?.(getTextContent(content[0]));
754+
t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2));
755+
});
756+
});
757+
});

tests/tools/extensions.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import assert from 'node:assert';
88
import path from 'node:path';
99
import {describe, it} from 'node:test';
1010

11+
import sinon from 'sinon';
12+
1113
import {
1214
installExtension,
1315
uninstallExtension,
16+
listExtensions,
1417
} from '../../src/tools/extensions.js';
1518
import {withMcpContext} from '../utils.js';
1619

@@ -71,4 +74,14 @@ describe('extension', () => {
7174
);
7275
});
7376
});
77+
it('lists installed extensions', async () => {
78+
await withMcpContext(async (response, context) => {
79+
const setListExtensionsSpy = sinon.spy(response, 'setListExtensions');
80+
await listExtensions.handler({params: {}}, response, context);
81+
assert.ok(
82+
setListExtensionsSpy.calledOnce,
83+
'setListExtensions should be called',
84+
);
85+
});
86+
});
7487
});

0 commit comments

Comments
 (0)