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
4 changes: 4 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,4 +772,8 @@ export class McpContext implements Context {
listExtensions(): InstalledExtension[] {
return this.#extensionRegistry.list();
}

getExtension(id: string): InstalledExtension | undefined {
return this.#extensionRegistry.getById(id);
}
}
1 change: 1 addition & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export type Context = Readonly<{
installExtension(path: string): Promise<string>;
uninstallExtension(id: string): Promise<void>;
listExtensions(): InstalledExtension[];
getExtension(id: string): InstalledExtension | undefined;
}>;

export function defineTool<Schema extends zod.ZodRawShape>(
Expand Down
22 changes: 22 additions & 0 deletions src/tools/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,25 @@ export const listExtensions = defineTool({
response.setListExtensions();
},
});

export const reloadExtension = defineTool({
name: 'reload_extension',
description: 'Reloads an unpacked Chrome extension by its ID.',
annotations: {
category: ToolCategory.EXTENSIONS,
readOnlyHint: false,
conditions: [EXTENSIONS_CONDITION],
},
schema: {
id: zod.string().describe('ID of the extension to reload.'),
},
handler: async (request, response, context) => {
const {id} = request.params;
const extension = context.getExtension(id);
if (!extension) {
throw new Error(`Extension with ID ${id} not found.`);
}
await context.installExtension(extension.path);
response.appendResponseLine('Extension reloaded.');
},
});
52 changes: 46 additions & 6 deletions tests/tools/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {describe, it} from 'node:test';

import sinon from 'sinon';

import type {McpResponse} from '../../src/McpResponse.js';
import {
installExtension,
uninstallExtension,
listExtensions,
reloadExtension,
} from '../../src/tools/extensions.js';
import {withMcpContext} from '../utils.js';

Expand All @@ -22,6 +24,15 @@ const EXTENSION_PATH = path.join(
'../../../tests/tools/fixtures/extension',
);

function extractId(response: McpResponse) {
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');
return extensionId;
}

describe('extension', () => {
it('installs and uninstalls an extension and verifies it in chrome://extensions', async () => {
await withMcpContext(async (response, context) => {
Expand All @@ -32,12 +43,7 @@ describe('extension', () => {
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 extensionId = extractId(response);
const page = context.getSelectedPage();
await page.goto('chrome://extensions');

Expand Down Expand Up @@ -84,4 +90,38 @@ describe('extension', () => {
);
});
});
it('reloads an extension', async () => {
await withMcpContext(async (response, context) => {
await installExtension.handler(
{params: {path: EXTENSION_PATH}},
response,
context,
);

const extensionId = extractId(response);
const installSpy = sinon.spy(context, 'installExtension');
response.resetResponseLineForTesting();

await reloadExtension.handler(
{params: {id: extensionId!}},
response,
context,
);
assert.ok(
installSpy.calledOnceWithExactly(EXTENSION_PATH),
'installExtension should be called with the extension path',
);

const reloadResponseLine = response.responseLines[0];
assert.ok(
reloadResponseLine.includes('Extension reloaded'),
'Response should indicate reload',
);

const list = context.listExtensions();
assert.ok(list.length === 1, 'List should have only one extension');
const reinstalled = list.find(e => e.id === extensionId);
assert.ok(reinstalled, 'Extension should be present after reload');
});
});
});