豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content
Merged
6 changes: 6 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ jobs:
shell: bash
run: npm ci

- name: Install Chrome Canary
shell: bash
run: |
CANARY_PATH=$(npx @puppeteer/browsers install chrome@canary --format "{{path}}")
echo "CANARY_EXECUTABLE_PATH=$CANARY_PATH" >> $GITHUB_ENV

- name: Build
run: npm run bundle
env:
Expand Down
18 changes: 18 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,24 @@ export class McpContext implements Context {
this.#extensionRegistry.remove(id);
}

async triggerExtensionAction(id: string): Promise<void> {
const page = this.getSelectedPptrPage();
// @ts-expect-error internal puppeteer api is needed since we don't have a way to get
// a tab id at the moment
const theTarget = page._tabId;
const session = await this.browser.target().createCDPSession();

try {
// @ts-expect-error triggerAction is not yet available
await session.send('Extensions.triggerAction', {
id,
targetId: theTarget,
});
} finally {
await session.detach();
}
}

listExtensions(): InstalledExtension[] {
return this.#extensionRegistry.list();
}
Expand Down
2 changes: 1 addition & 1 deletion src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ Call ${handleDialog.name} to handle it before continuing.`);
}

if (this.#includeExtensionServiceWorkers) {
if (!context.getExtensionServiceWorkers().length) {
if (context.getExtensionServiceWorkers().length) {
response.push(`## Extension Service Workers`);
}

Expand Down
1 change: 1 addition & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export type Context = Readonly<{
): void;
installExtension(path: string): Promise<string>;
uninstallExtension(id: string): Promise<void>;
triggerExtensionAction(id: string): Promise<void>;
listExtensions(): InstalledExtension[];
getExtension(id: string): InstalledExtension | undefined;
getSelectedMcpPage(): McpPage;
Expand Down
18 changes: 18 additions & 0 deletions src/tools/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,21 @@ export const reloadExtension = defineTool({
response.appendResponseLine('Extension reloaded.');
},
});

export const triggerExtensionAction = defineTool({
name: 'trigger_extension_action',
description: 'Triggers an action in a Chrome extension.',
annotations: {
category: ToolCategory.EXTENSIONS,
readOnlyHint: false,
conditions: [EXTENSIONS_CONDITION],
},
schema: {
id: zod.string().describe('ID of the extension.'),
},
handler: async (request, response, context) => {
const {id} = request.params;
await context.triggerExtensionAction(id);
response.appendResponseLine(`Extension action triggered. Id: ${id}`);
},
});
54 changes: 41 additions & 13 deletions tests/tools/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,25 @@ import {afterEach, describe, it} from 'node:test';

import sinon from 'sinon';

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

const EXTENSION_WITH_SW_PATH = path.join(
import.meta.dirname,
'../../../tests/tools/fixtures/extension-sw',
);
const EXTENSION_PATH = path.join(
import.meta.dirname,
'../../../tests/tools/fixtures/extension',
);

export 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', () => {
afterEach(() => {
sinon.restore();
Expand All @@ -47,7 +43,7 @@ describe('extension', () => {
context,
);

const extensionId = extractId(response);
const extensionId = extractExtensionId(response);
const page = context.getSelectedPptrPage();
await page.goto('chrome://extensions');

Expand Down Expand Up @@ -102,7 +98,7 @@ describe('extension', () => {
context,
);

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

Expand All @@ -128,4 +124,36 @@ describe('extension', () => {
assert.ok(reinstalled, 'Extension should be present after reload');
});
});
it('triggers an extension action', async () => {
await withMcpContext(
async (response, context) => {
const extensionId = await context.installExtension(
EXTENSION_WITH_SW_PATH,
);

const targetsBefore = context.browser.targets();
const pageTargetBefore = targetsBefore.find(
t => t.type() === 'page' && t.url().includes(extensionId),
);
assert.ok(!pageTargetBefore, 'Page should not exist before action');

await triggerExtensionAction.handler(
{params: {id: extensionId}},
response,
context,
);

const pageTargetAfter = await context.browser.waitForTarget(
t => t.type() === 'page' && t.url().includes(extensionId),
);
assert.ok(pageTargetAfter, 'Page should exist after action');
},
{
executablePath: process.env.CANARY_EXECUTABLE_PATH,
},
{
categoryExtensions: true,
} as ParsedArguments,
);
});
});
4 changes: 3 additions & 1 deletion tests/tools/fixtures/extension-sw/sw.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
console.log('Service worker loaded');
chrome.action.onClicked.addListener(tab => {
console.log('Action clicked');
});
6 changes: 2 additions & 4 deletions tests/tools/script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import type {ParsedArguments} from '../../src/cli.js';
import {installExtension} from '../../src/tools/extensions.js';
import {evaluateScript} from '../../src/tools/script.js';
import {serverHooks} from '../server.js';
import {html, withMcpContext} from '../utils.js';

import {extractId} from './extensions.test.js';
import {extractExtensionId, html, withMcpContext} from '../utils.js';

const EXTENSION_PATH = path.join(
import.meta.dirname,
Expand Down Expand Up @@ -209,7 +207,7 @@ describe('script', () => {
context,
);

const extensionId = extractId(response);
const extensionId = extractExtensionId(response);
const swTarget = await context.browser.waitForTarget(
t => t.type() === 'service_worker' && t.url().includes(extensionId),
);
Expand Down
21 changes: 19 additions & 2 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';

import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
import logger from 'debug';
import type {Browser} from 'puppeteer';
Expand Down Expand Up @@ -42,15 +44,29 @@ export function getImageContent(content: CallToolResult['content'][number]): {
throw new Error(`Expected image content but got ${content.type}`);
}

export function extractExtensionId(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;
}

const browsers = new Map<string, Browser>();
let context: McpContext | undefined;

export async function withBrowser(
cb: (browser: Browser, page: Page) => Promise<void>,
options: {debug?: boolean; autoOpenDevTools?: boolean} = {},
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
executablePath?: string;
} = {},
) {
const launchOptions: LaunchOptions = {
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
executablePath:
options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
headless: !options.debug,
defaultViewport: null,
devtools: options.autoOpenDevTools ?? false,
Expand Down Expand Up @@ -85,6 +101,7 @@ export async function withMcpContext(
debug?: boolean;
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
executablePath?: string;
} = {},
args: ParsedArguments = {} as ParsedArguments,
) {
Expand Down
Loading