2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f83a808d39 Add response.addCode() calls to browser_wait_for tool and update tests
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-08-10 22:03:03 +00:00
copilot-swe-agent[bot]
54754d8be1 Initial plan 2025-08-10 21:52:45 +00:00
55 changed files with 338 additions and 889 deletions

View File

@@ -28,7 +28,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15, windows-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

View File

@@ -68,31 +68,3 @@ jobs:
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
attach_eol_manifest $tag
done
package-extension:
runs-on: ubuntu-latest
permissions:
contents: write # Needed to upload release assets
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install extension dependencies
working-directory: ./extension
run: npm ci
- name: Build extension
working-directory: ./extension
run: npm run build
- name: Package extension
working-directory: ./extension
run: |
cd dist
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip .
cd ..
- name: Upload extension to release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip

View File

@@ -100,29 +100,6 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
</details>
<details>
<summary>opencode</summary>
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "local",
"command": [
"npx",
"@playwright/mcp@latest"
],
"enabled": true
}
}
}
```
</details>
<details>
<summary>Qodo Gen</summary>
@@ -181,9 +158,6 @@ Playwright MCP server supports following arguments. They can be provided in the
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--executable-path <path> path to the browser executable.
--extension Connect to a running browser instance
(Edge/Chrome only). Requires the "Playwright MCP
Bridge" browser extension to be installed.
--headless run browser in headless mode, headed by default
--host <host> host to bind server to. Default is localhost. Use
0.0.0.0 to bind to all interfaces.
@@ -217,7 +191,7 @@ Playwright MCP server supports following arguments. They can be provided in the
### User profile
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
**Persistent profile**
@@ -257,10 +231,6 @@ state [here](https://playwright.dev/docs/auth).
}
```
**Browser Extension**
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
### Configuration file
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file

View File

@@ -1,48 +0,0 @@
# Playwright MCP Chrome Extension
## Introduction
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
## Prerequisites
- Chrome/Edge/Chromium browser
## Installation Steps
### Download the Extension
Download the latest Chrome extension from GitHub:
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
### Load Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in the top right corner)
3. Click "Load unpacked" and select the extension directory
### Configure Playwright MCP server
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
```json
{
"mcpServers": {
"playwright-extension": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--extension"
]
}
}
}
```
## Usage
### Browser Tab Selection
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "0.0.34",
"version": "1.0.0",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",

View File

@@ -1,12 +1,12 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.34",
"version": "0.0.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp-extension",
"version": "0.0.34",
"version": "0.0.32",
"license": "Apache-2.0",
"devDependencies": {
"@types/chrome": "^0.0.315",

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.34",
"version": "0.0.32",
"description": "Playwright MCP Browser Extension",
"type": "module",
"private": true,

View File

@@ -193,7 +193,7 @@ class TabShareExtension {
}
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
if (this._connectedTabId === tabId)
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
void this._setConnectedTabId(tabId);
}

View File

