diff --git a/src/McpContext.ts b/src/McpContext.ts index a3bcddbbc..a1aba4282 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -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); + } } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index ae68cfba0..1e568c6cc 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -144,6 +144,7 @@ export type Context = Readonly<{ installExtension(path: string): Promise; uninstallExtension(id: string): Promise; listExtensions(): InstalledExtension[]; + getExtension(id: string): InstalledExtension | undefined; }>; export function defineTool( diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 431fb9f1b..0ab2d43ff 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -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.'); + }, +}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 0405c5f8b..65f645ab4 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -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'; @@ -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) => { @@ -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'); @@ -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'); + }); + }); });