feat: add MCP Chrome extension (#325)
Instructions: 1. `git clone https://github.com/mxschmitt/playwright-mcp && git checkout extension-drafft` 2. `npm ci && npm run build` 3. `chrome://extensions` in your normal Chrome, "load unpacked" and select the extension folder. 4. `node cli.js --port=4242 --extension` - The URL it prints at the end you can put into the extension popup. 5. Put either this into Claude Desktop (it does not support SSE yet hence wrapping it or just put the URL into Cursor/VSCode) ```json { "mcpServers": { "playwright": { "command": "bash", "args": [ "-c", "source $HOME/.nvm/nvm.sh && nvm use --silent 22 && npx supergateway --streamableHttp http://127.0.0.1:4242/mcp" ] } } } ``` Things like `Take a snapshot of my browser.` should now work in your Prompt Chat. ---- - SSE only for now, since we already have a http server with a port there - Upstream "page tests" can be executed over this CDP relay via https://github.com/microsoft/playwright/pull/36286 - Limitations for now are everything what happens outside of the tab its session is shared with -> `window.open` / `target=_blank`. --------- Co-authored-by: Yury Semikhatsky <yurys@chromium.org>
This commit is contained in:
@@ -17,19 +17,25 @@
|
||||
import fs from 'fs';
|
||||
import url from 'url';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { chromium } from 'playwright';
|
||||
import { fork } from 'child_process';
|
||||
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
import { ManualPromise } from '../src/manualPromise.js';
|
||||
|
||||
import type { Config } from '../config';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Stream } from 'stream';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpBrowser: string | undefined;
|
||||
mcpMode: 'docker' | undefined;
|
||||
mcpMode: 'docker' | 'extension' | undefined;
|
||||
};
|
||||
|
||||
type CDPServer = {
|
||||
@@ -46,6 +52,7 @@ type TestFixtures = {
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
startMcpExtension: (relayServerURL: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -64,7 +71,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
await use(client);
|
||||
},
|
||||
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
|
||||
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||
const configDir = path.dirname(test.info().config.configFile!);
|
||||
let client: Client | undefined;
|
||||
@@ -88,16 +95,18 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
}
|
||||
|
||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||
const transport = createTransport(args, mcpMode);
|
||||
let stderr = '';
|
||||
transport.stderr?.on('data', data => {
|
||||
const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
|
||||
let stderrBuffer = '';
|
||||
stderr?.on('data', data => {
|
||||
if (process.env.PWMCP_DEBUG)
|
||||
process.stderr.write(data);
|
||||
stderr += data.toString();
|
||||
stderrBuffer += data.toString();
|
||||
});
|
||||
await client.connect(transport);
|
||||
if (mcpMode === 'extension')
|
||||
await startMcpExtension(relayServerURL!);
|
||||
await client.ping();
|
||||
return { client, stderr: () => stderr };
|
||||
return { client, stderr: () => stderrBuffer };
|
||||
});
|
||||
|
||||
await client?.close();
|
||||
@@ -138,7 +147,39 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
|
||||
mcpMode: [undefined, { option: true }],
|
||||
|
||||
_workerServers: [async ({}, use, workerInfo) => {
|
||||
startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => {
|
||||
let context: BrowserContext | undefined;
|
||||
await use(async (relayServerURL: string) => {
|
||||
if (mcpMode !== 'extension')
|
||||
throw new Error('Must be running in MCP extension mode to use this fixture.');
|
||||
const cdpPort = await findFreePort();
|
||||
const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension');
|
||||
context = await chromium.launchPersistentContext('', {
|
||||
headless: mcpHeadless,
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`,
|
||||
'--enable-features=AllowContentInitiatedDataUrlNavigations',
|
||||
],
|
||||
channel: 'chromium',
|
||||
...{ assistantMode: true, cdpPort },
|
||||
});
|
||||
const popupPage = await context.newPage();
|
||||
const page = context.pages()[0];
|
||||
await page.bringToFront();
|
||||
// Do not auto dismiss dialogs.
|
||||
page.on('dialog', () => { });
|
||||
await expect.poll(() => context?.serviceWorkers()).toHaveLength(1);
|
||||
// Connect to the relay server.
|
||||
await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString());
|
||||
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear();
|
||||
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL);
|
||||
await popupPage.getByRole('button', { name: 'Share This Tab' }).click();
|
||||
});
|
||||
await context?.close();
|
||||
},
|
||||
|
||||
_workerServers: [async ({ }, use, workerInfo) => {
|
||||
const port = 8907 + workerInfo.workerIndex * 4;
|
||||
const server = await TestServer.create(port);
|
||||
|
||||
@@ -164,17 +205,62 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
||||
},
|
||||
});
|
||||
|
||||
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
||||
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
|
||||
transport: Transport,
|
||||
stderr: Stream | null,
|
||||
relayServerURL?: string,
|
||||
}> {
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
if (mcpMode === 'docker') {
|
||||
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
|
||||
return new StdioClientTransport({
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'docker',
|
||||
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr,
|
||||
};
|
||||
}
|
||||
return new StdioClientTransport({
|
||||
if (mcpMode === 'extension') {
|
||||
const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
const cdpRelayServerReady = new ManualPromise<string>();
|
||||
const sseEndpointPromise = new ManualPromise<string>();
|
||||
let stderrBuffer = '';
|
||||
relay.stderr!.on('data', data => {
|
||||
stderrBuffer += data.toString();
|
||||
const match = stderrBuffer.match(/Listening on (http:\/\/.*)/);
|
||||
if (match)
|
||||
sseEndpointPromise.resolve(match[1].toString());
|
||||
const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/);
|
||||
if (extensionMatch)
|
||||
cdpRelayServerReady.resolve(extensionMatch[1].toString());
|
||||
});
|
||||
relay.on('exit', () => {
|
||||
sseEndpointPromise.reject(new Error(`Process exited`));
|
||||
cdpRelayServerReady.reject(new Error(`Process exited`));
|
||||
});
|
||||
const relayServerURL = await cdpRelayServerReady;
|
||||
const sseEndpoint = await sseEndpointPromise;
|
||||
|
||||
const transport = new SSEClientTransport(new URL(sseEndpoint));
|
||||
// We cannot just add transport.onclose here as Client.connect() overrides it.
|
||||
const origClose = transport.close;
|
||||
transport.close = async () => {
|
||||
await origClose.call(transport);
|
||||
relay.kill();
|
||||
};
|
||||
return {
|
||||
transport,
|
||||
stderr: relay.stderr!,
|
||||
relayServerURL,
|
||||
};
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||
cwd: path.join(path.dirname(__filename), '..'),
|
||||
@@ -186,6 +272,10 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
||||
DEBUG_HIDE_DATE: '1',
|
||||
},
|
||||
});
|
||||
return {
|
||||
transport,
|
||||
stderr: transport.stderr!,
|
||||
};
|
||||
}
|
||||
|
||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||
@@ -242,6 +332,17 @@ export const expect = baseExpect.extend({
|
||||
},
|
||||
});
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatOutput(output: string): string[] {
|
||||
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user