42 Commits

Author SHA1 Message Date
Yury Semikhatsky
d5d810f896 chore: mark 0.0.34 (#901) 2025-08-15 17:38:58 -07:00
Yury Semikhatsky
1efd3b55e5 devops: update extension manifest version (#904) 2025-08-15 16:10:49 -07:00
Yury Semikhatsky
1d1db1e287 chore: fix copyright (#903) 2025-08-15 15:36:12 -07:00
Yury Semikhatsky
25f15e7f5e devops: set-version.js script (#902) 2025-08-15 15:28:59 -07:00
Yury Semikhatsky
c559243ef6 chore(extension): connected badge while loading (#899) 2025-08-15 13:44:17 -07:00
Yury Semikhatsky
91d5d24cab chore: handle list roots in the server, with timeout (#898) 2025-08-15 11:23:59 -07:00
Yury Semikhatsky
92554abfd1 devops: extension publishing job (#888) 2025-08-15 10:25:46 -07:00
Pavel Feldman
4370f2cdf2 chore: try macos15 runners (#892) 2025-08-15 10:19:52 -07:00
Yury Semikhatsky
ba726fb44a chore(extension): connection timeout when extension not installed (#896) 2025-08-15 09:09:35 -07:00
Yury Semikhatsky
2fc4e88048 chore(extension): add readme file, recommend --extension option (#894) 2025-08-14 16:01:14 -07:00
Adam Tarantino
3f148a4005 docs: add opencode installation instructions (#895) 2025-08-14 15:41:46 -07:00
Pavel Feldman
c92aefdc12 chore: close all clients in fixture (#878) 2025-08-14 10:57:07 -07:00
Pavel Feldman
badfd82202 chore: move tool schema to mcp as it is used by all servers (#887) 2025-08-13 18:23:25 -07:00
Yury Semikhatsky
12942b81d6 fix: wait for initialization to complete before listing tools (#886) 2025-08-13 17:29:10 -07:00
Pavel Feldman
73adb0fdf0 chore: steer towards mcp types a bit (#880) 2025-08-13 14:09:37 -07:00
Yury Semikhatsky
8572ab300c chore: separate proxy client from external (#877) 2025-08-12 18:05:45 -07:00
Pavel Feldman
c091a11d76 chore: extract utils folder (#876) 2025-08-12 14:33:00 -07:00
Pavel Feldman
dbd44110f1 chore: run test server per context (#874)
Fixes https://github.com/microsoft/playwright-mcp/issues/869
2025-08-12 13:41:08 -07:00
Pavel Feldman
2f41a3f6b1 chore: roll Playwright to latest (#875) 2025-08-12 13:30:32 -07:00
Yury Semikhatsky
7c4d67b3ae chore: tool definition without zod (#873) 2025-08-12 13:19:25 -07:00
Vicente Filho
53c6b6dcb1 fix: backtick quote escaping (#871) 2025-08-12 13:19:09 -07:00
Yury Semikhatsky
1fb2878271 fix(proxy): properly forward root requests and client metadata (#865) 2025-08-12 10:17:45 +02:00
Pavel Feldman
ab0ecc4075 chore: introduce check-deps (#864) 2025-08-11 17:21:26 -07:00
Yury Semikhatsky
f010164bf1 chore: mcp backend switcher (#854) 2025-08-11 14:16:43 -07:00
Pavel Feldman
db9cfe1720 chore: bump test workers to 2 on CI (#863) 2025-08-11 12:48:54 -07:00
Pavel Feldman
24f81a7a27 fix: emit code for waitfor (#862)
Fixes https://github.com/microsoft/playwright-mcp/issues/859
2025-08-11 11:58:45 -07:00
Yury Semikhatsky
21ced701b5 chore(extension): status page (#856) 2025-08-08 18:33:10 -07:00
Pavel Feldman
d3bf2eefc6 chore: mark 0.0.33 (#851) 2025-08-08 17:22:18 -07:00
Pavel Feldman
2ca899316d chore: roll Playwright to recent (#850) 2025-08-08 09:37:07 -07:00
Pavel Feldman
16f3523317 chore: do not return fullPage screenshots to the LLM (#849) 2025-08-08 09:36:51 -07:00
Omar Bahareth
6c2dda31ad fix(docs): Invalid MCP Install Link (#846) 2025-08-07 18:39:50 -07:00
Yury Semikhatsky
3b6ecf0a43 chore(extension): connect button for each page, style tweaks (#848)
<img width="643" height="709" alt="image"
src="https://github.com/user-attachments/assets/850f2455-b853-4c0f-8047-a7f2ced16b7b"
/>
2025-08-07 17:24:48 -07:00
Yury Semikhatsky
636f1956cc chore(extension): explicitly detach from debugger when connection closes (#847) 2025-08-07 14:45:52 -07:00
Yury Semikhatsky
5aef2aafcb devops: switch to node 20 on CI (#844)
Node 18 maintanence period ended in April 2025. Running on 18 already
caused a problem in https://github.com/microsoft/playwright-mcp/pull/842
2025-08-07 10:04:43 -07:00
Yury Semikhatsky
8ecc46c905 chore(extension): add test (#842)
* On Linux headed mode under xvfb-run fails to properly launch the
process. It works fine without xvfb-run, we don't have environment for
that on CI, so run on macOS instead.
* Node v18.20.8 stalls on `const uuid = crypto.randomUUID();`, so use
v20 for the extension tests.
2025-08-06 16:27:39 -07:00
Yury Semikhatsky
5dbb1504ba chore(extension): show error when connection is rejected due to inact… (#836)
…ivity
2025-08-05 15:08:57 -07:00
Yury Semikhatsky
20e1144c3b chore(extension): proper watchdog for inactive page selector (#835) 2025-08-05 14:18:04 -07:00
Yury Semikhatsky
eab20aa69e chore(extension): do not send if socket is already closed (#834)
* Remove debugger listeners if closed() is called as `ws.onclosed` is
dispatched asynchronously
* Tabs can be closed while update badge command is in flight
* Inflight CDP commands fail if the tab closes, do not try to send their
response to a closed socket
2025-08-05 13:47:08 -07:00
Yury Semikhatsky
46ce86f97e chore(extension): terminate connection if nothing has been selected (#827) 2025-08-05 09:47:39 -07:00
Yury Semikhatsky
4890b9d509 chore(extension): create relay per context (#828) 2025-08-05 08:32:54 -07:00
Yury Semikhatsky
3f6837baa9 fix: cursor does not respond to listRoots (#826) 2025-08-04 20:52:55 -07:00
Yury Semikhatsky
6d62c173c8 chore(extension): build into dist directory (#825) 2025-08-04 11:47:25 -07:00
68 changed files with 1900 additions and 662 deletions

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -28,15 +28,14 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-15, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -55,10 +54,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -83,3 +82,42 @@ jobs:
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1
test_extension:
strategy:
fail-fast: false
runs-on: macos-latest
defaults:
run:
working-directory: ./extension
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: extension
path: ./extension/dist
retention-days: 7
- name: Install and build MCP server
run: |
cd ..
npm ci
npm run build
npx playwright install chromium
- name: Run tests
run: |
if [[ "$(uname)" == "Linux" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
else
npm run test
fi
shell: bash

View File

@@ -68,3 +68,31 @@ 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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
lib/
dist/
node_modules/
test-results/
playwright-report/

View File

@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
#### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually:
@@ -100,6 +100,29 @@ 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>
@@ -158,6 +181,9 @@ 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.
@@ -169,8 +195,6 @@ Playwright MCP server supports following arguments. They can be provided in the
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
--permissions <permissions> comma-separated list of permissions to grant, for
example "clipboard-read,clipboard-write"
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
@@ -193,7 +217,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), or in the isolated contexts for the testing sessions.
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.
**Persistent profile**
@@ -233,6 +257,10 @@ 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

48
extension/README.md Normal file
View File

@@ -0,0 +1,48 @@
# 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": "1.0.0",
"version": "0.0.34",
"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.32",
"version": "0.0.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp-extension",
"version": "0.0.32",
"version": "0.0.34",
"license": "Apache-2.0",
"devDependencies": {
"@types/chrome": "^0.0.315",
@@ -16,7 +16,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vite-plugin-static-copy": "^3.1.1"
},
"engines": {
"node": ">=18"
@@ -1090,6 +1091,46 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -1142,6 +1183,31 @@
}
]
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1224,6 +1290,34 @@
"node": ">=6"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1247,6 +1341,72 @@
"node": ">=6.9.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1277,6 +1437,19 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1328,12 +1501,48 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-map": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz",
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1396,6 +1605,19 @@
"node": ">=0.10.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/rollup": {
"version": "4.46.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
@@ -1462,6 +1684,64 @@
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -1475,6 +1755,16 @@
"node": ">=14.17"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -1564,6 +1854,26 @@
}
}
},
"node_modules/vite-plugin-static-copy": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz",
"integrity": "sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"fs-extra": "^11.3.0",
"p-map": "^7.0.3",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.14"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.32",
"version": "0.0.34",
"description": "Playwright MCP Browser Extension",
"type": "module",
"private": true,
@@ -19,7 +19,8 @@
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"clean": "rm -rf lib"
"test": "playwright test",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
@@ -29,6 +30,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vite-plugin-static-copy": "^3.1.1"
}
}

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 { defineConfig } from '@playwright/test';
import type { TestOptions } from '../tests/fixtures.js';
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
projects: [
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
],
});

View File

@@ -19,42 +19,67 @@ import { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
tabId: number;
windowId: number;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId: number;
windowId: number;
mcpRelayUrl: string;
} | {
type: 'getConnectionStatus';
} | {
type: 'disconnect';
};
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
return true;
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'connectToTab':
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
case 'getConnectionStatus':
sendResponse({
connectedTabId: this._connectedTabId
});
return false;
case 'disconnect':
this._disconnect().then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
return false;
}
private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
@@ -62,17 +87,41 @@ class TabShareExtension {
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
const connection = new RelayConnection(socket, tabId);
const connectionClosed = (m: string) => {
debugLog(m);
if (this._activeConnection === connection) {
this._activeConnection = undefined;
void this._setConnectedTabId(null);
}
const connection = new RelayConnection(socket);
connection.onclose = () => {
debugLog('Connection closed');
this._pendingTabSelection.delete(selectorTabId);
// TODO: show error in the selector tab?
};
this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`);
} catch (error: any) {
debugLog(`Failed to connect to MCP relay:`, error.message);
throw error;
}
}
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try {
this._activeConnection?.close('Another connection is requested');
} catch (error: any) {
debugLog(`Error closing active connection:`, error);
}
await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection)
throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => {
debugLog('MCP connection closed');
this._activeConnection = undefined;
void this._setConnectedTabId(null);
};
socket.onclose = () => connectionClosed('WebSocket closed');
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
this._activeConnection = connection;
await Promise.all([
this._setConnectedTabId(tabId),
@@ -91,18 +140,29 @@ class TabShareExtension {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '', color: null });
await this._updateBadge(oldTabId, { text: '' });
if (tabId)
await this._updateBadge(tabId, { text: '', color: '#4CAF50' });
await this._updateBadge(tabId, { text: '', color: '#4CAF50', title: 'Connected to MCP client' });
}
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
await chrome.action.setBadgeText({ tabId, text });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
try {
await chrome.action.setBadgeText({ tabId, text });
await chrome.action.setTitle({ tabId, title: title || '' });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
} catch (error: any) {
// Ignore errors as the tab may be closed already.
}
}
private async _onTabRemoved(tabId: number): Promise<void> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
return;
}
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
@@ -110,15 +170,50 @@ class TabShareExtension {
this._connectedTabId = null;
}
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
await this._setConnectedTabId(tabId);
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
for (const [tabId, pending] of this._pendingTabSelection) {
if (tabId === activeInfo.tabId) {
if (pending.timerId) {
clearTimeout(pending.timerId);
pending.timerId = undefined;
}
continue;
}
if (!pending.timerId) {
pending.timerId = setTimeout(() => {
const existed = this._pendingTabSelection.delete(tabId);
if (existed) {
pending.connection.close('Tab has been inactive for 5 seconds');
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
}
}, 5000);
return;
}
}
}
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
if (this._connectedTabId === tabId)
void this._setConnectedTabId(tabId);
}
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
}
private async _onActionClicked(): Promise<void> {
await chrome.tabs.create({
url: chrome.runtime.getURL('status.html'),
active: true
});
}
private async _disconnect(): Promise<void> {
this._activeConnection?.close('User disconnected');
this._activeConnection = undefined;
await this._setConnectedTabId(null);
}
}
new TabShareExtension();

View File

@@ -41,11 +41,18 @@ export class RelayConnection {
private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
private _tabPromise: Promise<void>;
private _tabPromiseResolve!: () => void;
private _closed = false;
constructor(ws: WebSocket, tabId: number) {
this._debuggee = { tabId };
onclose?: () => void;
constructor(ws: WebSocket) {
this._debuggee = { };
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this);
this._ws.onclose = () => this._onClose();
// Store listeners for cleanup
this._eventListener = this._onDebuggerEvent.bind(this);
this._detachListener = this._onDebuggerDetach.bind(this);
@@ -53,10 +60,27 @@ export class RelayConnection {
chrome.debugger.onDetach.addListener(this._detachListener);
}
// Either setTabId or close is called after creating the connection.
setTabId(tabId: number): void {
this._debuggee = { tabId };
this._tabPromiseResolve();
}
close(message: string): void {
this._ws.close(1000, message);
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
// CDP events to the closed connection.
this._onClose();
}
private _onClose() {
if (this._closed)
return;
this._closed = true;
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
this._ws.close(1000, message);
chrome.debugger.detach(this._debuggee).catch(() => {});
this.onclose?.();
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
@@ -111,9 +135,8 @@ export class RelayConnection {
}
private async _handleCommand(message: ProtocolCommand): Promise<any> {
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'attachToTab') {
await this._tabPromise;
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
@@ -121,6 +144,8 @@ export class RelayConnection {
targetInfo: result?.targetInfo,
};
}
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);
@@ -147,6 +172,7 @@ export class RelayConnection {
}
private _sendMessage(message: any): void {
this._ws.send(JSON.stringify(message));
if (this._ws.readyState === WebSocket.OPEN)
this._ws.send(JSON.stringify(message));
}
}

View File

@@ -25,10 +25,9 @@ body {
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 24px;
padding: 16px;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
.content-wrapper {
@@ -36,50 +35,55 @@ body {
margin: 0 auto;
}
.main-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
color: #1f2328;
/* Status Banner */
.status-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
}
/* Status Banner */
.status-banner {
padding: 16px;
margin-bottom: 24px;
border-radius: 6px;
border: 1px solid;
padding: 12px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.status-banner.connected {
background-color: #dafbe1;
border-color: #1a7f37;
color: #0d5a23;
color: #1f2328;
}
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
}
.status-banner.error {
background-color: #ffebe9;
border-color: #da3633;
color: #a40e26;
color: #1f2328;
}
.status-banner.connecting {
background-color: #fff8c5;
border-color: #d1b500;
color: #7a5c00;
.status-banner.error::before {
content: "\274C";
margin-right: 8px;
}
/* Buttons */
.button-container {
margin-bottom: 24px;
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
@@ -88,46 +92,63 @@ body {
justify-content: center;
text-decoration: none;
margin-right: 8px;
min-width: 90px;
}
.button.primary {
background-color: #2da44e;
border-color: #2da44e;
color: #ffffff;
background-color: #f8f9fa;
color: #3c4043;
border: 1px solid #dadce0;
}
.button.primary:hover {
background-color: #2c974b;
background-color: #f1f3f4;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
}
.button.default {
background-color: #f6f8fa;
border-color: #d1d9e0;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
border-color: #c7d2da;
}
.button.reject {
background-color: #da3633;
color: #ffffff;
border: 1px solid #da3633;
}
.button.reject:hover {
background-color: #c73836;
border-color: #c73836;
}
/* Tab selection */
.tab-section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #1f2328;
padding-left: 12px;
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #656d76;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
margin-bottom: 8px;
background-color: #ffffff;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.tab-item:hover {
background-color: #f8f9fa;
}
.tab-item.selected {

View File

@@ -18,10 +18,12 @@
<head>
<title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="src/ui/connect.css">
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
<link rel="stylesheet" href="connect.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="src/ui/connect.tsx"></script>
<script type="module" src="connect.tsx"></script>
</body>
</html>

View File

@@ -16,20 +16,13 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
@@ -54,42 +47,41 @@ const ConnectApp: React.FC = () => {
setClientInfo(info);
setStatus({
type: 'connecting',
message: `MCP client "${info}" is trying to connect. Do you want to continue?`
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
void connectToMCPRelay(relayUrl);
void loadTabs();
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
}, []);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) {
if (response.success)
setTabs(response.tabs);
const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
setSelectedTab(currentTab);
} else {
else
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}
}, []);
const handleContinue = useCallback(async () => {
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
setShowButtons(false);
setShowTabList(false);
if (!selectedTab) {
setStatus({ type: 'error', message: 'Tab not selected.' });
return;
}
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
type: 'connectToTab',
mcpRelayUrl,
tabId: selectedTab.id,
windowId: selectedTab.windowId,
tabId: tab.id,
windowId: tab.windowId,
});
if (response?.success) {
@@ -106,7 +98,7 @@ const ConnectApp: React.FC = () => {
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [selectedTab, clientInfo, mcpRelayUrl]);
}, [clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => {
setShowButtons(false);
@@ -114,39 +106,46 @@ const ConnectApp: React.FC = () => {
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject();
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, []);
return (
<div className='app-container'>
<div className='content-wrapper'>
<h1 className='main-title'>
Playwright MCP Extension
</h1>
{status && <StatusBanner type={status.type} message={status.message} />}
{showButtons && (
<div className='button-container'>
<Button variant='primary' onClick={handleContinue}>
Continue
</Button>
<Button variant='default' onClick={handleReject}>
Reject
</Button>
{status && (
<div className='status-container'>
<StatusBanner type={status.type} message={status.message} />
{showButtons && (
<Button variant='reject' onClick={handleReject}>
Reject
</Button>
)}
</div>
)}
{showTabList && (
<div>
<h2 className='tab-section-title'>
<div className='tab-section-title'>
Select page to expose to MCP server:
</h2>
</div>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
isSelected={selectedTab?.id === tab.id}
onSelect={() => setSelectedTab(tab)}
button={
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
Connect
</Button>
}
/>
))}
</div>
@@ -161,46 +160,6 @@ const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, m
return <div className={`status-banner ${type}`}>{message}</div>;
};
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
tab,
isSelected,
onSelect
}) => {
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
return (
<div className={className} onClick={onSelect}>
<input
type='radio'
className='tab-radio'
checked={isSelected}
/>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>{tab.title || 'Untitled'}</div>
<div className='tab-url'>{tab.url}</div>
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright MCP Bridge Status</title>
<link rel="stylesheet" href="connect.css">
</head>
<body>
<div id="root"></div>
<script src="status.tsx" type="module"></script>
</body>
</html>

110
extension/src/ui/status.tsx Normal file
View File

@@ -0,0 +1,110 @@
/**
* 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 React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
interface ConnectionStatus {
isConnected: boolean;
connectedTabId: number | null;
connectedTab?: TabInfo;
}
const StatusApp: React.FC = () => {
const [status, setStatus] = useState<ConnectionStatus>({
isConnected: false,
connectedTabId: null
});
useEffect(() => {
void loadStatus();
}, []);
const loadStatus = async () => {
// Get current connection status from background script
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
if (connectedTabId) {
const tab = await chrome.tabs.get(connectedTabId);
setStatus({
isConnected: true,
connectedTabId,
connectedTab: {
id: tab.id!,
windowId: tab.windowId!,
title: tab.title!,
url: tab.url!,
favIconUrl: tab.favIconUrl
}
});
} else {
setStatus({
isConnected: false,
connectedTabId: null
});
}
};
const openConnectedTab = async () => {
if (!status.connectedTabId)
return;
await chrome.tabs.update(status.connectedTabId, { active: true });
window.close();
};
const disconnect = async () => {
await chrome.runtime.sendMessage({ type: 'disconnect' });
window.close();
};
return (
<div className='app-container'>
<div className='content-wrapper'>
{status.isConnected && status.connectedTab ? (
<div>
<div className='tab-section-title'>
Page with connected MCP client:
</div>
<div>
<TabItem
tab={status.connectedTab}
button={
<Button variant='primary' onClick={disconnect}>
Disconnect
</Button>
}
onClick={openConnectedTab}
/>
</div>
</div>
) : (
<div className='status-banner'>
No MCP clients are currently connected.
</div>
)}
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<StatusApp />);
}

View File

@@ -0,0 +1,67 @@
/**
* 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 React from 'react';
export interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
export interface TabItemProps {
tab: TabInfo;
onClick?: () => void;
button?: React.ReactNode;
}
export const TabItem: React.FC<TabItemProps> = ({
tab,
onClick,
button
}) => {
return (
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>
{tab.title || 'Untitled'}
</div>
<div className='tab-url'>{tab.url}</div>
</div>
{button}
</div>
);
};

View File

@@ -0,0 +1,187 @@
/**
* 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 { fileURLToPath } from 'url';
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>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async (mode?: 'disable-extension') => {
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' ? [] : [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// for manifest v3:
let [serviceWorker] = browserContext.serviceWorkers();
if (!serviceWorker)
serviceWorker = await browserContext.waitForEvent('serviceworker');
return browserContext;
}
});
await browserContext?.close();
},
});
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
name: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
return client;
}
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
return client;
}
for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag],
] as const) {
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!`),
});
});
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);
});
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
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');
});
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;
});
}

View File

@@ -6,8 +6,9 @@
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"outDir": "./dist/lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react"
},

View File

@@ -17,23 +17,37 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: '/lib/ui/',
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: '../../icons/*',
dest: 'icons'
},
{
src: '../../manifest.json',
dest: '.'
}
]
})
],
root: resolve(__dirname, 'src/ui'),
build: {
outDir: resolve(__dirname, 'lib/ui'),
emptyOutDir: true,
outDir: resolve(__dirname, 'dist/'),
emptyOutDir: false,
minify: false,
rollupOptions: {
input: resolve(__dirname, 'connect.html'),
input: ['src/ui/connect.html', 'src/ui/status.html'],
output: {
manualChunks: undefined,
inlineDynamicImports: true,
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]'
entryFileNames: 'lib/ui/[name].js',
chunkFileNames: 'lib/ui/[name].js',
assetFileNames: 'lib/ui/[name].[ext]'
}
}
}

32
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@playwright/mcp",
"version": "0.0.32",
"version": "0.0.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.32",
"version": "0.0.34",
"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-1753913825000",
"playwright-core": "1.55.0-alpha-1753913825000",
"playwright": "1.55.0-alpha-2025-08-12",
"playwright-core": "1.55.0-alpha-2025-08-12",
"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-1753913825000",
"@playwright/test": "1.55.0-alpha-2025-08-12",
"@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-1753913825000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-YM5YHU6nTYNVzXlKvQvtEdXzpubLvdfEiTxwWvbqGHL/iDK2kBJd3L0psIG6yClU1wy01O756TkOOQSEpzOu7g==",
"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==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0-alpha-1753913825000"
"playwright": "1.55.0-alpha-2025-08-12"
},
"bin": {
"playwright": "cli.js"
@@ -3745,12 +3745,12 @@
}
},
"node_modules/playwright": {
"version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-IDyZzTu3tRNIjcx7/6ZmU7VmZPFGaW4jNsizwqbjSoeLFZPTLx2y693qeVVF/8KwEjuiSU3hVTQEzWvnx7cf2Q==",
"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==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0-alpha-1753913825000"
"playwright-core": "1.55.0-alpha-2025-08-12"
},
"bin": {
"playwright": "cli.js"
@@ -3763,9 +3763,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-FH5pHzLseQxD8+d2wGlRa/I32AzJ+ZzcdDNM1aiSw5+gmq+aOo3PBqXHvhsh7tj0h4l2Qf6z9qf4mMiwijVthw==",
"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==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

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

View File

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

7
src/DEPS.list Normal file
View File

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

View File

@@ -21,8 +21,10 @@ import path from 'path';
import * as playwright from 'playwright';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
import { logUnhandledError, testDebug } from './log.js';
import { createHash } from './utils.js';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './utils/log.js';
import { createHash } from './utils/guid.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
@@ -50,7 +52,6 @@ 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;
@@ -58,11 +59,11 @@ class BaseContextFactory implements BrowserContextFactory {
this.config = config;
}
protected async _obtainBrowser(): Promise<playwright.Browser> {
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this.name})`);
this._browserPromise = this._doObtainBrowser();
this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
@@ -73,16 +74,13 @@ class BaseContextFactory implements BrowserContextFactory {
return this._browserPromise;
}
protected async _doObtainBrowser(): Promise<playwright.Browser> {
protected async _doObtainBrowser(clientInfo: ClientInfo): 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();
const browser = await this._obtainBrowser(clientInfo);
const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
}
@@ -108,11 +106,11 @@ class IsolatedContextFactory extends BaseContextFactory {
super('isolated', 'Create a new isolated browser context', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
tracesDir: this._tracesDir,
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
@@ -138,7 +136,7 @@ class CdpContextFactory extends BaseContextFactory {
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.config.browser.isolated ? await browser.newContext(this.config.browser.contextOptions) : browser.contexts()[0];
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
}
}
@@ -156,7 +154,7 @@ class RemoteContextFactory extends BaseContextFactory {
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(this.config.browser.contextOptions);
return browser.newContext();
}
}
@@ -175,9 +173,7 @@ 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);
let tracesDir: string | undefined;
if (this.config.saveTrace)
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
@@ -242,3 +238,16 @@ 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,25 +15,20 @@
*/
import { fileURLToPath } from 'url';
import { z } from 'zod';
import { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './log.js';
import { logUnhandledError } from './utils/log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { packageJSON } from './package.js';
import { defineTool } from './tools/tool.js';
import { packageJSON } from './utils/package.js';
import { toMcpTool } from './mcp/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;
@@ -44,19 +39,15 @@ export class BrowserServerBackend implements ServerBackend {
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
constructor(config: FullConfig, factories: FactoryList) {
constructor(config: FullConfig, factory: BrowserContextFactory) {
this._config = config;
this._browserContextFactory = factories[0];
this._browserContextFactory = factory;
this._tools = filteredTools(config);
if (factories.length > 1)
this._tools.push(this._defineContextSwitchTool(factories));
}
async initialize(server: mcpServer.Server): Promise<void> {
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
let rootPath: string | undefined;
if (capabilities.roots) {
const { roots } = await server.listRoots();
if (roots.length > 0) {
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
@@ -67,18 +58,21 @@ export class BrowserServerBackend implements ServerBackend {
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...server.getClientVersion(), rootPath },
clientInfo: { ...clientVersion, rootPath },
});
}
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
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 || {});
const context = this._context!;
const response = new Response(context, schema.name, parsedArguments);
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
const response = new Response(context, name, parsedArguments);
context.setRunningTool(true);
try {
await tool.handle(context, parsedArguments, response);
@@ -93,48 +87,6 @@ export class BrowserServerBackend implements ServerBackend {
}
serverClosed() {
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;
void this._context?.dispose().catch(logUnhandledError);
}
}

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.js';
import { sanitizeForFilePath } from './utils/fileUtils.js';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
@@ -40,7 +40,6 @@ export type CLIOptions = {
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
permissions?: string[];
port?: number;
proxyBypass?: string;
proxyServer?: string;
@@ -171,9 +170,6 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
if (cliOptions.blockServiceWorkers)
contextOptions.serviceWorkers = 'block';
if (cliOptions.permissions)
contextOptions.permissions = cliOptions.permissions;
const result: Config = {
browser: {
browserName,
@@ -220,7 +216,6 @@ function configFromEnv(): Config {
options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);

View File

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

3
src/extension/DEPS.list Normal file
View File

@@ -0,0 +1,3 @@
[*]
../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 '../httpServer.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../manualPromise.js';
import { httpAddressToString } from '../utils/httpServer.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../utils/manualPromise.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
@@ -56,6 +56,7 @@ type CDPResponse = {
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
@@ -69,9 +70,10 @@ export class CDPRelayServer {
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string) {
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
@@ -98,6 +100,9 @@ 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');
@@ -106,7 +111,7 @@ export class CDPRelayServer {
private _connectBrowser(clientInfo: ClientInfo) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString();
@@ -117,7 +122,12 @@ export class CDPRelayServer {
if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
spawn(executablePath, [href], {
const args: string[] = [];
if (this._userDataDir)
args.push(`--user-data-dir=${this._userDataDir}`);
args.push(href);
spawn(executablePath, args, {
windowsHide: true,
detached: true,
shell: false,

View File

@@ -16,7 +16,7 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../httpServer.js';
import { startHttpServer } from '../utils/httpServer.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
@@ -28,51 +28,39 @@ export class ExtensionContextFactory implements BrowserContextFactory {
description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string;
private _relayPromise: Promise<CDPRelayServer> | undefined;
private _browserPromise: Promise<playwright.Browser> | undefined;
private _userDataDir?: string;
constructor(browserChannel: string) {
constructor(browserChannel: string, userDataDir: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
const browser = await this._browserPromise;
const browser = await this._obtainBrowser(clientInfo, abortSignal);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
this._browserPromise = undefined;
}
};
}
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
if (!this._relayPromise)
this._relayPromise = this._startRelay(abortSignal);
const relay = await this._relayPromise;
abortSignal.throwIfAborted();
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
browser.on('disconnected', () => {
this._browserPromise = undefined;
debugLogger('Browser disconnected');
});
return browser;
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
}
private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({});
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
if (abortSignal.aborted) {
httpServer.close();
throw new Error(abortSignal.reason);
}
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
if (abortSignal.aborted)
cdpRelayServer.stop();
else
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
return cdpRelayServer;
}
}

View File

@@ -1,31 +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 { 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');
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');
}

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 {

5
src/loopTools/DEPS.list Normal file
View File

@@ -0,0 +1,5 @@
[*]
../
../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.ToolResponse> {
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];

View File

@@ -18,10 +18,11 @@ import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../package.js';
import { packageJSON } from '../utils/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';
@@ -48,12 +49,13 @@ class LoopToolsServerBackend implements ServerBackend {
this._context = await Context.create(this._config);
}
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
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 || {});
return await tool.handle(this._context!, parsedArguments);
}

View File

@@ -17,11 +17,12 @@
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: mcpServer.ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.ToolResponse>;
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {

2
src/mcp/DEPS.list Normal file
View File

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

131
src/mcp/proxyBackend.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* 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,44 +14,26 @@
* limitations under the License.
*/
import { z } from 'zod';
import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ManualPromise } from '../manualPromise.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../utils/manualPromise.js';
import { logUnhandledError } from '../utils/log.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Tool, CallToolResult, CallToolRequest, Root } 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';
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>;
const serverDebug = debug('pw:mcp:server');
export type ClientVersion = { name: string, version: string };
export interface ServerBackend {
name: string;
version: string;
initialize?(server: Server): Promise<void>;
tools(): ToolSchema<any>[];
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(): void;
}
@@ -71,23 +53,16 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
}
});
const tools = backend.tools();
server.setRequestHandler(ListToolsRequestSchema, async () => {
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,
},
})) };
serverDebug('listTools');
await initializedPromise;
const tools = await backend.listTools();
return { tools };
});
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
serverDebug('callTool', request);
await initializedPromise;
if (runHeartbeat && !heartbeatRunning) {
@@ -95,22 +70,29 @@ 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(tool, tool.inputSchema.parse(request.params.arguments || {}));
return await backend.callTool(request.params.name, request.params.arguments || {});
} catch (error) {
return errorResult(String(error));
return {
content: [{ type: 'text', text: '### Result\n' + String(error) }],
isError: true,
};
}
});
addServerListener(server, 'initialized', () => {
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
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, 'close', () => backend.serverClosed?.());
return server;

42
src/mcp/tool.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* 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 '../httpServer.js';
import { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
import * as mcpServer from './server.js';
import type { ServerBackendFactory } from './server.js';

View File

@@ -15,17 +15,21 @@
*/
import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import * as mcpServer from './mcp/server.js';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './package.js';
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
import { packageJSON } from './utils/package.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)
@@ -39,6 +43,7 @@ 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')
@@ -46,7 +51,6 @@ program
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--permissions <permissions>', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList)
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
@@ -56,7 +60,6 @@ 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())
@@ -71,28 +74,22 @@ program
const config = await resolveCLIConfig(options);
if (options.extension) {
await runWithExtension(config);
const contextFactory = createExtensionContextFactory(config);
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
await mcpTransport.start(serverBackendFactory, config.server);
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
const browserContextFactory = contextFactory(config);
const factories: FactoryList = [browserContextFactory];
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
if (options.connectTool)
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);
}
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)));
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
});
function setupExitWatchdog() {
@@ -111,4 +108,19 @@ 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 './log.js';
import { logUnhandledError } from './utils/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 './log.js';
import { ManualPromise } from './manualPromise.js';
import { logUnhandledError } from './utils/log.js';
import { ManualPromise } from './utils/manualPromise.js';
import { ModalState } from './tools/tool.js';
import type { Context } from './context.js';

2
src/tools/DEPS.list Normal file
View File

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

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js';
import * as javascript from '../utils/codegen.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 '../javascript.js';
import * as javascript from '../utils/codegen.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 '../javascript.js';
import * as javascript from '../utils/codegen.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 '../javascript.js';
import * as javascript from '../utils/codegen.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
@@ -75,10 +75,15 @@ const screenshot = defineTabTool({
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
response.addImage({
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
data: buffer
});
// https://github.com/microsoft/playwright-mcp/issues/817
// Never return large images to LLM, saving them to the file system is enough.
if (!params.fullPage) {
response.addImage({
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
data: buffer
});
}
}
});

View File

@@ -17,7 +17,7 @@
import { z } from 'zod';
import { defineTabTool, defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import * as javascript from '../utils/codegen.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/server.js';
import type { ToolSchema } from '../mcp/tool.js';
export type FileUploadModalState = {
type: 'fileChooser';

View File

@@ -36,10 +36,8 @@ 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) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
response.addCode(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
}
@@ -48,12 +46,12 @@ const wait = defineTool({
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
response.addCode(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
response.addCode(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
await locator.waitFor({ state: 'visible' });
}

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

@@ -17,8 +17,6 @@
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')
@@ -32,6 +30,10 @@ export function cacheDir() {
return path.join(cacheDirectory, 'ms-playwright');
}
export async function userDataDir(browserConfig: FullConfig['browser']) {
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
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

@@ -16,14 +16,10 @@
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

@@ -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

@@ -46,6 +46,40 @@ 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,14 +40,18 @@ 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: (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
}) => Promise<{ client: Client, stderr: () => string }>;
startClient: StartClient;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -68,7 +72,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
const clients: Client[] = [];
await use(async options => {
const args: string[] = [];
@@ -86,9 +90,11 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
args.push(`--config=${path.relative(configDir, configFile)}`);
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
const 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,
};
@@ -101,12 +107,13 @@ 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 client?.close();
await Promise.all(clients.map(client => client.close()));
},
wsEndpoint: async ({ }, use) => {
@@ -123,6 +130,8 @@ 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,
@@ -191,7 +200,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
cwd: path.dirname(test.info().config.configFile!),
stderr: 'pipe',
env: {
...process.env,

View File

@@ -1,170 +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 { test, expect } from './fixtures.js';
test('clipboard permissions support via CLI argument', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Test Page</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard write operation without user permission prompt
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("test clipboard content")'
}
});
expect(writeResponse.isError).toBeFalsy();
// Test clipboard read operation without user permission prompt
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"test clipboard content"'
});
});
test('clipboard permissions support via config file contextOptions', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Config Test Page</h1>
</body>
</html>
`, 'text/html');
const config = {
browser: {
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write']
}
}
};
const { client } = await startClient({ config });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted via config
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard operations work with config file
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("config test content")'
}
});
expect(writeResponse.isError).toBeFalsy();
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"config test content"'
});
});
test('multiple permissions can be granted', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Multiple Permissions Test</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write,geolocation'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Test that multiple permissions can be granted
const clipboardPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(clipboardPermissionResponse.isError).toBeFalsy();
expect(clipboardPermissionResponse).toHaveResponse({
result: '"granted"'
});
const geolocationPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "geolocation" }).then(result => result.state)'
}
});
expect(geolocationPermissionResponse.isError).toBeFalsy();
expect(geolocationPermissionResponse).toHaveResponse({
result: '"granted"'
});
});