@@ -19,12 +19,10 @@ import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { StartClient } from '../../tests/fixtures.js';
type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
launch: () => Promise<BrowserContext>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
@@ -39,14 +37,14 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async (mode?: 'disable-extension') => {
launch: async () => {
browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: mcpBrowser,
// Opening the browser singleton only works in headed.
headless: false,
// Automation disables singleton browser process behavior, which is necessary for the extension.
ignoreDefaultArgs: ['--enable-automation'],
args: mode === 'disable-extension' ? [] : [
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
@@ -65,7 +63,9 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
},
});
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const { client } = await startClient({
args: [`--connect-tool`],
config: {
@@ -78,110 +78,75 @@ async function startAndCallConnectTool(browserWithExtension: BrowserWithExtensio
expect(await client.callTool({
name: 'browser_connect',
arguments: {
name: 'extension'
method: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
return client;
}
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test('snapshot of an existing page', async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto(server.HELLO_WORLD);
// Another empty page.
await browserContext.newPage();
expect(browserContext.pages()).toHaveLength(3);
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--extension`],
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
return client;
}
for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag],
] as const) {
expect(await client.callTool({
name: 'browser_connect',
arguments: {
method: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
expect(browserContext.pages()).toHaveLength(3);
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto(server.HELLO_WORLD);
// Another empty page.
await browserContext.newPage();
expect(browserContext.pages()).toHaveLength(3);
const client = await startClientMethod(browserWithExtension, startClient);
expect(browserContext.pages()).toHaveLength(3);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_snapshot',
arguments: { },
});
const selectorPage = await confirmationPagePromise;
expect(browserContext.pages()).toHaveLength(4);
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(browserContext.pages()).toHaveLength(4);
const navigateResponse = client.callTool({
name: 'browser_snapshot',
arguments: { },
});
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
const selectorPage = await confirmationPagePromise;
expect(browserContext.pages()).toHaveLength(4);
const browserContext = await browserWithExtension.launch();
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
isError: true,
});
await confirmationPagePromise;
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
}
expect(browserContext.pages()).toHaveLength(4);
});

32
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@playwright/mcp",
"version": "0.0.34",
"version": "0.0.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.34",
"version": "0.0.33",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
@@ -14,8 +14,8 @@
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-2025-08-12",
"playwright-core": "1.55.0-alpha-2025-08-12",
"playwright": "1.55.0-alpha-2025-08-07",
"playwright-core": "1.55.0-alpha-2025-08-07",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
@@ -27,7 +27,7 @@
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-2025-08-12",
"@playwright/test": "1.55.0-alpha-2025-08-07",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
@@ -703,13 +703,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.55.0-alpha-2025-08-12",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-12.tgz",
"integrity": "sha512-lyq9MDSd4UcOWx5292AYLBfbYYCstg8iLb+lk6LdM69ps6bwmPloZO3Ol3JO3FQQ63qAuW9VD0w+ZYKL0lRmQA==",
"version": "1.55.0-alpha-2025-08-07",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-07.tgz",
"integrity": "sha512-N83L8JSSJ+E690HCbgzmXIcbRfM/rlh0uWZhbHbMp9q4qDPABSgvhm0HGiG345PV1ozoqcCI/mXLZPircsmPIA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0-alpha-2025-08-12"
"playwright": "1.55.0-alpha-2025-08-07"
},
"bin": {
"playwright": "cli.js"
@@ -3745,12 +3745,12 @@
}
},
"node_modules/playwright": {
"version": "1.55.0-alpha-2025-08-12",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-12.tgz",
"integrity": "sha512-daZPM5gX0VTG6ae3/qOpEKc9NxoavkM2lfL0UIzTG0k+yK8ZeSPYo63iewZhVANsWRm0BT+XQ1NniAUOwWQ+xA==",
"version": "1.55.0-alpha-2025-08-07",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-07.tgz",
"integrity": "sha512-rH8kdQOZzhjxC6FOL9zSEDwPl88ZqQq9QEvRDONWhzKwRQ/jOXlEZRxm8QRCBdrLqBMTGHx/YOaP7MIV//rtIA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0-alpha-2025-08-12"
"playwright-core": "1.55.0-alpha-2025-08-07"
},
"bin": {
"playwright": "cli.js"
@@ -3763,9 +3763,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0-alpha-2025-08-12",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-12.tgz",
"integrity": "sha512-4uxOd9xmeF6gqdsORzzlXd7p795vcACOiAGVHHEiTuFXsD83LYH+0C/SYLWB0Z+fAq4LdKGsy0qEfTm0JkY8Ig==",
"version": "1.55.0-alpha-2025-08-07",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-07.tgz",
"integrity": "sha512-NUuC6R0/dLk1QKiYoJL8NUsQAC6Je0C2BpuIg5h4wcvBwJ5TFldslmik17Txg3TXBSqwgG76DAl4Q6UdHGn54Q==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.34",
"version": "0.0.33",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -17,9 +17,8 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"check-deps": "node utils/check-deps.js",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
@@ -43,8 +42,8 @@
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-2025-08-12",
"playwright-core": "1.55.0-alpha-2025-08-12",
"playwright": "1.55.0-alpha-2025-08-07",
"playwright-core": "1.55.0-alpha-2025-08-07",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
@@ -53,7 +52,7 @@
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-2025-08-12",
"@playwright/test": "1.55.0-alpha-2025-08-07",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",

View File

@@ -22,10 +22,12 @@ export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
workers: process.env.CI ? 2 : undefined,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
projects: [
{ name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{
name: 'chromium-docker',
@@ -37,6 +39,5 @@ export default defineConfig<TestOptions>({
}] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
],
});

View File

@@ -1,7 +0,0 @@
[*]
./tools/
./mcp/
./utils/
[program.ts]
***

View File

@@ -21,10 +21,8 @@ import path from 'path';
import * as playwright from 'playwright';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './utils/log.js';
import { createHash } from './utils/guid.js';
import { logUnhandledError, testDebug } from './log.js';
import { createHash } from './utils.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
@@ -52,6 +50,7 @@ class BaseContextFactory implements BrowserContextFactory {
readonly description: string;
readonly config: FullConfig;
protected _browserPromise: Promise<playwright.Browser> | undefined;
protected _tracesDir: string | undefined;
constructor(name: string, description: string, config: FullConfig) {
this.name = name;
@@ -59,11 +58,11 @@ class BaseContextFactory implements BrowserContextFactory {
this.config = config;
}
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
protected async _obtainBrowser(): Promise<playwright.Browser> {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this.name})`);
this._browserPromise = this._doObtainBrowser(clientInfo);
this._browserPromise = this._doObtainBrowser();
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
@@ -74,13 +73,16 @@ class BaseContextFactory implements BrowserContextFactory {
return this._browserPromise;
}
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
protected async _doObtainBrowser(): Promise<playwright.Browser> {
throw new Error('Not implemented');
}
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this.config.saveTrace)
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser(clientInfo);
const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
}
@@ -106,11 +108,11 @@ class IsolatedContextFactory extends BaseContextFactory {
super('isolated', 'Create a new isolated browser context', config);
}
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
tracesDir: this._tracesDir,
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
@@ -173,7 +175,9 @@ class PersistentContextFactory implements BrowserContextFactory {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
let tracesDir: string | undefined;
if (this.config.saveTrace)
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
@@ -238,16 +242,3 @@ async function findFreePort(): Promise<number> {
server.on('error', reject);
});
}
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
if (!config.saveTrace)
return undefined;
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
return tracesDir;
}

View File

@@ -15,20 +15,25 @@
*/
import { fileURLToPath } from 'url';
import { z } from 'zod';
import { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './utils/log.js';
import { logUnhandledError } from './log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { packageJSON } from './utils/package.js';
import { toMcpTool } from './mcp/tool.js';
import { packageJSON } from './package.js';
import { defineTool } from './tools/tool.js';
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type * as mcpServer from './mcp/server.js';
import type { ServerBackend } from './mcp/server.js';
type NonEmptyArray<T> = [T, ...T[]];
export type FactoryList = NonEmptyArray<BrowserContextFactory>;
export class BrowserServerBackend implements ServerBackend {
name = 'Playwright';
version = packageJSON.version;
@@ -39,15 +44,21 @@ export class BrowserServerBackend implements ServerBackend {
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
constructor(config: FullConfig, factory: BrowserContextFactory) {
constructor(config: FullConfig, factories: FactoryList) {
this._config = config;
this._browserContextFactory = factory;
this._browserContextFactory = factories[0];
this._tools = filteredTools(config);
if (factories.length > 1)
this._tools.push(this._defineContextSwitchTool(factories));
}
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
async initialize(server: mcpServer.Server): Promise<void> {
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
let rootPath: string | undefined;
if (roots.length > 0) {
if (capabilities.roots && (
server.getClientVersion()?.name === 'Visual Studio Code' ||
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
const { roots } = await server.listRoots();
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
@@ -58,21 +69,18 @@ export class BrowserServerBackend implements ServerBackend {
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...clientVersion, rootPath },
clientInfo: { ...server.getClientVersion(), rootPath },
});
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
}
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
const tool = this._tools.find(tool => tool.schema.name === name)!;
if (!tool)
throw new Error(`Tool "${name}" not found`);
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
const context = this._context!;
const response = new Response(context, name, parsedArguments);
const response = new Response(context, schema.name, parsedArguments);
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
context.setRunningTool(true);
try {
await tool.handle(context, parsedArguments, response);
@@ -87,6 +95,48 @@ export class BrowserServerBackend implements ServerBackend {
}
serverClosed() {
void this._context?.dispose().catch(logUnhandledError);
void this._context!.dispose().catch(logUnhandledError);
}
private _defineContextSwitchTool(factories: FactoryList): Tool<any> {
const self = this;
return defineTool({
capability: 'core',
schema: {
name: 'browser_connect',
title: 'Connect to a browser context',
description: [
'Connect to a browser using one of the available methods:',
...factories.map(factory => `- "${factory.name}": ${factory.description}`),
].join('\n'),
inputSchema: z.object({
method: z.enum(factories.map(factory => factory.name) as [string, ...string[]]).default(factories[0].name).describe('The method to use to connect to the browser'),
}),
type: 'readOnly',
},
async handle(context, params, response) {
const factory = factories.find(factory => factory.name === params.method);
if (!factory) {
response.addError('Unknown connection method: ' + params.method);
return;
}
await self._setContextFactory(factory);
response.addResult('Successfully changed connection method.');
}
});
}
private async _setContextFactory(newFactory: BrowserContextFactory) {
if (this._context) {
const options = {
...this._context.options,
browserContextFactory: newFactory,
};
await this._context.dispose();
this._context = new Context(options);
}
this._browserContextFactory = newFactory;
}
}

View File

@@ -18,7 +18,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './utils/fileUtils.js';
import { sanitizeForFilePath } from './utils.js';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';

View File

@@ -17,7 +17,7 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { logUnhandledError } from './utils/log.js';
import { logUnhandledError } from './log.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';

View File

@@ -1,3 +0,0 @@
[*]
../mcp/
../utils/

View File

@@ -26,9 +26,9 @@ import { spawn } from 'child_process';
import http from 'http';
import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../utils/httpServer.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../utils/manualPromise.js';
import { httpAddressToString } from '../httpServer.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../manualPromise.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
@@ -100,9 +100,6 @@ export class CDPRelayServer {
debugLogger('Waiting for incoming extension connection');
await Promise.race([
this._extensionConnectionPromise,
new Promise((_, reject) => setTimeout(() => {
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]);
debugLogger('Extension connection established');

View File

@@ -16,7 +16,7 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../utils/httpServer.js';
import { startHttpServer } from '../httpServer.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';

31
src/extension/main.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* 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 { ExtensionContextFactory } from './extensionContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig) {
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
await mcpTransport.start(serverBackendFactory, config.server);
}
export function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
}

View File

@@ -17,6 +17,8 @@
import os from 'node:os';
import path from 'node:path';
import type { FullConfig } from './config.js';
export function cacheDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
@@ -30,10 +32,6 @@ export function cacheDir() {
return path.join(cacheDirectory, 'ms-playwright');
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
export async function userDataDir(browserConfig: FullConfig['browser']) {
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
}

View File

@@ -27,7 +27,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
return mcpServer.createServer(new BrowserServerBackend(config, [factory]), false);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {

View File

@@ -27,7 +27,7 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
if (char === '"')
return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + escapedText.replace(/[`]/g, '\\`') + char;
return char + escapedText.replace(/[`]/g, '`') + char;
throw new Error('Invalid escape char');
}

View File

@@ -1,5 +0,0 @@
[*]
../
../loop/
../mcp/
../utils/

View File

@@ -46,13 +46,13 @@ export class Context {
static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];

View File

@@ -18,11 +18,10 @@ import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../utils/package.js';
import { packageJSON } from '../package.js';
import { Context } from './context.js';
import { perform } from './perform.js';
import { snapshot } from './snapshot.js';
import { toMcpTool } from '../mcp/tool.js';
import type { FullConfig } from '../config.js';
import type { ServerBackend } from '../mcp/server.js';
@@ -49,13 +48,12 @@ class LoopToolsServerBackend implements ServerBackend {
this._context = await Context.create(this._config);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
const tool = this._tools.find(tool => tool.schema.name === name)!;
const parsedArguments = tool.schema.inputSchema.parse(args || {});
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
return await tool.handle(this._context!, parsedArguments);
}

View File

@@ -17,12 +17,11 @@
import type { z } from 'zod';
import type * as mcpServer from '../mcp/server.js';
import type { Context } from './context.js';
import type { ToolSchema } from '../mcp/tool.js';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
schema: mcpServer.ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {

View File

@@ -1,2 +0,0 @@
[*]
../utils/

View File

@@ -1,131 +0,0 @@
/**
* 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 { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { logUnhandledError } from '../utils/log.js';
import { packageJSON } from '../utils/package.js';
import type { ServerBackend, ClientVersion, Root } from './server.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
export type MCPProvider = {
name: string;
description: string;
connect(): Promise<Transport>;
};
export class ProxyBackend implements ServerBackend {
name = 'Playwright MCP Client Switcher';
version = packageJSON.version;
private _mcpProviders: MCPProvider[];
private _currentClient: Client | undefined;
private _contextSwitchTool: Tool;
private _roots: Root[] = [];
constructor(mcpProviders: MCPProvider[]) {
this._mcpProviders = mcpProviders;
this._contextSwitchTool = this._defineContextSwitchTool();
}
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._roots = roots;
await this._setCurrentClient(this._mcpProviders[0]);
}
async listTools(): Promise<Tool[]> {
const response = await this._currentClient!.listTools();
if (this._mcpProviders.length === 1)
return response.tools;
return [
...response.tools,
this._contextSwitchTool,
];
}
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
if (name === this._contextSwitchTool.name)
return this._callContextSwitchTool(args);
return await this._currentClient!.callTool({
name,
arguments: args,
}) as CallToolResult;
}
serverClosed?(): void {
void this._currentClient?.close().catch(logUnhandledError);
}
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
try {
const factory = this._mcpProviders.find(factory => factory.name === params.name);
if (!factory)
throw new Error('Unknown connection method: ' + params.name);
await this._setCurrentClient(factory);
return {
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
};
} catch (error) {
return {
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
isError: true,
};
}
}
private _defineContextSwitchTool(): Tool {
return {
name: 'browser_connect',
description: [
'Connect to a browser using one of the available methods:',
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
].join('\n'),
inputSchema: zodToJsonSchema(z.object({
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
}), { strictUnions: true }) as Tool['inputSchema'],
annotations: {
title: 'Connect to a browser context',
readOnlyHint: true,
openWorldHint: false,
},
};
}
private async _setCurrentClient(factory: MCPProvider) {
await this._currentClient?.close();
this._currentClient = undefined;
const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version });
client.registerCapabilities({
roots: {
listRoots: true,
},
});
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
client.setRequestHandler(PingRequestSchema, () => ({}));
const transport = await factory.connect();
await client.connect(transport);
this._currentClient = client;
}
}

View File

@@ -14,26 +14,44 @@
* limitations under the License.
*/
import debug from 'debug';
import { z } from 'zod';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ManualPromise } from '../utils/manualPromise.js';
import { logUnhandledError } from '../utils/log.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ManualPromise } from '../manualPromise.js';
import { logUnhandledError } from '../log.js';
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
const serverDebug = debug('pw:mcp:server');
export type ClientCapabilities = {
roots?: {
listRoots?: boolean
};
};
export type ToolResponse = {
content: (TextContent | ImageContent)[];
isError?: boolean;
};
export type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
export type ClientVersion = { name: string, version: string };
export interface ServerBackend {
name: string;
version: string;
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
initialize?(server: Server): Promise<void>;
tools(): ToolSchema<any>[];
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
serverClosed?(): void;
}
@@ -53,16 +71,23 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
}
});
const tools = backend.tools();
server.setRequestHandler(ListToolsRequestSchema, async () => {
serverDebug('listTools');
await initializedPromise;
const tools = await backend.listTools();
return { tools };
return { tools: tools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: zodToJsonSchema(tool.inputSchema),
annotations: {
title: tool.title,
readOnlyHint: tool.type === 'readOnly',
destructiveHint: tool.type === 'destructive',
openWorldHint: true,
},
})) };
});
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
serverDebug('callTool', request);
await initializedPromise;
if (runHeartbeat && !heartbeatRunning) {
@@ -70,29 +95,22 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
startHeartbeat(server);
}
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
if (!tool)
return errorResult(`Error: Tool "${request.params.name}" not found`);
try {
return await backend.callTool(request.params.name, request.params.arguments || {});
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
} catch (error) {
return {
content: [{ type: 'text', text: '### Result\n' + String(error) }],
isError: true,
};
return errorResult(String(error));
}
});
addServerListener(server, 'initialized', async () => {
try {
const capabilities = server.getClientCapabilities();
let clientRoots: Root[] = [];
if (capabilities?.roots) {
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
clientRoots = roots;
}
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(clientVersion, clientRoots);
initializedPromise.resolve();
} catch (e) {
logUnhandledError(e);
}
addServerListener(server, 'initialized', () => {
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
});
addServerListener(server, 'close', () => backend.serverClosed?.());
return server;

View File

@@ -1,42 +0,0 @@
/**
* 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 { zodToJsonSchema } from 'zod-to-json-schema';
import type { z } from 'zod';
import type * as mcpServer from './server.js';
export type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
return {
name: tool.name,
description: tool.description,
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
annotations: {
title: tool.title,
readOnlyHint: tool.type === 'readOnly',
destructiveHint: tool.type === 'destructive',
openWorldHint: true,
},
};
}

View File

@@ -21,7 +21,7 @@ import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
import { httpAddressToString, startHttpServer } from '../httpServer.js';
import * as mcpServer from './server.js';
import type { ServerBackendFactory } from './server.js';

View File

@@ -19,4 +19,4 @@ import path from 'path';
import url from 'url';
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', '..', 'package.json'), 'utf8'));
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -15,21 +15,17 @@
*/
import { program, Option } from 'commander';
import * as mcpServer from './mcp/server.js';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './utils/package.js';
import { packageJSON } from './package.js';
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
import { Context } from './context.js';
import { contextFactory } from './browserContextFactory.js';
import { runLoopTools } from './loopTools/main.js';
import { ProxyBackend } from './mcp/proxyBackend.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
import { InProcessTransport } from './mcp/inProcessTransport.js';
import type { MCPProvider } from './mcp/proxyBackend.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
program
.version('Version ' + packageJSON.version)
@@ -43,7 +39,6 @@ program
.option('--config <path>', 'path to the configuration file.')
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
.option('--executable-path <path>', 'path to the browser executable.')
.option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.')
.option('--headless', 'run browser in headless mode, headed by default')
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--ignore-https-errors', 'ignore https errors')
@@ -60,6 +55,7 @@ program
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
@@ -74,22 +70,28 @@ program
const config = await resolveCLIConfig(options);
if (options.extension) {
const contextFactory = createExtensionContextFactory(config);
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
await mcpTransport.start(serverBackendFactory, config.server);
await runWithExtension(config);
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
const browserContextFactory = contextFactory(config);
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
const factories: FactoryList = [browserContextFactory];
if (options.connectTool)
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)));
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
factories.push(createExtensionContextFactory(config));
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
await mcpTransport.start(serverBackendFactory, config.server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
}
});
function setupExitWatchdog() {
@@ -108,19 +110,4 @@ function setupExitWatchdog() {
process.on('SIGTERM', handleExit);
}
function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
}
function mcpProviderForBrowserContextFactory(config: FullConfig, browserContextFactory: BrowserContextFactory) {
return {
name: browserContextFactory.name,
description: browserContextFactory.description,
connect: async () => {
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
return new InProcessTransport(server);
},
};
}
void program.parseAsync(process.argv);

View File

@@ -18,7 +18,7 @@ import fs from 'fs';
import path from 'path';
import { Response } from './response.js';
import { logUnhandledError } from './utils/log.js';
import { logUnhandledError } from './log.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';

View File

@@ -17,8 +17,8 @@
import { EventEmitter } from 'events';
import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './utils/log.js';
import { ManualPromise } from './utils/manualPromise.js';
import { logUnhandledError } from './log.js';
import { ManualPromise } from './manualPromise.js';
import { ModalState } from './tools/tool.js';
import type { Context } from './context.js';

View File

@@ -1,2 +0,0 @@
[*]
../utils/

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';

View File

@@ -19,7 +19,7 @@ import { z } from 'zod';
import { defineTabTool } from './tool.js';
import { elementSchema } from './snapshot.js';
import { generateLocator } from './utils.js';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../javascript.js';
const pressKey = defineTabTool({
capability: 'core',

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../javascript.js';
const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool, defineTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
const snapshot = defineTool({

View File

@@ -20,7 +20,7 @@ import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js';
import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
import type { ToolSchema } from '../mcp/tool.js';
import type { ToolSchema } from '../mcp/server.js';
export type FileUploadModalState = {
type: 'fileChooser';

View File

@@ -36,8 +36,12 @@ const wait = defineTool({
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
const code: string[] = [];
if (params.time) {
response.addCode(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
const timeCode = `await new Promise(f => setTimeout(f, ${params.time!} * 1000));`;
code.push(timeCode);
response.addCode(timeCode);
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
}
@@ -46,12 +50,16 @@ const wait = defineTool({
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
response.addCode(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
const goneCode = `await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`;
code.push(goneCode);
response.addCode(goneCode);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
response.addCode(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
const locatorCode = `await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`;
code.push(locatorCode);
response.addCode(locatorCode);
await locator.waitFor({ state: 'visible' });
}

View File

@@ -16,10 +16,14 @@
import crypto from 'crypto';
export function createGuid(): string {
return crypto.randomBytes(16).toString('hex');
}
export function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

View File

@@ -46,40 +46,6 @@ test('test snapshot tool list', async ({ client }) => {
]));
});
test('test tool list proxy mode', async ({ startClient }) => {
const { client } = await startClient({
args: ['--connect-tool'],
});
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_connect', // the extra tool
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_press_key',
'browser_resize',
'browser_snapshot',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait_for',
]));
});
test('test capabilities (pdf)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--caps=pdf'],

View File

@@ -40,18 +40,14 @@ type CDPServer = {
start: () => Promise<BrowserContext>;
};
export type StartClient = (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
}) => Promise<{ client: Client, stderr: () => string }>;
type TestFixtures = {
client: Client;
startClient: StartClient;
startClient: (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
}) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -72,7 +68,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const configDir = path.dirname(test.info().config.configFile!);
const clients: Client[] = [];
let client: Client | undefined;
await use(async options => {
const args: string[] = [];
@@ -90,11 +86,9 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
args.push(`--config=${path.relative(configDir, configFile)}`);
}
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
if (options.rootsResponseDelay)
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
return {
roots: options.roots,
};
@@ -107,13 +101,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
process.stderr.write(data);
stderrBuffer += data.toString();
});
clients.push(client);
await client.connect(transport);
await client.ping();
return { client, stderr: () => stderrBuffer };
});
await Promise.all(clients.map(client => client.close()));
await client?.close();
},
wsEndpoint: async ({ }, use) => {
@@ -130,8 +123,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
if (browserContext)
throw new Error('CDP server already exists');
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
channel: mcpBrowser,
headless: true,

View File

@@ -19,13 +19,13 @@ import path from 'path';
import { pathToFileURL } from 'url';
import { test, expect } from './fixtures.js';
import { createHash } from '../src/utils/guid.js';
import { createHash } from '../src/utils.js';
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
clientName: 'Visual Studio Code',
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
roots: [
{
name: 'test',
@@ -44,11 +44,12 @@ test('should use separate user data by root path', async ({ startClient, server
expect(file).toContain(hash);
});
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
test('check that trace is saved in workspace', async ({ startClient, server, mcpMode }, testInfo) => {
const rootPath = testInfo.outputPath('workspace');
const { client } = await startClient({
args: ['--save-trace'],
clientName: 'My client',
clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it
roots: [
{
name: 'workspace',
@@ -67,13 +68,3 @@ test('check that trace is saved in workspace', async ({ startClient, server }, t
const [file] = await fs.promises.readdir(path.join(rootPath, '.playwright-mcp'));
expect(file).toContain('traces');
});
test('should list all tools when listRoots is slow', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
clientName: 'Another custom client',
roots: [],
rootsResponseDelay: 1000,
});
const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(20);
});

View File

@@ -47,8 +47,8 @@ test('browser_wait_for(text)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { text: 'Text to appear' },
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
})).toHaveResponse({
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
});
@@ -84,8 +84,8 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { textGone: 'Text to disappear' },
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
})).toHaveResponse({
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
});

View File

@@ -1,179 +0,0 @@
#!/usr/bin/env node
/**
* Copyright 2019 Google Inc. All rights reserved.
* Modifications 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.
*/
// @ts-check
import fs from 'fs';
import ts from 'typescript';
import path from 'path';
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const depsCache = {};
const packageRoot = path.resolve(__dirname, '..');
async function checkDeps() {
const deps = new Set();
const src = path.join(packageRoot, 'src');
const program = ts.createProgram({
options: {
allowJs: true,
target: ts.ScriptTarget.ESNext,
strict: true,
},
rootNames: listAllFiles(src),
});
const sourceFiles = program.getSourceFiles();
const errors = [];
sourceFiles.filter(x => !x.fileName.includes(path.sep + 'node_modules' + path.sep) && !x.fileName.includes(path.sep + 'bundles' + path.sep)).map(x => visit(x, x.fileName, x.getFullText()));
if (errors.length) {
for (const error of errors)
console.log(error);
console.log(`--------------------------------------------------------`);
console.log(`Changing the project structure or adding new components?`);
console.log(`Update DEPS in ${packageRoot}`);
console.log(`--------------------------------------------------------`);
process.exit(1);
}
function visit(node, fileName, text) {
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
if (node.importClause) {
if (node.importClause.isTypeOnly)
return;
if (node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
if (node.importClause.namedBindings.elements.every(e => e.isTypeOnly))
return;
}
}
const importName = node.moduleSpecifier.text;
let importPath;
if (importName.startsWith('.'))
importPath = path.resolve(path.dirname(fileName), importName);
const mergedDeps = calculateDeps(fileName);
if (mergedDeps.includes('***'))
return;
if (importPath) {
if (!fs.existsSync(importPath)) {
if (fs.existsSync(importPath + '.ts'))
importPath = importPath + '.ts';
else if (fs.existsSync(importPath + '.tsx'))
importPath = importPath + '.tsx';
else if (fs.existsSync(importPath + '.d.ts'))
importPath = importPath + '.d.ts';
}
if (!allowImport(fileName, importPath, mergedDeps))
errors.push(`Disallowed import ${path.relative(packageRoot, importPath)} in ${path.relative(packageRoot, fileName)}`);
return;
}
const fullStart = node.getFullStart();
const commentRanges = ts.getLeadingCommentRanges(text, fullStart);
for (const range of commentRanges || []) {
const comment = text.substring(range.pos, range.end);
if (comment.includes('@no-check-deps'))
return;
}
if (importName.startsWith('@'))
deps.add(importName.split('/').slice(0, 2).join('/'));
else
deps.add(importName.split('/')[0]);
}
ts.forEachChild(node, x => visit(x, fileName, text));
}
function calculateDeps(from) {
const fromDirectory = path.dirname(from);
let depsDirectory = fromDirectory;
while (depsDirectory.startsWith(packageRoot) && !depsCache[depsDirectory] && !fs.existsSync(path.join(depsDirectory, 'DEPS.list')))
depsDirectory = path.dirname(depsDirectory);
if (!depsDirectory.startsWith(packageRoot))
return [];
let deps = depsCache[depsDirectory];
if (!deps) {
const depsListFile = path.join(depsDirectory, 'DEPS.list');
deps = {};
let group = [];
for (const line of fs.readFileSync(depsListFile, 'utf-8').split('\n').filter(Boolean).filter(l => !l.startsWith('#'))) {
const groupMatch = line.match(/\[(.*)\]/);
if (groupMatch) {
group = [];
deps[groupMatch[1]] = group;
continue;
}
if (line === '***')
group.push('***');
else
group.push(path.resolve(depsDirectory, line));
}
depsCache[depsDirectory] = deps;
}
return [...(deps['*'] || []), ...(deps[path.relative(depsDirectory, from)] || [])]
}
function allowImport(from, to, mergedDeps) {
const fromDirectory = path.dirname(from);
const toDirectory = isDirectory(to) ? to : path.dirname(to);
if (to === toDirectory)
to = path.join(to, 'index.ts');
if (fromDirectory === toDirectory)
return true;
for (const dep of mergedDeps) {
if (dep === '***')
return true;
if (to === dep || toDirectory === dep)
return true;
if (dep.endsWith('**')) {
const parent = dep.substring(0, dep.length - 2);
if (to.startsWith(parent))
return true;
}
}
return false;
}
}
function listAllFiles(dir) {
const dirs = fs.readdirSync(dir, { withFileTypes: true });
const result = [];
dirs.forEach(d => {
const res = path.resolve(dir, d.name);
if (d.isDirectory())
result.push(...listAllFiles(res));
else
result.push(res);
});
return result;
}
checkDeps().catch(e => {
console.error(e && e.stack ? e.stack : e);
process.exit(1);
});
function isDirectory(dir) {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
}

View File

@@ -1,68 +0,0 @@
#!/usr/bin/env node
/**
* 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.
*/
// @ts-check
import fs from 'fs';
import path from 'path';
import child_process from 'child_process';
import { argv } from 'process';
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const readJSON = async (filePath) => JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
const writeJSON = async (filePath, json) => {
await fs.promises.writeFile(filePath, JSON.stringify(json, null, 2) + '\n');
}
async function updatePackageJSON(dir, version) {
const packageJSONPath = path.join(dir, 'package.json');
const packageJSON = await readJSON(packageJSONPath);
console.log(`Updating ${packageJSONPath} to version ${version}`);
packageJSON.version = version;
await writeJSON(packageJSONPath, packageJSON);
// Run npm i to update package-lock.json
child_process.execSync('npm i', {
cwd: dir
});
}
async function updateExtensionManifest(dir, version) {
const manifestPath = path.join(dir, 'manifest.json');
const manifest = await readJSON(manifestPath);
console.log(`Updating ${manifestPath} to version ${version}`);
manifest.version = version;
await writeJSON(manifestPath, manifest);
}
async function setVersion(version) {
if (version.startsWith('v'))
throw new Error('version must not start with "v"');
const packageRoot = path.join(__dirname, '..');
await updatePackageJSON(packageRoot, version)
await updatePackageJSON(path.join(packageRoot, 'extension'), version)
await updateExtensionManifest(path.join(packageRoot, 'extension'), version)
}
if (argv.length !== 3) {
console.error('Usage: set-version <version>');
process.exit(1);
}
setVersion(argv[2]);