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:
Max Schmitt
2025-06-13 22:15:17 +02:00
committed by GitHub
parent 0df6d7a441
commit 6c3f3b6576
28 changed files with 1430 additions and 56 deletions

View File

@@ -14,8 +14,13 @@
* limitations under the License.
*/
import url from 'node:url';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js';
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension');
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
@@ -75,3 +80,15 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
test('does not support --device', async () => {
const result = spawnSync('node', [
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
});

View File

@@ -19,7 +19,8 @@ import fs from 'node:fs';
import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server }, testInfo) => {
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir');
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
@@ -45,7 +46,8 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
test.describe(() => {
test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium');
const config: Config = {
browser: {
browserName: 'firefox',

View File

@@ -16,7 +16,8 @@
import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => {
test('--device should work', async ({ startClient, server, mcpMode }) => {
test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.');
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
});

43
tests/extension.spec.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import url from 'url';
import path from 'path';
import { spawnSync } from 'child_process';
import { test, expect } from './fixtures.js';
import { createConnection } from '@playwright/mcp';
test.skip(({ mcpMode }) => mcpMode !== 'extension');
test('does not allow --cdp-endpoint', async ({ startClient }) => {
await expect(createConnection({
browser: { browserName: 'firefox' },
...({ extension: true })
})).rejects.toThrow(/Extension mode is only supported for Chromium browsers/);
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
test('does not support --device', async () => {
const result = spawnSync('node', [
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--extension',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with extension mode.');
});

View File

@@ -100,7 +100,8 @@ The tool "browser_file_upload" can only be used when there is related modal stat
}
});
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
@@ -124,7 +125,8 @@ test('clicking on download link emits download', async ({ startClient, server },
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});

View File

@@ -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);
}

View File

@@ -18,7 +18,9 @@ import fs from 'fs';
import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ startClient, server }) => {
test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched');
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient();
await client.callTool({
name: 'browser_navigate',

View File

@@ -29,6 +29,8 @@ import type { Config } from '../config.d.ts';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways');
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;

View File

@@ -27,6 +27,8 @@ async function createTab(client: Client, title: string, body: string) {
});
}
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension');
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',

View File

@@ -19,7 +19,9 @@ import path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP');
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({