View File

@@ -19,12 +19,13 @@ import path from 'path';
import { pathToFileURL } from 'url';
import { test, expect } from './fixtures.js';
import { createHash } from '../src/utils.js';
import { createHash } from '../src/utils/guid.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',
roots: [
{
name: 'test',
@@ -43,11 +44,11 @@ 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, mcpMode }, testInfo) => {
test('check that trace is saved in workspace', async ({ startClient, server }, testInfo) => {
const rootPath = testInfo.outputPath('workspace');
const { client } = await startClient({
args: ['--save-trace'],
clientName: 'My client',
roots: [
{
name: 'workspace',
@@ -66,3 +67,13 @@ test('check that trace is saved in workspace', async ({ startClient, server, mcp
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

@@ -264,12 +264,7 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
{
text: expect.stringContaining('fullPage: true'),
type: 'text',
},
{
data: expect.any(String),
mimeType: 'image/png',
type: 'image',
},
}
],
});
});

View File

@@ -47,6 +47,7 @@ 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({
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
@@ -83,7 +84,24 @@ 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({
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
});
});
test('browser_wait_for(time)', async ({ client, server }) => {
server.setContent('/', `<body><div>Hello World</div></body>`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { time: 1 },
})).toHaveResponse({
code: `await new Promise(f => setTimeout(f, 1 * 1000));`,
});
});

179
utils/check-deps.js Normal file
View File

@@ -0,0 +1,179 @@
#!/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();
}

68
utils/set-version.js Normal file
View File

@@ -0,0 +1,68 @@
#!/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]);