107 Commits

Author SHA1 Message Date
Pavel Feldman
e8e2af40b7 chore: mark v0.0.36 (#972) 2025-08-31 11:26:29 -07:00
Pavel Feldman
b176111891 chore: roll Playwright to latest (#971) 2025-08-29 18:25:21 -07:00
Pavel Feldman
29d468dac7 chore: add verification tools section (#957) 2025-08-28 21:39:35 -07:00
colin b. erdman
51ab77e04e docs: cursor manual install instructions should use @playwright/mcp@latest (#962) 2025-08-28 15:37:16 -07:00
Sangjin Park
7fb8b0dc3a fix: browser_tabs select action with index 0 failing due to falsy check (#964) 2025-08-28 15:36:31 -07:00
Pavel Feldman
fc04de2be5 chore: publish canary daily (#958) 2025-08-27 07:02:03 -07:00
Pavel Feldman
11480fa8ce chore: add eval object test (#952) 2025-08-26 08:58:33 -07:00
Pavel Feldman
78298c3448 chore: introduce verification tools (#951) 2025-08-25 18:08:07 -07:00
Yury Semikhatsky
7774ad93ca chore(extension): support custom executablePath (#947)
Fixes https://github.com/microsoft/playwright-mcp/issues/941
2025-08-25 13:48:52 -07:00
Yury Semikhatsky
1a64a51812 chore: version extension-relay protocol (#939) 2025-08-25 11:48:23 -07:00
Pavel Feldman
22043cb3ef chore: mark v0.0.35 (#938) 2025-08-23 15:34:49 -07:00
Yury Semikhatsky
0812df2f5e chore(extension): do not complain about old extension version (#937) 2025-08-22 17:53:30 -07:00
Pavel Feldman
3d1a60b7f3 chore: introduce form filling tool (#935) 2025-08-22 17:06:24 -07:00
Pavel Feldman
86eba2245a chore: remove navigate_forward due to low usage counts (#934) 2025-08-22 14:02:33 -07:00
Pavel Feldman
2521a67b2f chore: merge tabs tool into one (#933) 2025-08-22 13:46:52 -07:00
Pavel Feldman
fb28e99fa4 chore: mdb stub (#912) 2025-08-22 13:08:29 -07:00
Yury Semikhatsky
64af5f8763 chore(extension): do not show tab selector for browser_navigate (#923) 2025-08-22 10:02:09 -07:00
Yury Semikhatsky
fb65bc7559 chore: show download link on the status mismatch page (#928)
<img width="610" height="108" alt="image"
src="https://github.com/user-attachments/assets/bc1f7534-f282-44f0-bb58-e385b2bf82af"
/>
2025-08-22 09:10:53 -07:00
ach
94ca0763d5 docs: add instructions for installation in openai codex (#927) 2025-08-21 16:19:26 -07:00
Simon Knott
2ae7800ac1 chore(vscode): add vscode mcp factory (#868) 2025-08-21 14:28:35 +02:00
Yury Semikhatsky
f6862a39c3 chore: check version in page, link to instructions (#918) 2025-08-19 17:39:58 -07:00
Yury Semikhatsky
e664e0460c chore: check extension version on connect (#907) 2025-08-18 13:28:13 -07:00
Pavel Feldman
865eac2fee chore: do not wrap mcp in proxy by default, drive-by deps fix (#909) 2025-08-16 19:39:49 -07:00
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
Pavel Feldman
3c6eac9b21 chore: follow up with win test fix (#818) 2025-08-01 18:19:03 -07:00
Yury Semikhatsky
41a44f7abc chore(extension): terminate connection on debugger detach (#816) 2025-08-01 17:56:47 -07:00
Yury Semikhatsky
372395666a chore: allow to switch between browser connection methods (#815) 2025-08-01 17:34:28 -07:00
Pavel Feldman
a60d7b8cd1 chore: slice profile dirs by root in vscode (#814) 2025-08-01 16:59:59 -07:00
Pavel Feldman
ffe0117456 chore: refactor initialize (#812) 2025-08-01 13:06:36 -07:00
Yury Semikhatsky
7c07cc86eb chore(extension): bind relay lifetime to browser context (#804) 2025-07-31 22:25:40 -07:00
Pavel Feldman
3787439fc1 chore: serialize session entries for tool calls and user actions (#803) 2025-07-31 15:16:56 -07:00
Max Schmitt
2a86ac74e3 chore: use pngs by default for screenshots (#797)
1. Use PNG by default.
1. Increase JPG quality from `50` -> `90`.
2025-07-31 11:03:19 +02:00
Pavel Feldman
6dd44923da chore: make tab snapshot structured to mimic it in recorder (#799) 2025-07-30 20:57:34 -07:00
Pavel Feldman
f600234897 chore: record user actions in the session log (#798) 2025-07-30 18:26:13 -07:00
Pavel Feldman
4df162aff5 chore: parse response in tests (#796) 2025-07-30 12:47:22 -07:00
Yury Semikhatsky
65d99fe595 chore(extension): do not show chrome: tabs (#780) 2025-07-29 10:11:44 -07:00
Yury Semikhatsky
903c857f19 chore(extension): use separate package.json (#778) 2025-07-28 17:16:08 -07:00
Yury Semikhatsky
9b5f97b076 chore(extension): use react for connect dialog (#777) 2025-07-28 15:23:33 -07:00
Pavel Feldman
04988d8fac chore: mark v0.0.32 (#768) 2025-07-25 16:40:31 -07:00
Pavel Feldman
2bf57e22c6 chore: do not snapshot on fill (#767) 2025-07-25 15:54:18 -07:00
Yury Semikhatsky
dbf113d5e4 chore(extension): reject second http connection (#766) 2025-07-25 14:46:48 -07:00
Pavel Feldman
6710a78641 Revert "chore: recommend sse by default" (#765)
Reverts microsoft/playwright-mcp#758

Sounds like the stock streamable implementation is to spec, so we can
keep it.
2025-07-25 12:18:02 -07:00
Pavel Feldman
a9b9fb85da chore: ping client and disconnect on connection termination (#764) 2025-07-25 12:17:51 -07:00
Yury Semikhatsky
26a2a6fc83 chore: recommend sse by default (#758) 2025-07-25 09:51:01 -07:00
Pavel Feldman
e934d5e23e chore: retain the source code from the underlying tools (#756) 2025-07-24 17:08:35 -07:00
Pavel Feldman
ecfa10448b chore: extract loop tools into a separate folder (#755) 2025-07-24 16:22:03 -07:00
Yury Semikhatsky
e153ac3b7c chore(extension): exit gracefully when waiting for extension connection (#754) 2025-07-24 16:02:02 -07:00
Pavel Feldman
e0fb748ccc chore: wire one tool in-process (#753) 2025-07-24 15:25:32 -07:00
Pavel Feldman
c63b7823e1 chore: extract pure mcp server helpers (#751) 2025-07-24 12:57:01 -07:00
Yury Semikhatsky
bd34e9d7e9 chore(extension): page selector for MCP (#750) 2025-07-24 12:01:35 -07:00
Yury Semikhatsky
c72d0320f4 chore(extension): use free port (#735) 2025-07-24 10:25:13 -07:00
Pavel Feldman
da8a244f33 chore: one tool experiment (#746) 2025-07-24 10:09:01 -07:00
Pavel Feldman
31a4fb3d07 chore: unify loops (#745) 2025-07-23 17:42:53 -07:00
Yury Semikhatsky
bc120baa78 chore: do not double close connection (#744) 2025-07-23 17:41:15 -07:00
Pavel Feldman
2c5eac89a8 chore: add eval script (#743) 2025-07-23 10:31:37 -07:00
christian-lms
288f1b863b docs: Add LM Studio installation instructions (#688) 2025-07-23 08:22:13 -07:00
Yury Semikhatsky
53e3e37991 chore(extension): terminate all connections when tab closes (#741) 2025-07-22 22:23:00 -07:00
Pavel Feldman
b1a0f775cf chore: save session log (#740) 2025-07-22 20:06:03 -07:00
Pavel Feldman
6320b08173 chore: follow up on tab snapshot capture (#739) 2025-07-22 17:43:42 -07:00
Pavel Feldman
601a74305c chore: introduce response type (#738) 2025-07-22 16:36:21 -07:00
Yury Semikhatsky
c2b98dc70b chore(extension): handle root session id in the relay (#737) 2025-07-22 13:49:39 -07:00
Yury Semikhatsky
70862ce456 chore(extension): propagate errors to the client (#736) 2025-07-22 13:13:27 -07:00
Pavel Feldman
468c84eb8f chore: move state to tab, do not cache snapshot (#730) 2025-07-22 07:53:33 -07:00
Yury Semikhatsky
cfcca40b90 chore(extension): find installed chrome (#728) 2025-07-21 17:57:38 -07:00
Pavel Feldman
f1826b96b6 chore: align lint w/ playwright (#729) 2025-07-21 17:07:13 -07:00
Copilot
eeeab4f042 fix: browser_take_screenshot to not require snapshot unless element is specified (#725) 2025-07-21 10:52:06 -07:00
132 changed files with 10071 additions and 2432 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

48
.github/workflows/publish-canary.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Publish Canary
on:
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
jobs:
publish-canary:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Needed for npm provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
registry-url: https://registry.npmjs.org/
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Get current version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Set canary version
id: canary-version
run: echo "version=${{ steps.version.outputs.version }}-alpha-${{ steps.date.outputs.date }}" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
npm version ${{ steps.canary-version.outputs.version }} --no-git-tag-version
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run lint
- run: npm run ctest
- name: Publish to npm with next tag
run: npm publish --tag next --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Reset package.json version
run: git checkout -- package.json

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

4
.gitignore vendored
View File

@@ -1,8 +1,10 @@
lib/
dist/
node_modules/
test-results/
playwright-report/
.vscode/mcp.json
.idea
.DS_Store
.env
sessions/

166
README.md
View File

@@ -56,16 +56,31 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
</details>
<details>
<summary>Codex</summary>
Create or edit the configuration file `~/.codex/config.toml` and add:
```toml
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest"]
```
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
</details>
<details>
<summary>Cursor</summary>
#### 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:
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp@latest`. You can also verify config or add command like arguments via clicking `Edit`.
</details>
@@ -88,6 +103,41 @@ Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
</details>
<details>
<summary>LM Studio</summary>
#### Click the button to install:
[![Add MCP Server playwright to LM Studio](https://files.lmstudio.ai/deeplink/mcp-install-light.svg)](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
#### Or install manually:
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>
@@ -146,6 +196,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.
@@ -162,6 +215,8 @@ Playwright MCP server supports following arguments. They can be provided in the
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-session Whether to save the Playwright MCP session into
the output directory.
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--storage-state <path> path to the storage state file for isolated
@@ -177,7 +232,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**
@@ -217,6 +272,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
@@ -435,6 +494,15 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_fill_form**
- Title: Fill form
- Description: Fill multiple form fields
- Parameters:
- `fields` (array): Fields to fill in
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
@@ -472,14 +540,6 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Title: Go forward
- Description: Go forward to the next page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_network_requests**
- Title: List network requests
- Description: Returns all network requests since loading the page
@@ -530,7 +590,7 @@ http.createServer(async (req, res) => {
- Title: Take a screenshot
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `type` (string, optional): Image format for the screenshot. Default is png.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
@@ -568,39 +628,14 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Title: Close a tab
- Description: Close a tab
- **browser_tabs**
- Title: Manage tabs
- Description: List, create, close, or select a browser tab.
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- `action` (string): Operation to perform
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Title: List tabs
- Description: List browser tabs
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
</details>
<details>
@@ -670,5 +705,52 @@ http.createServer(async (req, res) => {
</details>
<details>
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_element_visible**
- Title: Verify element visible
- Description: Verify element is visible on the page
- Parameters:
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_list_visible**
- Title: Verify list visible
- Description: Verify list is visible on the page
- Parameters:
- `element` (string): Human-readable list description
- `ref` (string): Exact target element reference that points to the list
- `items` (array): Items to verify
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_text_visible**
- Title: Verify text visible
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
- Parameters:
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_value**
- Title: Verify value
- Description: Verify element value
- Parameters:
- `type` (string): Type of the element
- `element` (string): Human-readable element description
- `ref` (string): Exact target element reference that points to the element
- `value` (string): Value to verify. For checkbox, use "true" or "false".
- Read-only: **true**
</details>
<!--- End of tools generated section -->

7
config.d.ts vendored
View File

@@ -16,7 +16,7 @@
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
export type Config = {
/**
@@ -85,6 +85,11 @@ export type Config = {
*/
capabilities?: ToolCapability[];
/**
* Whether to save the Playwright session into the output directory.
*/
saveSession?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/

View File

@@ -192,6 +192,31 @@ const languageOptions = {
}
};
const importOrderRules = {
"import/order": [
2,
{
groups: [
"builtin",
"external",
"internal",
["parent", "sibling"],
"index",
"type",
],
},
],
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
};
const noFloatingPromisesRules = {
"@typescript-eslint/no-floating-promises": "error",
};
const noBooleanCompareRules = {
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
};
export default [
{
ignores: ["**/*.js"],
@@ -200,6 +225,11 @@ export default [
files: ["**/*.ts", "**/*.tsx"],
plugins,
languageOptions,
rules: baseRules,
rules: {
...baseRules,
...importOrderRules,
...noFloatingPromisesRules,
...noBooleanCompareRules,
},
},
];

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,26 +1,22 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "1.0.0",
"version": "0.0.36",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"permissions": [
"debugger",
"activeTab",
"tabs",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "lib/background.js",
"type": "module"
},
"action": {
"default_title": "Playwright MCP Bridge",
"default_icon": {
@@ -30,7 +26,6 @@
"128": "icons/icon-128.png"
}
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",

1884
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
extension/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.36",
"description": "Playwright MCP Browser Extension",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"test": "playwright test",
"clean": "rm -rf dist"
},
"devDependencies": {
"@types/chrome": "^0.0.315",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"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,38 +19,69 @@ import { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
} | {
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':
const tabId = sender.tab?.id;
if (!tabId) {
sendResponse({ success: false, error: 'No tab id' });
return true;
}
this._connectTab(tabId, message.mcpRelayUrl!).then(
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
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':
const tabId = message.tabId || sender.tab?.id!;
const windowId = message.windowId || sender.tab?.windowId!;
this._connectTab(sender.tab!.id!, tabId, 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, 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();
@@ -59,23 +90,51 @@ class TabShareExtension {
});
const connection = new RelayConnection(socket);
connection.setConnectedTabId(tabId);
const connectionClosed = (m: string) => {
debugLog(m);
if (this._activeConnection === connection) {
this._activeConnection = undefined;
void this._setConnectedTabId(null);
}
connection.onclose = () => {
debugLog('Connection closed');
this._pendingTabSelection.delete(selectorTabId);
// TODO: show error in the selector tab?
};
socket.onclose = () => connectionClosed('WebSocket closed');
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
this._activeConnection = connection;
await this._setConnectedTabId(tabId);
debugLog(`Tab ${tabId} connected successfully`);
this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`);
} catch (error: any) {
debugLog(`Failed to connect tab ${tabId}:`, error.message);
const message = `Failed to connect to MCP relay: ${error.message}`;
debugLog(message);
throw new Error(message);
}
}
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);
};
await Promise.all([
this._setConnectedTabId(tabId),
chrome.tabs.update(tabId, { active: true }),
chrome.windows.update(windowId, { focused: true }),
]);
debugLog(`Connected to MCP bridge`);
} catch (error: any) {
await this._setConnectedTabId(null);
debugLog(`Failed to connect tab ${tabId}:`, error.message);
throw error;
}
}
@@ -84,25 +143,79 @@ 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> {
if (this._connectedTabId === tabId)
this._activeConnection!.setConnectedTabId(null);
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');
this._activeConnection = undefined;
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);
}
}

View File

@@ -1,70 +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.
*/
document.addEventListener('DOMContentLoaded', async () => {
const statusContainer = document.getElementById('status-container') as HTMLElement;
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
function showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
statusContainer.replaceChildren(div);
}
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
return;
}
let clientInfo = 'unknown';
try {
const client = JSON.parse(params.get('client') || '{}');
clientInfo = `${client.name}/${client.version}`;
} catch (e) {
showStatus('error', 'Failed to parse client version.');
return;
}
showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl
});
if (response?.success)
showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
});

View File

@@ -37,15 +37,22 @@ type ProtocolResponse = {
};
export class RelayConnection {
private _debuggee: chrome.debugger.Debuggee = {};
private _rootSessionId = '';
private _debuggee: chrome.debugger.Debuggee;
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;
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,31 +60,34 @@ export class RelayConnection {
chrome.debugger.onDetach.addListener(this._detachListener);
}
setConnectedTabId(tabId: number | null): void {
if (!tabId) {
this._debuggee = { };
this._rootSessionId = '';
return;
}
// Either setTabId or close is called after creating the connection.
setTabId(tabId: number): void {
this._debuggee = { tabId };
this._rootSessionId = `pw-tab-${tabId}`;
this._tabPromiseResolve();
}
close(message?: string): void {
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 || 'Connection closed');
}
private async _detachDebugger(): Promise<void> {
await chrome.debugger.detach(this._debuggee);
chrome.debugger.detach(this._debuggee).catch(() => {});
this.onclose?.();
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId)
return;
debugLog('Forwarding CDP event:', method, params);
const sessionId = source.sessionId || this._rootSessionId;
const sessionId = source.sessionId;
this._sendMessage({
method: 'forwardCDPEvent',
params: {
@@ -91,13 +101,8 @@ export class RelayConnection {
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId)
return;
this._sendMessage({
method: 'detachedFromTab',
params: {
tabId: this._debuggee.tabId,
reason,
},
});
this.close(`Debugger detached: ${reason}`);
this._debuggee = { };
}
private _onMessage(event: MessageEvent): void {
@@ -130,28 +135,24 @@ 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');
return {
sessionId: this._rootSessionId,
targetInfo: result?.targetInfo,
};
}
if (message.method === 'detachFromTab') {
debugLog('Detaching debugger from tab:', this._debuggee);
return await this._detachDebugger();
}
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);
const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee };
// Pass session id, unless it's the root session.
if (sessionId && sessionId !== this._rootSessionId)
debuggerSession.sessionId = sessionId;
const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee,
sessionId,
};
// Forward CDP command to chrome.debugger
return await chrome.debugger.sendCommand(
debuggerSession,
@@ -171,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

@@ -0,0 +1,206 @@
/*
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.
*/
body {
margin: 0;
padding: 0;
}
/* Base styles */
.app-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 16px;
min-height: 100vh;
font-size: 14px;
}
.content-wrapper {
max-width: 600px;
margin: 0 auto;
}
/* Status Banner */
.status-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
}
.status-banner {
padding: 12px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.status-banner.connected {
color: #1f2328;
}
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
}
.status-banner.error {
color: #1f2328;
}
.status-banner.error::before {
content: "\274C";
margin-right: 8px;
}
/* Buttons */
.button-container {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin-right: 8px;
min-width: 90px;
}
.button.primary {
background-color: #f8f9fa;
color: #3c4043;
border: 1px solid #dadce0;
}
.button.primary:hover {
background-color: #f1f3f4;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
}
.button.default {
background-color: #f6f8fa;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
}
.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 {
padding-left: 12px;
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #656d76;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
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 {
background-color: #f6f8fa;
}
.tab-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab-radio {
margin-right: 12px;
flex-shrink: 0;
}
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-content {
flex: 1;
min-width: 0;
}
.tab-title {
font-weight: 500;
color: #1f2328;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 12px;
color: #656d76;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Link-style button */
.link-button {
background: none;
border: none;
color: #0066cc;
text-decoration: underline;
cursor: pointer;
padding: 0;
font: inherit;
}

View File

@@ -17,16 +17,13 @@
<html>
<head>
<title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<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 class="header">
<h3>Playwright MCP extension</h3>
</div>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<script src="lib/connect.js"></script>
<div id="root"></div>
<script type="module" src="connect.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,233 @@
/**
* 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, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
type Status =
| { type: 'connecting'; message: string }
| { type: 'connected'; message: string }
| { type: 'error'; message: string }
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
const SUPPORTED_PROTOCOL_VERSION = 1;
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [status, setStatus] = useState<Status | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
const [newTab, setNewTab] = useState<boolean>(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
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;
}
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
const extensionVersion = chrome.runtime.getManifest().version;
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
extensionVersion,
}
});
return;
}
void connectToMCPRelay(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
if (params.get('newTab') === 'true') {
setNewTab(true);
setShowTabList(false);
} else {
void loadTabs();
}
}, []);
const handleReject = useCallback((message: string) => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message });
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
handleReject(response.error);
}, [handleReject]);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
setTabs(response.tabs);
else
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}, []);
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
setShowButtons(false);
setShowTabList(false);
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToTab',
mcpRelayUrl,
tabId: tab?.id,
windowId: tab?.windowId,
});
if (response?.success) {
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
} else {
setStatus({
type: 'error',
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
});
}
} catch (e) {
setStatus({
type: 'error',
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [clientInfo, mcpRelayUrl]);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject('Connection timed out.');
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, [handleReject]);
return (
<div className='app-container'>
<div className='content-wrapper'>
{status && (
<div className='status-container'>
<StatusBanner status={status} />
{showButtons && (
<div className='button-container'>
{newTab ? (
<>
<Button variant='primary' onClick={() => handleConnectToTab()}>
Allow
</Button>
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
</>
) : (
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
)}
</div>
)}
</div>
)}
{showTabList && (
<div>
<div className='tab-section-title'>
Select page to expose to MCP server:
</div>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
button={
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
Connect
</Button>
}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
return (
<div>
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return (
<div className={`status-banner ${status.type}`}>
{'versionMismatch' in status ? (
<VersionMismatchError
extensionVersion={status.versionMismatch.extensionVersion}
/>
) : (
status.message
)}
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}

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,4 @@
// Help VSCode to find right tsconfig file.
{
"extends": "../../tsconfig.ui.json"
}

View File

@@ -0,0 +1,307 @@
/**
* 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 fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures.js';
type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
};
type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(fileURLToPath(new URL('../dist', import.meta.url)));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, 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');
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();
},
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
overrideProtocolVersion: async ({}, use) => {
await use((version: number) => {
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
});
process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined;
}
});
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;
}
const testWithOldExtensionVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
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;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).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, useShortConnectionTimeout }) => {
useShortConnectionTimeout(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;
});
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
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;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
useShortConnectionTimeout(500);
overrideProtocolVersion(1000);
// Prelaunch the browser, so that it is properly closed after the test.
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 confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
});
}
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(1000);
const executablePath = test.info().outputPath('echo.sh');
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
launchOptions: {
executablePath,
},
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
timeout: 1000,
});
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
});

View File

@@ -6,10 +6,16 @@
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"outDir": "./dist/lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": [
"src",
],
"exclude": [
"src/ui",
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"noEmit": true,
},
"include": [
"src/ui",
],
}

54
extension/vite.config.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* 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 { 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(),
viteStaticCopy({
targets: [
{
src: '../../icons/*',
dest: 'icons'
},
{
src: '../../manifest.json',
dest: '.'
}
]
})
],
root: resolve(__dirname, 'src/ui'),
build: {
outDir: resolve(__dirname, 'dist/'),
emptyOutDir: false,
minify: false,
rollupOptions: {
input: ['src/ui/connect.html', 'src/ui/status.html'],
output: {
manualChunks: undefined,
entryFileNames: 'lib/ui/[name].js',
chunkFileNames: 'lib/ui/[name].js',
assetFileNames: 'lib/ui/[name].[ext]'
}
}
}
});

7
index.d.ts vendored
View File

@@ -19,10 +19,5 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config.js';
import type { BrowserContext } from 'playwright';
export type Connection = {
server: Server;
close(): Promise<void>;
};
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
export {};

664
package-lock.json generated
View File

@@ -1,47 +1,452 @@
{
"name": "@playwright/mcp",
"version": "0.0.31",
"version": "0.0.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.31",
"version": "0.0.36",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"playwright": "1.56.0-alpha-1756505518000",
"playwright-core": "1.56.0-alpha-1756505518000",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
"mcp-server-playwright": "cli.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@playwright/test": "1.56.0-alpha-1756505518000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.57.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.57.0.tgz",
"integrity": "sha512-z5LMy0MWu0+w2hflUgj4RlJr1R+0BxKXL7ldXTO8FasU8fu599STghO+QKwId2dAD0d464aHtU+ChWuRHw4FNw==",
"dev": true,
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
@@ -72,9 +477,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -87,9 +492,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz",
"integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -97,9 +502,9 @@
}
},
"node_modules/@eslint/core": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -110,9 +515,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz",
"integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -134,13 +539,16 @@
}
},
"node_modules/@eslint/js": {
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz",
"integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz",
"integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -154,13 +562,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz",
"integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.12.0",
"@eslint/core": "^0.15.1",
"levn": "^0.4.1"
},
"engines": {
@@ -295,13 +703,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==",
"version": "1.56.0-alpha-1756505518000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0-alpha-1756505518000.tgz",
"integrity": "sha512-BLTEYook8jXHONKqmOgcG/q6SLZIyyJClgc+YJGg/G3w3dg1pE2dtdO/gECFnM8FX9UY4DOa9c6eJVU1feHk/w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0-alpha-1752701791000"
"playwright": "1.56.0-alpha-1756505518000"
},
"bin": {
"playwright": "cli.js"
@@ -363,17 +771,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/chrome": {
"version": "0.0.315",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz",
"integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -385,33 +782,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@@ -595,9 +968,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -689,9 +1062,9 @@
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -924,9 +1297,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1269,6 +1642,18 @@
"node": ">=0.10.0"
}
},
"node_modules/dotenv": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
"integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1441,6 +1826,45 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -1461,20 +1885,20 @@
}
},
"node_modules/eslint": {
"version": "9.22.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz",
"integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz",
"integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.2",
"@eslint/config-helpers": "^0.1.0",
"@eslint/core": "^0.12.0",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "9.22.0",
"@eslint/plugin-kit": "^0.2.7",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.15.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.31.0",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -1485,9 +1909,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -1641,9 +2065,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -1671,9 +2095,9 @@
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -1684,15 +2108,15 @@
}
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1702,9 +2126,9 @@
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -3151,6 +3575,28 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.10.2.tgz",
"integrity": "sha512-n+vi74LzHtvlKcDPn9aApgELGiu5CwhaLG40zxLTlFQdoSJCLACORIPC2uVQ3JEYAbqapM+XyRKFy2Thej7bIw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3299,12 +3745,12 @@
}
},
"node_modules/playwright": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==",
"version": "1.56.0-alpha-1756505518000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-1756505518000.tgz",
"integrity": "sha512-aChIG1Hly/pxzVdwOMArmOMNz4Wo2VyWBxLaMvLJaGWRPPB9+Sl1N8PRm6oH1CbbpFGpPvIeXl83LomkibShRA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0-alpha-1752701791000"
"playwright-core": "1.56.0-alpha-1756505518000"
},
"bin": {
"playwright": "cli.js"
@@ -3317,9 +3763,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==",
"version": "1.56.0-alpha-1756505518000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-1756505518000.tgz",
"integrity": "sha512-qeM+G9jA+PkA3dSYZmqKrARnIgd53B+7Lm3e52wH3rPyZJ+IBhRvhW369iN8tVJunbmsr7fkU1+05K2c7q9y0g==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.31",
"version": "0.0.36",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -17,17 +17,17 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"build:extension": "tsc --project extension",
"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",
"watch:extension": "tsc --watch --project extension",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib extension/lib",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
"exports": {
@@ -41,27 +41,31 @@
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"playwright": "1.56.0-alpha-1756505518000",
"playwright-core": "1.56.0-alpha-1756505518000",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@playwright/test": "1.56.0-alpha-1756505518000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2",
"typescript": "^5.8.2"
},
"bin": {

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]
***

172
src/actions.d.ts vendored Normal file
View File

@@ -0,0 +1,172 @@
/**
* 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.
*/
type Point = { x: number, y: number };
export type ActionName =
'check' |
'click' |
'closePage' |
'fill' |
'navigate' |
'openPage' |
'press' |
'select' |
'uncheck' |
'setInputFiles' |
'assertText' |
'assertValue' |
'assertChecked' |
'assertVisible' |
'assertSnapshot';
export type ActionBase = {
name: ActionName,
signals: Signal[],
ariaSnapshot?: string,
};
export type ActionWithSelector = ActionBase & {
selector: string,
ref?: string,
};
export type ClickAction = ActionWithSelector & {
name: 'click',
button: 'left' | 'middle' | 'right',
modifiers: number,
clickCount: number,
position?: Point,
};
export type CheckAction = ActionWithSelector & {
name: 'check',
};
export type UncheckAction = ActionWithSelector & {
name: 'uncheck',
};
export type FillAction = ActionWithSelector & {
name: 'fill',
text: string,
};
export type NavigateAction = ActionBase & {
name: 'navigate',
url: string,
};
export type OpenPageAction = ActionBase & {
name: 'openPage',
url: string,
};
export type ClosesPageAction = ActionBase & {
name: 'closePage',
};
export type PressAction = ActionWithSelector & {
name: 'press',
key: string,
modifiers: number,
};
export type SelectAction = ActionWithSelector & {
name: 'select',
options: string[],
};
export type SetInputFilesAction = ActionWithSelector & {
name: 'setInputFiles',
files: string[],
};
export type AssertTextAction = ActionWithSelector & {
name: 'assertText',
text: string,
substring: boolean,
};
export type AssertValueAction = ActionWithSelector & {
name: 'assertValue',
value: string,
};
export type AssertCheckedAction = ActionWithSelector & {
name: 'assertChecked',
checked: boolean,
};
export type AssertVisibleAction = ActionWithSelector & {
name: 'assertVisible',
};
export type AssertSnapshotAction = ActionWithSelector & {
name: 'assertSnapshot',
ariaSnapshot: string,
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
// Signals.
export type BaseSignal = {
};
export type NavigationSignal = BaseSignal & {
name: 'navigation',
url: string,
};
export type PopupSignal = BaseSignal & {
name: 'popup',
popupAlias: string,
};
export type DownloadSignal = BaseSignal & {
name: 'download',
downloadAlias: string,
};
export type DialogSignal = BaseSignal & {
name: 'dialog',
dialogAlias: string,
};
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
export type FrameDescription = {
pageGuid: string;
pageAlias: string;
framePath: string[];
};
export type ActionInContext = {
frame: FrameDescription;
description?: string;
action: Action;
startTime: number;
endTime?: number;
};
export type SignalInContext = {
frame: FrameDescription;
signal: Signal;
timestamp: number;
};

View File

@@ -14,46 +14,52 @@
* limitations under the License.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'node:os';
import fs from 'fs';
import net from 'net';
import path from 'path';
import * as playwright from 'playwright';
import { logUnhandledError, testDebug } from './log.js';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './utils/log.js';
import { createHash } from './utils/guid.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
if (browserConfig.remoteEndpoint)
return new RemoteContextFactory(browserConfig);
if (browserConfig.cdpEndpoint)
return new CdpContextFactory(browserConfig);
if (browserConfig.isolated)
return new IsolatedContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig);
export function contextFactory(config: FullConfig): BrowserContextFactory {
if (config.browser.remoteEndpoint)
return new RemoteContextFactory(config);
if (config.browser.cdpEndpoint)
return new CdpContextFactory(config);
if (config.browser.isolated)
return new IsolatedContextFactory(config);
return new PersistentContextFactory(config);
}
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
export interface BrowserContextFactory {
createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
readonly config: FullConfig;
private _logName: string;
protected _browserPromise: Promise<playwright.Browser> | undefined;
readonly name: string;
constructor(name: string, browserConfig: FullConfig['browser']) {
this.name = name;
this.browserConfig = browserConfig;
constructor(name: string, config: FullConfig) {
this._logName = name;
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();
testDebug(`obtain browser (${this._logName})`);
this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
@@ -64,13 +70,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(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser();
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this._logName})`);
const browser = await this._obtainBrowser(clientInfo);
const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
}
@@ -80,27 +86,28 @@ class BaseContextFactory implements BrowserContextFactory {
}
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
testDebug(`close browser context (${this.name})`);
testDebug(`close browser context (${this._logName})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
testDebug(`close browser (${this._logName})`);
await browser.close().catch(logUnhandledError);
}
}
}
class IsolatedContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('isolated', browserConfig);
constructor(config: FullConfig) {
super('isolated', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
await injectCdpPort(this.browserConfig);
const browserType = playwright[this.browserConfig.browserName];
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
...this.browserConfig.launchOptions,
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
@@ -111,35 +118,35 @@ class IsolatedContextFactory extends BaseContextFactory {
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(this.browserConfig.contextOptions);
return browser.newContext(this.config.browser.contextOptions);
}
}
class CdpContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('cdp', browserConfig);
constructor(config: FullConfig) {
super('cdp', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
}
}
class RemoteContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('remote', browserConfig);
constructor(config: FullConfig) {
super('remote', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const url = new URL(this.browserConfig.remoteEndpoint!);
url.searchParams.set('browser', this.browserConfig.browserName);
if (this.browserConfig.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
return playwright[this.browserConfig.browserName].connect(String(url));
const url = new URL(this.config.browser.remoteEndpoint!);
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
return playwright[this.config.browser.browserName].connect(String(url));
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
@@ -148,27 +155,32 @@ class RemoteContextFactory extends BaseContextFactory {
}
class PersistentContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
readonly config: FullConfig;
readonly name = 'persistent';
readonly description = 'Create a new persistent browser context';
private _userDataDirs = new Set<string>();
constructor(browserConfig: FullConfig['browser']) {
this.browserConfig = browserConfig;
constructor(config: FullConfig) {
this.config = config;
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.browserConfig);
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
const browserType = playwright[this.browserConfig.browserName];
const browserType = playwright[this.config.browser.browserName];
for (let i = 0; i < 5; i++) {
try {
const browserContext = await browserType.launchPersistentContext(userDataDir, {
...this.browserConfig.launchOptions,
...this.browserConfig.contextOptions,
tracesDir,
...this.config.browser.launchOptions,
...this.config.browser.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
@@ -196,17 +208,12 @@ class PersistentContextFactory implements BrowserContextFactory {
testDebug('close browser context complete (persistent)');
}
private async _createUserDataDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
private async _createUserDataDir(rootPath: string | undefined) {
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
@@ -217,7 +224,7 @@ async function injectCdpPort(browserConfig: FullConfig['browser']) {
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
}
async function findFreePort() {
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
@@ -227,3 +234,16 @@ async function findFreePort() {
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

@@ -0,0 +1,88 @@
/**
* 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 { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './utils/log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.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';
export class BrowserServerBackend implements ServerBackend {
private _tools: Tool[];
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
constructor(config: FullConfig, factory: BrowserContextFactory) {
this._config = config;
this._browserContextFactory = factory;
this._tools = filteredTools(config);
}
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
let rootPath: string | undefined;
if (roots.length > 0) {
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
}
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
this._context = new Context({
tools: this._tools,
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...clientVersion, rootPath },
});
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
const tool = this._tools.find(tool => tool.schema.name === name)!;
if (!tool)
throw new Error(`Tool "${name}" not found`);
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
const context = this._context!;
const response = new Response(context, name, parsedArguments);
context.setRunningTool(name);
try {
await tool.handle(context, parsedArguments, response);
await response.finish();
this._sessionLog?.logResponse(response);
} catch (error: any) {
response.addError(String(error));
} finally {
context.setRunningTool(undefined);
}
return response.serialize();
}
serverClosed() {
void this._context?.dispose().catch(logUnhandledError);
}
}

View File

@@ -18,10 +18,10 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './utils/fileUtils.js';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
export type CLIOptions = {
allowedOrigins?: string[];
@@ -43,6 +43,7 @@ export type CLIOptions = {
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveSession?: boolean;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
@@ -67,7 +68,7 @@ const defaultConfig: FullConfig = {
blockedOrigins: undefined,
},
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
saveTrace: false,
};
type BrowserUserConfig = NonNullable<Config['browser']>;
@@ -79,7 +80,7 @@ export type FullConfig = Config & {
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
saveTrace: boolean;
server: NonNullable<Config['server']>,
};
@@ -95,9 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
result = mergeConfig(result, configInFile);
result = mergeConfig(result, envOverrides);
result = mergeConfig(result, cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
@@ -190,6 +188,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
@@ -239,10 +238,14 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
}
}
export async function outputFile(config: FullConfig, name: string): Promise<string> {
await fs.promises.mkdir(config.outputDir, { recursive: true });
export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
const outputDir = config.outputDir
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
await fs.promises.mkdir(outputDir, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(config.outputDir, fileName);
return path.join(outputDir, fileName);
}
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {

View File

@@ -1,96 +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 { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js';
import { allTools } from './tools.js';
import { packageJSON } from './package.js';
import { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
const context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: {
tools: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
annotations: {
title: tool.schema.title,
readOnlyHint: tool.schema.type === 'readOnly',
destructiveHint: tool.schema.type === 'destructive',
openWorldHint: true,
},
})) as McpTool[],
};
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool)
return errorResult(`Tool "${request.params.name}" not found`);
const modalStates = context.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
if (!tool.clearsModalState && modalStates.length)
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
try {
return await context.run(tool, request.params.arguments);
} catch (error) {
return errorResult(String(error));
}
});
return new Connection(server, context);
}
export class Connection {
readonly server: McpServer;
readonly context: Context;
constructor(server: McpServer, context: Context) {
this.server = server;
this.context = context;
this.server.oninitialized = () => {
this.context.clientVersion = this.server.getClientVersion();
};
}
async close() {
await this.server.close();
await this.context.close();
}
}

View File

@@ -17,76 +17,68 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { logUnhandledError } from './utils/log.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import { outputFile } from './config.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
type PendingAction = {
dialogShown: ManualPromise<void>;
};
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
import type * as actions from './actions.js';
import type { SessionLog } from './sessionLog.js';
const testDebug = debug('pw:mcp:test');
type ContextOptions = {
tools: Tool[];
config: FullConfig;
browserContextFactory: BrowserContextFactory;
sessionLog: SessionLog | undefined;
clientInfo: ClientInfo;
};
export class Context {
readonly tools: Tool[];
readonly config: FullConfig;
readonly sessionLog: SessionLog | undefined;
readonly options: ContextOptions;
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
clientVersion: { name: string; version: string; } | undefined;
private _clientInfo: ClientInfo;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools;
this.config = config;
this._browserContextFactory = browserContextFactory;
private static _allContexts: Set<Context> = new Set();
private _closeBrowserContextPromise: Promise<void> | undefined;
private _runningToolName: string | undefined;
private _abortController = new AbortController();
constructor(options: ContextOptions) {
this.tools = options.tools;
this.config = options.config;
this.sessionLog = options.sessionLog;
this.options = options;
this._browserContextFactory = options.browserContextFactory;
this._clientInfo = options.clientInfo;
testDebug('create context');
Context._allContexts.add(this);
}
clientSupportsImages(): boolean {
if (this.config.imageResponses === 'omit')
return false;
return true;
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState, inTab: Tab) {
this._modalStates.push({ ...modalState, tab: inTab });
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
static async disposeAll() {
await Promise.all([...Context._allContexts].map(context => context.dispose()));
}
tabs(): Tab[] {
return this._tabs;
}
currentTab(): Tab | undefined {
return this._currentTab;
}
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
return this._currentTab;
}
@@ -98,8 +90,12 @@ export class Context {
}
async selectTab(index: number) {
this._currentTab = this._tabs[index];
await this._currentTab.page.bringToFront();
const tab = this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
return tab;
}
async ensureTab(): Promise<Tab> {
@@ -109,169 +105,17 @@ export class Context {
return this._currentTab!;
}
async listTabsMarkdown(): Promise<string> {
if (!this._tabs.length)
return '### No tabs open';
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i}:${current} [${title}] (${url})`);
}
return lines.join('\n');
}
async closeTab(index: number | undefined) {
async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index];
await tab?.page.close();
return await this.listTabsMarkdown();
if (!tab)
throw new Error(`Tab ${index} not found`);
const url = tab.page.url();
await tab.page.close();
return url;
}
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
if (resultOverride)
return resultOverride;
if (!this._currentTab) {
return {
content: [{
type: 'text',
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
}],
};
}
const tab = this.currentTabOrDie();
// TODO: race against modal dialogs to resolve clicks.
const actionResult = await this._raceAgainstModalDialogs(async () => {
try {
if (waitForNetwork)
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
else
return await action?.() ?? undefined;
} finally {
if (captureSnapshot && !this._javaScriptBlocked())
await tab.captureSnapshot();
}
});
const result: string[] = [];
result.push(`### Ran Playwright code
\`\`\`js
${code.join('\n')}
\`\`\``);
if (this.modalStates().length) {
result.push('', ...this.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
const messages = tab.takeRecentConsoleMessages();
if (messages.length) {
result.push('', `### New console messages`);
for (const message of messages)
result.push(`- ${trim(message.toString(), 100)}`);
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
}
if (captureSnapshot && tab.hasSnapshot()) {
if (this.tabs().length > 1)
result.push('', await this.listTabsMarkdown());
if (this.tabs().length > 1)
result.push('', '### Current tab');
else
result.push('', '### Page state');
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.title()}`
);
result.push(tab.snapshotOrDie().text());
}
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
}
async waitForTimeout(time: number) {
if (!this._currentTab || this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
this._pendingAction = {
dialogShown: new ManualPromise(),
};
let result: ToolActionResult | undefined;
try {
await Promise.race([
action().then(r => result = r),
this._pendingAction.dialogShown,
]);
} finally {
this._pendingAction = undefined;
}
return result;
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
dialogShown(tab: Tab, dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
}, tab);
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(tab: Tab, download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
async outputFile(name: string): Promise<string> {
return outputFile(this.config, this._clientInfo.rootPath, name);
}
private _onPageCreated(page: playwright.Page) {
@@ -282,7 +126,6 @@ ${code.join('\n')}
}
private _onPageClosed(tab: Tab) {
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
@@ -291,10 +134,25 @@ ${code.join('\n')}
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (!this._tabs.length)
void this.close();
void this.closeBrowserContext();
}
async close() {
async closeBrowserContext() {
if (!this._closeBrowserContextPromise)
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
await this._closeBrowserContextPromise;
this._closeBrowserContextPromise = undefined;
}
isRunningTool() {
return this._runningToolName !== undefined;
}
setRunningTool(name: string | undefined) {
this._runningToolName = name;
}
private async _closeBrowserContextImpl() {
if (!this._browserContextPromise)
return;
@@ -310,6 +168,12 @@ ${code.join('\n')}
});
}
async dispose() {
this._abortController.abort('MCP context disposed');
await this.closeBrowserContext();
Context._allContexts.delete(this);
}
private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));
@@ -335,10 +199,14 @@ ${code.join('\n')}
}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this._closeBrowserContextPromise)
throw new Error('Another browser context is being closed.');
// TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext(this.clientVersion!);
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
if (this.sessionLog)
await InputRecorder.create(this, browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
@@ -354,8 +222,55 @@ ${code.join('\n')}
}
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
export class InputRecorder {
private _context: Context;
private _browserContext: playwright.BrowserContext;
private constructor(context: Context, browserContext: playwright.BrowserContext) {
this._context = context;
this._browserContext = browserContext;
}
static async create(context: Context, browserContext: playwright.BrowserContext) {
const recorder = new InputRecorder(context, browserContext);
await recorder._initialize();
return recorder;
}
private async _initialize() {
const sessionLog = this._context.sessionLog!;
await (this._browserContext as any)._enableRecorder({
mode: 'recording',
recorderMode: 'api',
}, {
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, false);
},
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, true);
},
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
if (this._context.isRunningTool())
return;
if (data.signal.name !== 'navigation')
return;
const tab = Tab.forPage(page);
const navigateAction: actions.Action = {
name: 'navigate',
url: data.signal.url,
signals: [],
};
if (tab)
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
},
});
}
}

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

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

View File

@@ -22,15 +22,21 @@
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import { WebSocket, WebSocketServer } from 'ws';
import type websocket from 'ws';
import http from 'node:http';
import { spawn } from 'child_process';
import http from 'http';
import debug from 'debug';
import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import { httpAddressToString, startHttpServer } from '../transport.js';
import { BrowserContextFactory } from '../browserContextFactory.js';
import { Browser, chromium, type BrowserContext } from 'playwright';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../mcp/manualPromise.js';
import * as protocol from './protocol.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
const debugLogger = debug('pw:mcp:relay');
@@ -52,6 +58,9 @@ type CDPResponse = {
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
@@ -62,19 +71,20 @@ export class CDPRelayServer {
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _extensionConnectionPromise: Promise<void>;
private _extensionConnectionResolve: (() => void) | null = null;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server) {
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
this._resetExtensionConnection();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
@@ -87,31 +97,68 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
await this._connectBrowser(clientInfo);
await this._extensionConnectionPromise;
this._connectBrowser(clientInfo, toolName);
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');
}
private async _connectBrowser(clientInfo: { name: string, version: string }) {
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
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/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const client = {
name: clientInfo.name,
version: clientInfo.version,
};
url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
if (toolName)
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
const href = url.toString();
const command = `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' '${href}'`;
try {
await promisify(exec)(command);
} catch (err) {
debugLogger('Failed to run command:', err);
let executablePath = this._executablePath;
if (!executablePath) {
const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo)
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
executablePath = executableInfo.executablePath();
if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
}
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,
stdio: 'ignore',
});
}
stop(): void {
this._playwrightConnection?.close();
this._extensionConnection?.close();
this.closeConnections('Server stopped');
this._wss.close();
}
closeConnections(reason: string) {
this._closePlaywrightConnection(reason);
this._closeExtensionConnection(reason);
}
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
@@ -128,21 +175,26 @@ export class CDPRelayServer {
}
private _handlePlaywrightConnection(ws: WebSocket): void {
if (this._playwrightConnection) {
debugLogger('Rejecting second Playwright connection');
ws.close(1000, 'Another CDP client already connected');
return;
}
this._playwrightConnection = ws;
ws.on('message', async data => {
try {
const message = JSON.parse(data.toString());
await this._handlePlaywrightMessage(message);
} catch (error) {
debugLogger('Error parsing Playwright message:', error);
} catch (error: any) {
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
}
});
ws.on('close', () => {
if (this._playwrightConnection === ws) {
this._playwrightConnection = null;
this._closeExtensionConnection();
debugLogger('Playwright MCP disconnected');
}
if (this._playwrightConnection !== ws)
return;
this._playwrightConnection = null;
this._closeExtensionConnection('Playwright client disconnected');
debugLogger('Playwright WebSocket closed');
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
@@ -150,13 +202,23 @@ export class CDPRelayServer {
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection() {
private _closeExtensionConnection(reason: string) {
this._extensionConnection?.close(reason);
this._extensionConnectionPromise.reject(new Error(reason));
this._resetExtensionConnection();
}
private _resetExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection?.close();
this._extensionConnection = null;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
this._extensionConnectionPromise = new ManualPromise();
void this._extensionConnectionPromise.catch(logUnhandledError);
}
private _closePlaywrightConnection(reason: string) {
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
this._playwrightConnection.close(1000, reason);
this._playwrightConnection = null;
}
private _handleExtensionConnection(ws: WebSocket): void {
@@ -165,163 +227,119 @@ export class CDPRelayServer {
return;
}
this._extensionConnection = new ExtensionConnection(ws);
this._extensionConnection.onclose = c => {
if (this._extensionConnection === c)
this._extensionConnection = null;
this._extensionConnection.onclose = (c, reason) => {
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
if (this._extensionConnection !== c)
return;
this._resetExtensionConnection();
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
};
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
this._extensionConnectionResolve?.();
this._extensionConnectionPromise.resolve();
}
private _handleExtensionMessage(method: string, params: any) {
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
sessionId: params.sessionId,
sessionId,
method: params.method,
params: params.params
});
break;
case 'detachedFromTab':
debugLogger('← Debugger detached from tab:', params);
this._connectedTabInfo = undefined;
break;
}
}
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
if (!this._extensionConnection) {
debugLogger('Extension not connected, sending error to Playwright');
this._sendToPlaywright({
id: message.id,
error: { message: 'Extension not connected' }
});
return;
}
if (await this._interceptCDPCommand(message))
return;
await this._forwardToExtension(message);
}
private async _interceptCDPCommand(message: CDPCommand): Promise<boolean> {
switch (message.method) {
case 'Browser.getVersion': {
this._sendToPlaywright({
id: message.id,
result: {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
}
});
return true;
}
case 'Browser.setDownloadBehavior': {
this._sendToPlaywright({
id: message.id
});
return true;
}
case 'Target.setAutoAttach': {
// Simulate auto-attach behavior with real target info
if (!message.sessionId) {
this._connectedTabInfo = await this._extensionConnection!.send('attachToTab');
debugLogger('Simulating auto-attach for target:', message);
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo!.sessionId,
targetInfo: {
...this._connectedTabInfo!.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
this._sendToPlaywright({
id: message.id
});
} else {
await this._forwardToExtension(message);
}
return true;
}
case 'Target.getTargetInfo': {
debugLogger('Target.getTargetInfo', message);
this._sendToPlaywright({
id: message.id,
result: this._connectedTabInfo?.targetInfo
});
return true;
}
}
return false;
}
private async _forwardToExtension(message: CDPCommand): Promise<void> {
const { id, sessionId, method, params } = message;
try {
if (!this._extensionConnection)
throw new Error('Extension not connected');
const { id, sessionId, method, params } = message;
const result = await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
const result = await this._handleCDPCommand(method, params, sessionId);
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
id: message.id,
sessionId: message.sessionId,
id,
sessionId,
error: { message: (e as Error).message }
});
}
}
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
switch (method) {
case 'Browser.getVersion': {
return {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
};
}
case 'Browser.setDownloadBehavior': {
return { };
}
case 'Target.setAutoAttach': {
// Forward child session handling.
if (sessionId)
break;
// Simulate auto-attach behavior with real target info
const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
this._connectedTabInfo = {
targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`,
};
debugLogger('Simulating auto-attach');
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo.sessionId,
targetInfo: {
...this._connectedTabInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
return { };
}
case 'Target.getTargetInfo': {
return this._connectedTabInfo?.targetInfo;
}
}
return await this._forwardToExtension(method, params, sessionId);
}
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
// Top level sessionId is only passed between the relay and the client.
if (this._connectedTabInfo?.sessionId === sessionId)
sessionId = undefined;
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
}
private _sendToPlaywright(message: CDPResponse): void {
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
this._playwrightConnection?.send(JSON.stringify(message));
}
}
class ExtensionContextFactory implements BrowserContextFactory {
private _relay: CDPRelayServer;
private _browserPromise: Promise<Browser> | undefined;
constructor(relay: CDPRelayServer) {
this._relay = relay;
}
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo);
const browser = await this._browserPromise;
return {
browserContext: browser.contexts()[0],
close: async () => {}
};
}
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<Browser> {
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
return await chromium.connectOverCDP(this._relay.cdpEndpoint());
}
}
export async function startCDPRelayServer(port: number) {
const httpServer = await startHttpServer({ port });
const cdpRelayServer = new CDPRelayServer(httpServer);
process.on('exit', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return new ExtensionContextFactory(cdpRelayServer);
}
type ExtensionResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void }>();
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
private _lastId = 0;
onmessage?: (method: string, params: any) => void;
onclose?: (self: ExtensionConnection) => void;
onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) {
this._ws = ws;
@@ -330,20 +348,21 @@ class ExtensionConnection {
this._ws.on('error', this._onError.bind(this));
}
async send(method: string, params?: any, sessionId?: string): Promise<any> {
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN)
throw new Error('WebSocket closed');
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
this._ws.send(JSON.stringify({ id, method, params }));
const error = new Error(`Protocol error: ${method}`);
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
this._callbacks.set(id, { resolve, reject, error });
});
}
close(message?: string) {
close(message: string) {
debugLogger('closing extension connection:', message);
this._ws.close(1000, message ?? 'Connection closed');
this.onclose?.(this);
if (this._ws.readyState === WebSocket.OPEN)
this._ws.close(1000, message);
}
private _onMessage(event: websocket.RawData) {
@@ -364,24 +383,28 @@ class ExtensionConnection {
}
}
private _handleParsedMessage(object: any) {
private _handleParsedMessage(object: ExtensionResponse) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error)
callback.reject(new Error(object.error.message));
else
if (object.error) {
const error = callback.error;
error.message = object.error;
callback.reject(error);
} else {
callback.resolve(object.result);
}
} else if (object.id) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method, object.params);
this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
}
}
private _onClose(event: websocket.CloseEvent) {
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
this._dispose();
this.onclose?.(this, event.reason);
}
private _onError(event: websocket.ErrorEvent) {

View File

@@ -0,0 +1,65 @@
/**
* 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 debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../mcp/http.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory {
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
};
}
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
}
private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({});
if (abortSignal.aborted) {
httpServer.close();
throw new Error(abortSignal.reason);
}
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return cdpRelayServer;
}
}

View File

@@ -1,35 +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 { resolveCLIConfig } from '../config.js';
import { startHttpServer, startHttpTransport, startStdioTransport } from '../transport.js';
import { Server } from '../server.js';
import { startCDPRelayServer } from './cdpRelay.js';
export async function runWithExtension(options: any) {
const config = await resolveCLIConfig({ });
const contextFactory = await startCDPRelayServer(9225);
const server = new Server(config, contextFactory);
server.setupExitWatchdog();
if (options.port !== undefined) {
const httpServer = await startHttpServer({ port: options.port });
startHttpTransport(httpServer, server);
} else {
await startStdioTransport(server);
}
}

42
src/extension/protocol.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.
*/
// Whenever the commands/events change, the version must be updated. The latest
// extension version should be compatible with the old MCP clients.
export const VERSION = 1;
export type ExtensionCommand = {
'attachToTab': {
params: {};
};
'forwardCDPCommand': {
params: {
method: string,
sessionId?: string
params?: any,
};
};
};
export type ExtensionEvents = {
'forwardCDPEvent': {
params: {
method: string,
sessionId?: string
params?: any,
};
};
};

View File

@@ -1,232 +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 fs from 'fs';
import path from 'path';
import http from 'http';
import net from 'net';
import mime from 'mime';
import { ManualPromise } from './manualPromise.js';
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
export type Transport = {
sendEvent?: (method: string, params: any) => void;
close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void;
};
export class HttpServer {
private _server: http.Server;
private _urlPrefixPrecise: string = '';
private _urlPrefixHumanReadable: string = '';
private _port: number = 0;
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._server = http.createServer(this._onRequest.bind(this));
decorateServer(this._server);
}
server() {
return this._server;
}
routePrefix(prefix: string, handler: ServerRouteHandler) {
this._routes.push({ prefix, handler });
}
routePath(path: string, handler: ServerRouteHandler) {
this._routes.push({ exact: path, handler });
}
port(): number {
return this._port;
}
private async _tryStart(port: number | undefined, host: string) {
const errorPromise = new ManualPromise();
const errorListener = (error: Error) => errorPromise.reject(error);
this._server.on('error', errorListener);
try {
this._server.listen(port, host);
await Promise.race([
new Promise(cb => this._server!.once('listening', cb)),
errorPromise,
]);
} finally {
this._server.removeListener('error', errorListener);
}
}
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
const host = options.host || 'localhost';
if (options.preferredPort) {
try {
await this._tryStart(options.preferredPort, host);
} catch (e: any) {
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
throw e;
await this._tryStart(undefined, host);
}
} else {
await this._tryStart(options.port, host);
}
const address = this._server.address();
if (typeof address === 'string') {
this._urlPrefixPrecise = address;
this._urlPrefixHumanReadable = address;
} else {
this._port = address!.port;
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
}
}
async stop() {
await new Promise(cb => this._server!.close(cb));
}
urlPrefix(purpose: 'human-readable' | 'precise'): string {
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
}
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
try {
for (const [name, value] of Object.entries(headers || {}))
response.setHeader(name, value);
if (request.headers.range)
this._serveRangeFile(request, response, absoluteFilePath);
else
this._serveFile(response, absoluteFilePath);
return true;
} catch (e) {
return false;
}
}
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
const content = fs.readFileSync(absoluteFilePath);
response.statusCode = 200;
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Length', content.byteLength);
response.end(content);
}
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
const range = request.headers.range;
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
response.statusCode = 400;
return response.end('Bad request');
}
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
let start: number;
let end: number;
const size = fs.statSync(absoluteFilePath).size;
if (startStr !== '' && endStr === '') {
// No end specified: use the whole file
start = +startStr;
end = size - 1;
} else if (startStr === '' && endStr !== '') {
// No start specified: calculate start manually
start = size - +endStr;
end = size - 1;
} else {
start = +startStr;
end = +endStr;
}
// Handle unavailable range request
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
response.writeHead(416, {
'Content-Range': `bytes */${size}`
});
return response.end();
}
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
response.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
});
const readable = fs.createReadStream(absoluteFilePath, { start, end });
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
}
request.on('error', () => response.end());
try {
if (!request.url) {
response.end();
return;
}
const url = new URL('http://localhost' + request.url);
for (const route of this._routes) {
if (route.exact && url.pathname === route.exact) {
route.handler(request, response);
return;
}
if (route.prefix && url.pathname.startsWith(route.prefix)) {
route.handler(request, response);
return;
}
}
response.statusCode = 404;
response.end();
} catch (e) {
response.end();
}
}
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -14,22 +14,27 @@
* limitations under the License.
*/
import { createConnection as createConnectionImpl } from './connection.js';
import type { Connection } from '../index.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.js';
import { packageJSON } from './utils/package.js';
import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
return createConnectionImpl(config, factory);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {
name = 'custom';
description = 'Connect to a browser using a custom context getter';
private readonly _contextGetter: () => Promise<BrowserContext>;
constructor(contextGetter: () => Promise<BrowserContext>) {

108
src/loop/loop.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* 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 debug from 'debug';
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
export type LLMToolCall = {
name: string;
arguments: any;
id: string;
};
export type LLMTool = {
name: string;
description: string;
inputSchema: any;
};
export type LLMMessage =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
export type LLMConversation = {
messages: LLMMessage[];
tools: LLMTool[];
};
export interface LLMDelegate {
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
checkDoneToolCall(toolCall: LLMToolCall): string | null;
}
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
const { tools } = await client.listTools();
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
const conversation = delegate.createConversation(taskContent, tools, oneShot);
for (let iteration = 0; iteration < 5; ++iteration) {
debug('history')('Making API call for iteration', iteration);
const toolCalls = await delegate.makeApiCall(conversation);
if (toolCalls.length === 0)
throw new Error('Call the "done" tool when the task is complete.');
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
for (const toolCall of toolCalls) {
const doneResult = delegate.checkDoneToolCall(toolCall);
if (doneResult !== null)
return conversation.messages;
const { name, arguments: args, id } = toolCall;
try {
debug('tool')(name, args);
const response = await client.callTool({
name,
arguments: args,
});
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
debug('tool')(responseContent);
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
toolResults.push({
toolCallId: id,
content: text,
});
} catch (error) {
debug('tool')(error);
toolResults.push({
toolCallId: id,
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
isError: true,
});
// Skip remaining tool calls for this iteration
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
toolResults.push({
toolCallId: remainingToolCall.id,
content: `This tool call is skipped due to previous error.`,
isError: true,
});
}
break;
}
}
delegate.addToolResults(conversation, toolResults);
if (oneShot)
return conversation.messages;
}
throw new Error('Failed to perform step, max attempts reached');
}

177
src/loop/loopClaude.ts Normal file
View File

@@ -0,0 +1,177 @@
/**
* 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 type Anthropic from '@anthropic-ai/sdk';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'claude-sonnet-4-20250514';
export class ClaudeDelegate implements LLMDelegate {
private _anthropic: Anthropic | undefined;
async anthropic(): Promise<Anthropic> {
if (!this._anthropic) {
const anthropic = await import('@anthropic-ai/sdk');
this._anthropic = new anthropic.Anthropic();
}
return this._anthropic;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const llmTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
llmTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: llmTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to Claude format
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
claudeMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const content: Anthropic.Messages.ContentBlock[] = [];
// Add text content
if (message.content) {
content.push({
type: 'text',
text: message.content,
citations: []
});
}
// Add tool calls
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments
});
}
}
claudeMessages.push({
role: 'assistant',
content
});
} else if (message.role === 'tool') {
// Tool results are added differently - we need to find if there's already a user message with tool results
const lastMessage = claudeMessages[claudeMessages.length - 1];
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: message.toolCallId,
content: message.content,
is_error: message.isError,
};
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
// Add to existing tool results message
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
} else {
// Create new tool results message
claudeMessages.push({
role: 'user',
content: [toolResult]
});
}
}
}
// Convert generic tools to Claude format
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
}));
const anthropic = await this.anthropic();
const response = await anthropic.messages.create({
model,
max_tokens: 10000,
messages: claudeMessages,
tools: claudeTools,
});
// Extract tool calls and add assistant message to generic conversation
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
name: toolCall.name,
arguments: toolCall.input as any,
id: toolCall.id,
}));
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: textContent,
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
});
return llmToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return (toolCall.arguments as { result: string }).result;
return null;
}
}

168
src/loop/loopOpenAI.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* 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 type OpenAI from 'openai';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'gpt-4.1';
export class OpenAIDelegate implements LLMDelegate {
private _openai: OpenAI | undefined;
async openai(): Promise<OpenAI> {
if (!this._openai) {
const oai = await import('openai');
this._openai = new oai.OpenAI();
}
return this._openai;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const genericTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
genericTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: genericTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to OpenAI format
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
openaiMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
toolCalls.push({
id: toolCall.id,
type: 'function',
function: {
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments)
}
});
}
}
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
role: 'assistant'
};
if (message.content)
assistantMessage.content = message.content;
if (toolCalls.length > 0)
assistantMessage.tool_calls = toolCalls;
openaiMessages.push(assistantMessage);
} else if (message.role === 'tool') {
openaiMessages.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: message.content,
});
}
}
// Convert generic tools to OpenAI format
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}));
const openai = await this.openai();
const response = await openai.chat.completions.create({
model,
messages: openaiMessages,
tools: openaiTools,
tool_choice: 'auto'
});
const message = response.choices[0].message;
// Extract tool calls and add assistant message to generic conversation
const toolCalls = message.tool_calls || [];
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
const functionCall = toolCall.function;
return {
name: functionCall.name,
arguments: JSON.parse(functionCall.arguments),
id: toolCall.id,
};
});
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: message.content || '',
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
});
return genericToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return toolCall.arguments.result;
return null;
}
}

72
src/loop/main.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 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.
*/
/* eslint-disable no-console */
import path from 'path';
import url from 'url';
import dotenv from 'dotenv';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { program } from 'commander';
import { OpenAIDelegate } from './loopOpenAI.js';
import { ClaudeDelegate } from './loopClaude.js';
import { runTask } from './loop.js';
import type { LLMDelegate } from './loop.js';
dotenv.config();
const __filename = url.fileURLToPath(import.meta.url);
async function run(delegate: LLMDelegate) {
const transport = new StdioClientTransport({
command: 'node',
args: [
path.resolve(__filename, '../../../cli.js'),
'--save-session',
'--output-dir', path.resolve(__filename, '../../../sessions')
],
stderr: 'inherit',
env: process.env as Record<string, string>,
});
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
for (const task of tasks) {
const messages = await runTask(delegate, client, task);
for (const message of messages)
console.log(`${message.role}: ${message.content}`);
}
await client.close();
}
const tasks = [
'Open https://playwright.dev/',
];
program
.option('--model <model>', 'model to use')
.action(async options => {
if (options.model === 'claude')
await run(new ClaudeDelegate());
else
await run(new OpenAIDelegate());
});
void program.parseAsync(process.argv);

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

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

78
src/loopTools/context.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* 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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { contextFactory } from '../browserContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { Context as BrowserContext } from '../context.js';
import { runTask } from '../loop/loop.js';
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js';
export class Context {
readonly config: FullConfig;
private _client: Client;
private _delegate: LLMDelegate;
constructor(config: FullConfig, client: Client) {
this.config = config;
this._client = client;
if (process.env.OPENAI_API_KEY)
this._delegate = new OpenAIDelegate();
else if (process.env.ANTHROPIC_API_KEY)
this._delegate = new ClaudeDelegate();
else
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
}
static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];
// Skip the first message, which is the user's task.
for (const message of messages.slice(1)) {
// Trim out all page snapshots.
if (!message.content.trim())
continue;
const index = oneShot ? -1 : message.content.indexOf('### Page state');
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
lines.push(`[${message.role}]:`, trimmedContent);
}
return {
content: [{ type: 'text', text: lines.join('\n') }],
};
}
async close() {
await BrowserContext.disposeAll();
}
}

67
src/loopTools/main.ts Normal file
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 dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.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';
import type { Tool } from './tool.js';
export async function runLoopTools(config: FullConfig) {
dotenv.config();
const serverBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-loop',
version: packageJSON.version,
create: () => new LoopToolsServerBackend(config)
};
await mcpServer.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
private _config: FullConfig;
private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot];
constructor(config: FullConfig) {
this._config = config;
}
async initialize() {
this._context = await Context.create(this._config);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
const tool = this._tools.find(tool => tool.schema.name === name)!;
const parsedArguments = tool.schema.inputSchema.parse(args || {});
return await tool.handle(this._context!, parsedArguments);
}
serverClosed() {
void this._context!.close();
}
}

36
src/loopTools/perform.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 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 { defineTool } from './tool.js';
const performSchema = z.object({
task: z.string().describe('The task to perform with the browser'),
});
export const perform = defineTool({
schema: {
name: 'browser_perform',
title: 'Perform a task with the browser',
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
inputSchema: performSchema,
type: 'destructive',
},
handle: async (context, params) => {
return await context.runTask(params.task);
},
});

32
src/loopTools/snapshot.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* 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 { defineTool } from './tool.js';
export const snapshot = defineTool({
schema: {
name: 'browser_snapshot',
title: 'Take a snapshot of the browser',
description: 'Take a snapshot of the browser to read what is on the page.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params) => {
return await context.runTask('Capture browser snapshot', true);
},
});

30
src/loopTools/tool.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* 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 type { z } from 'zod';
import type * as mcpServer from '../mcp/server.js';
import type { Context } from './context.js';
import type { ToolSchema } from '../mcp/tool.js';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

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

@@ -0,0 +1 @@
[*]

1
src/mcp/README.md Normal file
View File

@@ -0,0 +1 @@
- Generic MCP utils, no dependencies on anything.

View File

@@ -14,28 +14,63 @@
* limitations under the License.
*/
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
import assert from 'assert';
import net from 'net';
import http from 'http';
import crypto from 'crypto';
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 * as mcpServer from './server.js';
import { logUnhandledError } from './log.js';
import type { AddressInfo } from 'node:net';
import type { Server } from './server.js';
import type { Connection } from './connection.js';
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
}
import type { ServerBackendFactory } from './server.js';
const testDebug = debug('pw:mcp:test');
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
decorateServer(httpServer);
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
abortSignal?.addEventListener('abort', () => {
httpServer.close();
reject(new Error('Aborted'));
});
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
});
return httpServer;
}
export function httpAddressToString(address: string | net.AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
}
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
const sseSessions = new Map();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
else
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
});
}
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
@@ -54,11 +89,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
testDebug(`create SSE session: ${transport.sessionId}`);
const connection = await server.createConnection(transport);
await mcpServer.connect(serverBackendFactory, transport, false);
res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId);
void connection.close().catch(logUnhandledError);
});
return;
}
@@ -67,10 +101,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed');
}
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, { transport: StreamableHTTPServerTransport, connection: Connection }>) {
async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const { transport } = sessions.get(sessionId) ?? {};
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
@@ -84,18 +118,16 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: async sessionId => {
testDebug(`create http session: ${transport.sessionId}`);
const connection = await server.createConnection(transport);
sessions.set(sessionId, { transport, connection });
await mcpServer.connect(serverBackendFactory, transport, true);
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
const result = transport.sessionId ? sessions.get(transport.sessionId) : undefined;
if (!result)
if (!transport.sessionId)
return;
sessions.delete(result.transport.sessionId!);
sessions.delete(transport.sessionId);
testDebug(`delete http session: ${transport.sessionId}`);
result.connection.close().catch(logUnhandledError);
};
await transport.handleRequest(req, res);
@@ -106,53 +138,18 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
res.end('Invalid request');
}
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
return httpServer;
}
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(mcpServer, req, res, url, sseSessions);
else
await handleStreamable(mcpServer, req, res, streamableSessions);
});
const url = httpAddressToString(httpServer.address());
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/mcp`
}
}
}, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}
export function httpAddressToString(address: string | AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -0,0 +1,92 @@
/**
* 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 type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
export class InProcessTransport implements Transport {
private _server: Server;
private _serverTransport: InProcessServerTransport;
private _connected: boolean = false;
constructor(server: Server) {
this._server = server;
this._serverTransport = new InProcessServerTransport(this);
}
async start(): Promise<void> {
if (this._connected)
throw new Error('InprocessTransport already started!');
await this._server.connect(this._serverTransport);
this._connected = true;
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (!this._connected)
throw new Error('Transport not connected');
this._serverTransport._receiveFromClient(message);
}
async close(): Promise<void> {
if (this._connected) {
this._connected = false;
this.onclose?.();
this._serverTransport.onclose?.();
}
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
this.onmessage?.(message, extra);
}
}
class InProcessServerTransport implements Transport {
private _clientTransport: InProcessTransport;
constructor(clientTransport: InProcessTransport) {
this._clientTransport = clientTransport;
}
async start(): Promise<void> {
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
this._clientTransport._receiveFromServer(message);
}
async close(): Promise<void> {
this.onclose?.();
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromClient(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}

239
src/mcp/mdb.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* 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 debug from 'debug';
import { z } from 'zod';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { defineToolSchema } from './tool.js';
import * as mcpServer from './server.js';
import * as mcpHttp from './http.js';
import { wrapInProcess } from './server.js';
import { ManualPromise } from './manualPromise.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
const mdbDebug = debug('pw:mcp:mdb');
const errorsDebug = debug('pw:mcp:errors');
export class MDBBackend implements mcpServer.ServerBackend {
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
private _topLevelBackend: mcpServer.ServerBackend;
private _initialized = false;
constructor(topLevelBackend: mcpServer.ServerBackend) {
this._topLevelBackend = topLevelBackend;
}
async initialize(server: mcpServer.Server): Promise<void> {
if (this._initialized)
return;
this._initialized = true;
const transport = await wrapInProcess(this._topLevelBackend);
await this._pushClient(transport);
}
async listTools(): Promise<mcpServer.Tool[]> {
const response = await this._client().listTools();
return response.tools;
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
if (name === pushToolsSchema.name)
return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
this._interruptPromise = interruptPromise;
let [entry] = this._stack;
// Pop the client while the tool is not found.
while (entry && !entry.toolNames.includes(name)) {
mdbDebug('popping client from stack for ', name);
this._stack.shift();
await entry.client.close();
entry = this._stack[0];
}
if (!entry)
throw new Error(`Tool ${name} not found in the tool stack`);
const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
entry.resultPromise = resultPromise;
this._client().callTool({
name,
arguments: args,
}).then(result => {
resultPromise.resolve(result as mcpServer.CallToolResult);
}).catch(e => {
mdbDebug('error in client call', e);
if (this._stack.length < 2)
throw e;
this._stack.shift();
const prevEntry = this._stack[0];
void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
});
const result = await Promise.race([interruptPromise, resultPromise]);
if (interruptPromise.isDone())
mdbDebug('client call intercepted', result);
else
mdbDebug('client call result', result);
return result;
}
private _client(): Client {
const [entry] = this._stack;
if (!entry)
throw new Error('No debugging backend available');
return entry.client;
}
private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing tools to the stack', params.mcpUrl);
const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
await this._pushClient(transport, params.introMessage);
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing client to the stack');
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
await client.connect(transport);
mdbDebug('connected to the new client');
const { tools } = await client.listTools();
this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
mdbDebug('interrupting current call:', !!this._interruptPromise);
this._interruptPromise?.resolve({
content: [{
type: 'text',
text: introMessage || '',
}],
});
this._interruptPromise = undefined;
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
}
const pushToolsSchema = defineToolSchema({
name: 'mdb_push_tools',
title: 'Push MCP tools to the tools stack',
description: 'Push MCP tools to the tools stack',
inputSchema: z.object({
mcpUrl: z.string(),
introMessage: z.string().optional(),
}),
type: 'readOnly',
});
export type ServerBackendOnPause = mcpServer.ServerBackend & {
requestSelfDestruct?: () => void;
};
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
const mdbBackend = new MDBBackend(backendFactory.create());
// Start HTTP unconditionally.
const factory: mcpServer.ServerBackendFactory = {
...backendFactory,
create: () => mdbBackend
};
const url = await startAsHttp(factory, { port: options?.port || 0 });
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
if (options?.port !== undefined)
return url;
// Start stdio conditionally.
await mcpServer.connect(factory, new StdioServerTransport(), false);
}
export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
const factory = {
name: 'on-pause-backend',
nameInConfig: 'on-pause-backend',
version: '0.0.0',
create: () => wrappedBackend,
};
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
await mcpHttp.installHttpTransport(httpServer, factory);
const url = mcpHttp.httpAddressToString(httpServer.address());
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
await client.connect(transport);
const pushToolsResult = await client.callTool({
name: pushToolsSchema.name,
arguments: {
mcpUrl: url,
introMessage,
},
});
if (pushToolsResult.isError)
errorsDebug('Failed to push tools', pushToolsResult.content);
await transport.terminateSession();
await client.close();
await wrappedBackend.waitForClosed();
httpServer.close();
}
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
const httpServer = await mcpHttp.startHttpServer(options);
await mcpHttp.installHttpTransport(httpServer, backendFactory);
return mcpHttp.httpAddressToString(httpServer.address());
}
class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
private _backend: ServerBackendOnPause;
private _selfDestructPromise = new ManualPromise<void>();
constructor(backend: ServerBackendOnPause) {
this._backend = backend;
this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
}
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
await this._backend.initialize?.(server, clientVersion, roots);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._backend.listTools();
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
return this._backend.callTool(name, args);
}
serverClosed(server: mcpServer.Server) {
this._backend.serverClosed?.(server);
this._selfDestructPromise.resolve();
}
async waitForClosed() {
await this._selfDestructPromise;
}
}

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

@@ -0,0 +1,128 @@
/**
* 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 debug from 'debug';
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 type { ServerBackend, ClientVersion, Root, Server } 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>;
};
const errorsDebug = debug('pw:mcp:errors');
export class ProxyBackend implements ServerBackend {
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(server: Server, 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(errorsDebug);
}
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: '0.0.0' });
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;
}
}

158
src/mcp/server.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* 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 debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
import { InProcessTransport } from './inProcessTransport.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';
const serverDebug = debug('pw:mcp:server');
const errorsDebug = debug('pw:mcp:errors');
export type ClientVersion = { name: string, version: string };
export interface ServerBackend {
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(server: Server): void;
}
export type ServerBackendFactory = {
name: string;
nameInConfig: string;
version: string;
create: () => ServerBackend;
};
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
await server.connect(transport);
}
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
const server = createServer('Internal', '0.0.0', backend, false);
return new InProcessTransport(server);
}
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
let initializedPromiseResolve = () => {};
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
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) {
heartbeatRunning = true;
startHeartbeat(server);
}
try {
return await backend.callTool(request.params.name, request.params.arguments || {});
} catch (error) {
return {
content: [{ type: 'text', text: '### Result\n' + String(error) }],
isError: true,
};
}
});
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?.(server, clientVersion, clientRoots);
initializedPromiseResolve();
} catch (e) {
errorsDebug(e);
}
});
addServerListener(server, 'close', () => backend.serverClosed?.(server));
return server;
}
const startHeartbeat = (server: Server) => {
const beat = () => {
Promise.race([
server.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
]).then(() => {
setTimeout(beat, 3000);
}).catch(() => {
void server.close();
});
};
beat();
};
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
const oldListener = server[`on${event}`];
server[`on${event}`] = () => {
oldListener?.();
listener();
};
}
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
if (options.port === undefined) {
await connect(serverBackendFactory, new StdioServerTransport(), false);
return;
}
const httpServer = await startHttpServer(options);
await installHttpTransport(httpServer, serverBackendFactory);
const url = httpAddressToString(httpServer.address());
const mcpConfig: any = { mcpServers: { } };
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
url: `${url}/mcp`
};
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify(mcpConfig, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}

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

@@ -0,0 +1,46 @@
/**
* 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,
},
};
}
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
return tool;
}

View File

@@ -1,55 +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 * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot {
private _page: playwright.Page;
private _text!: string;
constructor(page: playwright.Page) {
this._page = page;
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot(page);
await snapshot._build();
return snapshot;
}
text(): string {
return this._text;
}
private async _build() {
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
].join('\n');
}
refLocator(params: { element: string, ref: string }): playwright.Locator {
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
}
}

View File

@@ -15,14 +15,18 @@
*/
import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
import * as mcpServer from './mcp/server.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
import { runWithExtension } from './extension/main.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 { runVSCodeTools } from './vscode/host.js';
import type { MCPProvider } from './mcp/proxyBackend.js';
program
.version('Version ' + packageJSON.version)
@@ -36,6 +40,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,43 +51,96 @@ program
.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"')
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.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('--vscode', 'VS Code tools.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
if (options.extension) {
await runWithExtension(options);
return;
}
setupExitWatchdog();
if (options.vision) {
// eslint-disable-next-line no-console
console.error('The --vision option is deprecated, use --caps=vision instead');
options.caps = 'vision';
}
const config = await resolveCLIConfig(options);
const browserContextFactory = contextFactory(config);
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);
const server = new Server(config);
server.setupExitWatchdog();
if (config.server.port !== undefined) {
const httpServer = await startHttpServer(config.server);
startHttpTransport(httpServer, server);
} else {
await startStdioTransport(server);
if (options.extension) {
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ extension',
nameInConfig: 'playwright-extension',
version: packageJSON.version,
create: () => new BrowserServerBackend(config, extensionContextFactory)
};
await mcpServer.start(serverBackendFactory, config.server);
return;
}
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);
if (options.vscode) {
await runVSCodeTools(config);
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
if (options.connectTool) {
const providers: MCPProvider[] = [
{
name: 'default',
description: 'Starts standalone browser',
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
},
{
name: 'extension',
description: 'Connect to a browser using the Playwright MCP extension',
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
},
];
const factory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ switch',
nameInConfig: 'playwright-switch',
version: packageJSON.version,
create: () => new ProxyBackend(providers),
};
await mcpServer.start(factory, config.server);
return;
}
const factory: mcpServer.ServerBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright',
version: packageJSON.version,
create: () => new BrowserServerBackend(config, browserContextFactory)
};
await mcpServer.start(factory, config.server);
});
function setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Context.disposeAll();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
void program.parseAsync(process.argv);

201
src/response.ts Normal file
View File

@@ -0,0 +1,201 @@
/**
* 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 { renderModalStates } from './tab.js';
import type { Tab, TabSnapshot } from './tab.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Context } from './context.js';
export class Response {
private _result: string[] = [];
private _code: string[] = [];
private _images: { contentType: string, data: Buffer }[] = [];
private _context: Context;
private _includeSnapshot = false;
private _includeTabs = false;
private _tabSnapshot: TabSnapshot | undefined;
readonly toolName: string;
readonly toolArgs: Record<string, any>;
private _isError: boolean | undefined;
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
this._context = context;
this.toolName = toolName;
this.toolArgs = toolArgs;
}
addResult(result: string) {
this._result.push(result);
}
addError(error: string) {
this._result.push(error);
this._isError = true;
}
isError() {
return this._isError;
}
result() {
return this._result.join('\n');
}
addCode(code: string) {
this._code.push(code);
}
code() {
return this._code.join('\n');
}
addImage(image: { contentType: string, data: Buffer }) {
this._images.push(image);
}
images() {
return this._images;
}
setIncludeSnapshot() {
this._includeSnapshot = true;
}
setIncludeTabs() {
this._includeTabs = true;
}
async finish() {
// All the async snapshotting post-action is happening here.
// Everything below should race against modal states.
if (this._includeSnapshot && this._context.currentTab())
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
for (const tab of this._context.tabs())
await tab.updateTitle();
}
tabSnapshot(): TabSnapshot | undefined {
return this._tabSnapshot;
}
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
const response: string[] = [];
// Start with command result.
if (this._result.length) {
response.push('### Result');
response.push(this._result.join('\n'));
response.push('');
}
// Add code if it exists.
if (this._code.length) {
response.push(`### Ran Playwright code
\`\`\`js
${this._code.join('\n')}
\`\`\``);
response.push('');
}
// List browser tabs.
if (this._includeSnapshot || this._includeTabs)
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
// Add snapshot if provided.
if (this._tabSnapshot?.modalStates.length) {
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
response.push('');
} else if (this._tabSnapshot) {
response.push(renderTabSnapshot(this._tabSnapshot));
response.push('');
}
// Main response part
const content: (TextContent | ImageContent)[] = [
{ type: 'text', text: response.join('\n') },
];
// Image attachments.
if (this._context.config.imageResponses !== 'omit') {
for (const image of this._images)
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}
return { content, isError: this._isError };
}
}
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
const lines: string[] = [];
if (tabSnapshot.consoleMessages.length) {
lines.push(`### New console messages`);
for (const message of tabSnapshot.consoleMessages)
lines.push(`- ${trim(message.toString(), 100)}`);
lines.push('');
}
if (tabSnapshot.downloads.length) {
lines.push(`### Downloads`);
for (const entry of tabSnapshot.downloads) {
if (entry.finished)
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
lines.push('');
}
lines.push(`### Page state`);
lines.push(`- Page URL: ${tabSnapshot.url}`);
lines.push(`- Page Title: ${tabSnapshot.title}`);
lines.push(`- Page Snapshot:`);
lines.push('```yaml');
lines.push(tabSnapshot.ariaSnapshot);
lines.push('```');
return lines.join('\n');
}
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
if (tabs.length === 1 && !force)
return [];
if (!tabs.length) {
return [
'### Open tabs',
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
'',
];
}
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const current = tab.isCurrentTab() ? ' (current)' : '';
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
}
lines.push('');
return lines;
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

View File

@@ -1,59 +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 { createConnection } from './connection.js';
import { contextFactory as defaultContextFactory } from './browserContextFactory.js';
import type { FullConfig } from './config.js';
import type { Connection } from './connection.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export class Server {
readonly config: FullConfig;
private _connectionList: Connection[] = [];
private _browserConfig: FullConfig['browser'];
private _contextFactory: BrowserContextFactory;
constructor(config: FullConfig, contextFactory?: BrowserContextFactory) {
this.config = config;
this._browserConfig = config.browser;
this._contextFactory = contextFactory ?? defaultContextFactory(this._browserConfig);
}
async createConnection(transport: Transport): Promise<Connection> {
const connection = createConnection(this.config, this._contextFactory);
this._connectionList.push(connection);
await connection.server.connect(transport);
return connection;
}
setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Promise.all(this._connectionList.map(connection => connection.close()));
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}

176
src/sessionLog.ts Normal file
View File

@@ -0,0 +1,176 @@
/**
* 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 fs from 'fs';
import path from 'path';
import { Response } from './response.js';
import { logUnhandledError } from './utils/log.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
import type * as actions from './actions.js';
import type { Tab, TabSnapshot } from './tab.js';
type LogEntry = {
timestamp: number;
toolCall?: {
toolName: string;
toolArgs: Record<string, any>;
result: string;
isError?: boolean;
};
userAction?: actions.Action;
code: string;
tabSnapshot?: TabSnapshot;
};
export class SessionLog {
private _folder: string;
private _file: string;
private _ordinal = 0;
private _pendingEntries: LogEntry[] = [];
private _sessionFileQueue = Promise.resolve();
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
constructor(sessionFolder: string) {
this._folder = sessionFolder;
this._file = path.join(this._folder, 'session.md');
}
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
await fs.promises.mkdir(sessionFolder, { recursive: true });
// eslint-disable-next-line no-console
console.error(`Session: ${sessionFolder}`);
return new SessionLog(sessionFolder);
}
logResponse(response: Response) {
const entry: LogEntry = {
timestamp: performance.now(),
toolCall: {
toolName: response.toolName,
toolArgs: response.toolArgs,
result: response.result(),
isError: response.isError(),
},
code: response.code(),
tabSnapshot: response.tabSnapshot(),
};
this._appendEntry(entry);
}
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
code = code.trim();
if (isUpdate) {
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
if (lastEntry.userAction?.name === action.name) {
lastEntry.userAction = action;
lastEntry.code = code;
return;
}
}
if (action.name === 'navigate') {
// Already logged at this location.
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
if (lastEntry?.tabSnapshot?.url === action.url)
return;
}
const entry: LogEntry = {
timestamp: performance.now(),
userAction: action,
code,
tabSnapshot: {
url: tab.page.url(),
title: '',
ariaSnapshot: action.ariaSnapshot || '',
modalStates: [],
consoleMessages: [],
downloads: [],
},
};
this._appendEntry(entry);
}
private _appendEntry(entry: LogEntry) {
this._pendingEntries.push(entry);
if (this._flushEntriesTimeout)
clearTimeout(this._flushEntriesTimeout);
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
}
private async _flushEntries() {
clearTimeout(this._flushEntriesTimeout);
const entries = this._pendingEntries;
this._pendingEntries = [];
const lines: string[] = [''];
for (const entry of entries) {
const ordinal = (++this._ordinal).toString().padStart(3, '0');
if (entry.toolCall) {
lines.push(
`### Tool call: ${entry.toolCall.toolName}`,
`- Args`,
'```json',
JSON.stringify(entry.toolCall.toolArgs, null, 2),
'```',
);
if (entry.toolCall.result) {
lines.push(
entry.toolCall.isError ? `- Error` : `- Result`,
'```',
entry.toolCall.result,
'```',
);
}
}
if (entry.userAction) {
const actionData = { ...entry.userAction } as any;
delete actionData.ariaSnapshot;
delete actionData.selector;
delete actionData.signals;
lines.push(
`### User action: ${entry.userAction.name}`,
`- Args`,
'```json',
JSON.stringify(actionData, null, 2),
'```',
);
}
if (entry.code) {
lines.push(
`- Code`,
'```js',
entry.code,
'```');
}
if (entry.tabSnapshot) {
const fileName = `${ordinal}.snapshot.yml`;
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
lines.push(`- Snapshot: ${fileName}`);
}
lines.push('', '');
}
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
}
}

View File

@@ -14,24 +14,49 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import { callOnPageNoTrace } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './utils/log.js';
import { ManualPromise } from './mcp/manualPromise.js';
import { ModalState } from './tools/tool.js';
import type { Context } from './context.js';
export class Tab {
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export const TabEvents = {
modalState: 'modalState'
};
export type TabEventsInterface = {
[TabEvents.modalState]: [modalState: ModalState];
};
export type TabSnapshot = {
url: string;
title: string;
ariaSnapshot: string;
modalStates: ModalState[];
consoleMessages: ConsoleMessage[];
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
};
export class Tab extends EventEmitter<TabEventsInterface> {
readonly context: Context;
readonly page: playwright.Page;
private _lastTitle = 'about:blank';
private _consoleMessages: ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
private _modalStates: ModalState[] = [];
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
super();
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
@@ -41,18 +66,59 @@ export class Tab {
page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
this.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.on('dialog', dialog => this._dialogShown(dialog));
page.on('download', download => {
void this.context.downloadStarted(this, download);
void this._downloadStarted(download);
});
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
(page as any)[tabSymbol] = this;
}
static forPage(page: playwright.Page): Tab | undefined {
return (page as any)[tabSymbol];
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState) {
this._modalStates.push(modalState);
this.emit(TabEvents.modalState, modalState);
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
return renderModalStates(this.context, this.modalStates());
}
private _dialogShown(dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
});
}
private async _downloadStarted(download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await this.context.outputFile(download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _clearCollectedArtifacts() {
@@ -71,8 +137,18 @@ export class Tab {
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
async updateTitle() {
await this._raceAgainstModalStates(async () => {
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
});
}
lastTitle(): string {
return this._lastTitle;
}
isCurrentTab(): boolean {
return this === this.context.currentTab();
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
@@ -95,26 +171,19 @@ export class Tab {
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([
downloadEvent,
new Promise(resolve => setTimeout(resolve, 1000)),
new Promise(resolve => setTimeout(resolve, 3000)),
]);
if (!download)
throw e;
// Make sure other "download" listeners are notified first.
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.waitForLoadState('load', { timeout: 5000 });
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
consoleMessages(): ConsoleMessage[] {
return this._consoleMessages;
}
@@ -123,14 +192,81 @@ export class Tab {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
async captureSnapshot(): Promise<TabSnapshot> {
let tabSnapshot: TabSnapshot | undefined;
const modalStates = await this._raceAgainstModalStates(async () => {
const snapshot = await (this.page as PageEx)._snapshotForAI();
tabSnapshot = {
url: this.page.url(),
title: await this.page.title(),
ariaSnapshot: snapshot,
modalStates: [],
consoleMessages: [],
downloads: this._downloads,
};
});
if (tabSnapshot) {
// Assign console message late so that we did not lose any to modal state.
tabSnapshot.consoleMessages = this._recentConsoleMessages;
this._recentConsoleMessages = [];
}
return tabSnapshot ?? {
url: this.page.url(),
title: '',
ariaSnapshot: '',
modalStates,
consoleMessages: [],
downloads: [],
};
}
takeRecentConsoleMessages(): ConsoleMessage[] {
const result = this._recentConsoleMessages.slice();
this._recentConsoleMessages.length = 0;
return result;
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
if (this.modalStates().length)
return this.modalStates();
const promise = new ManualPromise<ModalState[]>();
const listener = (modalState: ModalState) => promise.resolve([modalState]);
this.once(TabEvents.modalState, listener);
return await Promise.race([
action().then(() => {
this.off(TabEvents.modalState, listener);
return [];
}),
promise,
]);
}
async waitForCompletion(callback: () => Promise<void>) {
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
}
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
return (await this.refLocators([params]))[0];
}
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
const snapshot = await (this.page as PageEx)._snapshotForAI();
return params.map(param => {
if (!snapshot.includes(`[ref=${param.ref}]`))
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
});
}
async waitForTimeout(time: number) {
if (this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
}
@@ -162,3 +298,16 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
toString: () => String(errorOrValue),
};
}
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
const result: string[] = ['### Modal state'];
if (modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of modalStates) {
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
const tabSymbol = Symbol('tabSymbol');

View File

@@ -19,8 +19,10 @@ import console from './tools/console.js';
import dialogs from './tools/dialogs.js';
import evaluate from './tools/evaluate.js';
import files from './tools/files.js';
import form from './tools/form.js';
import install from './tools/install.js';
import keyboard from './tools/keyboard.js';
import mouse from './tools/mouse.js';
import navigate from './tools/navigate.js';
import network from './tools/network.js';
import pdf from './tools/pdf.js';
@@ -28,9 +30,10 @@ import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js';
import wait from './tools/wait.js';
import mouse from './tools/mouse.js';
import verify from './tools/verify.js';
import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
export const allTools: Tool<any>[] = [
...common,
@@ -38,6 +41,7 @@ export const allTools: Tool<any>[] = [
...dialogs,
...evaluate,
...files,
...form,
...install,
...keyboard,
...navigate,
@@ -48,4 +52,9 @@ export const allTools: Tool<any>[] = [
...snapshot,
...tabs,
...wait,
...verify,
];
export function filteredTools(config: FullConfig) {
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
}

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

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

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool, defineTool } from './tool.js';
const close = defineTool({
capability: 'core',
@@ -28,17 +28,14 @@ const close = defineTool({
type: 'readOnly',
},
handle: async context => {
await context.close();
return {
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
handle: async (context, params, response) => {
await context.closeBrowserContext();
response.setIncludeTabs();
response.addCode(`await page.close()`);
},
});
const resize = defineTool({
const resize = defineTabTool({
capability: 'core',
schema: {
name: 'browser_resize',
@@ -51,24 +48,12 @@ const resize = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
handle: async (tab, params, response) => {
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
const code = [
`// Resize browser window to ${params.width}x${params.height}`,
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
];
const action = async () => {
await tab.waitForCompletion(async () => {
await tab.page.setViewportSize({ width: params.width, height: params.height });
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
});
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
const console = defineTool({
const console = defineTabTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
@@ -26,19 +26,8 @@ const console = defineTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const messages = context.currentTabOrDie().consoleMessages();
const log = messages.map(message => message.toString()).join('\n');
return {
code: [`// <internal code to get console messages>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
handle: async (tab, params, response) => {
tab.consoleMessages().map(message => response.addResult(message.toString()));
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
const handleDialog = defineTool({
const handleDialog = defineTabTool({
capability: 'core',
schema: {
@@ -31,27 +31,20 @@ const handleDialog = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const dialogState = context.modalStates().find(state => state.type === 'dialog');
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
if (!dialogState)
throw new Error('No dialog visible');
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
context.clearModalState(dialogState);
const code = [
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
tab.clearModalState(dialogState);
await tab.waitForCompletion(async () => {
if (params.accept)
await dialogState.dialog.accept(params.promptText);
else
await dialogState.dialog.dismiss();
});
},
clearsModalState: 'dialog',

View File

@@ -16,8 +16,8 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
@@ -28,7 +28,7 @@ const evaluateSchema = z.object({
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
});
const evaluate = defineTool({
const evaluate = defineTabTool({
capability: 'core',
schema: {
name: 'browser_evaluate',
@@ -38,31 +38,22 @@ const evaluate = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code: string[] = [];
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
const snapshot = tab.snapshotOrDie();
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
locator = await tab.refLocator({ ref: params.ref, element: params.element });
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else {
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
}
return {
code,
action: async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
await tab.waitForCompletion(async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
});
},
});

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
const uploadFile = defineTool({
const uploadFile = defineTabTool({
capability: 'core',
schema: {
@@ -30,26 +30,19 @@ const uploadFile = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
if (!modalState)
throw new Error('No file chooser visible');
const code = [
`// <internal code to chose files ${params.paths.join(', ')}`,
];
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
const action = async () => {
tab.clearModalState(modalState);
await tab.waitForCompletion(async () => {
await modalState.fileChooser.setFiles(params.paths);
context.clearModalState(modalState);
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true,
};
});
},
clearsModalState: 'fileChooser',
});

61
src/tools/form.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* 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 { defineTabTool } from './tool.js';
import { generateLocator } from './utils.js';
import * as javascript from '../utils/codegen.js';
const fillForm = defineTabTool({
capability: 'core',
schema: {
name: 'browser_fill_form',
title: 'Fill form',
description: 'Fill multiple form fields',
inputSchema: z.object({
fields: z.array(z.object({
name: z.string().describe('Human-readable field name'),
type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the field'),
ref: z.string().describe('Exact target field reference from the page snapshot'),
value: z.string().describe('Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option.'),
})).describe('Fields to fill in'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
for (const field of params.fields) {
const locator = await tab.refLocator({ element: field.name, ref: field.ref });
const locatorSource = `await page.${await generateLocator(locator)}`;
if (field.type === 'textbox' || field.type === 'slider') {
await locator.fill(field.value);
response.addCode(`${locatorSource}.fill(${javascript.quote(field.value)});`);
} else if (field.type === 'checkbox' || field.type === 'radio') {
await locator.setChecked(field.value === 'true');
response.addCode(`${locatorSource}.setChecked(${javascript.quote(field.value)});`);
} else if (field.type === 'combobox') {
await locator.selectOption({ label: field.value });
response.addCode(`${locatorSource}.selectOption(${javascript.quote(field.value)});`);
}
}
},
});
export default [
fillForm,
];

View File

@@ -16,11 +16,10 @@
import { fork } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { z } from 'zod';
import { defineTool } from './tool.js';
import { fileURLToPath } from 'node:url';
const install = defineTool({
capability: 'core-install',
@@ -32,7 +31,7 @@ const install = defineTool({
type: 'destructive',
},
handle: async context => {
handle: async (context, params, response) => {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
@@ -50,11 +49,7 @@ const install = defineTool({
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
return {
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
response.setIncludeTabs();
},
});

View File

@@ -16,12 +16,12 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
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 = defineTool({
const pressKey = defineTabTool({
capability: 'core',
schema: {
@@ -34,22 +34,14 @@ const pressKey = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Press ${params.key}`);
response.addCode(`await page.keyboard.press('${params.key}');`);
const code = [
`// Press ${params.key}`,
`await page.keyboard.press('${params.key}');`,
];
const action = () => tab.page.keyboard.press(params.key);
return {
code,
action,
captureSnapshot: true,
waitForNetwork: true
};
await tab.waitForCompletion(async () => {
await tab.page.keyboard.press(params.key);
});
},
});
@@ -59,7 +51,7 @@ const typeSchema = elementSchema.extend({
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type = defineTool({
const type = defineTabTool({
capability: 'core',
schema: {
name: 'browser_type',
@@ -69,35 +61,25 @@ const type = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
handle: async (tab, params, response) => {
const locator = await tab.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
await tab.waitForCompletion(async () => {
if (params.slowly) {
response.setIncludeSnapshot();
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
} else {
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
}
if (params.slowly) {
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
steps.push(() => locator.pressSequentially(params.text));
} else {
code.push(`// Fill "${params.text}" into "${params.element}"`);
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
steps.push(() => locator.fill(params.text));
}
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
steps.push(() => locator.press('Enter'));
}
return {
code,
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
captureSnapshot: true,
waitForNetwork: true,
};
if (params.submit) {
response.setIncludeSnapshot();
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
await locator.press('Enter');
}
});
},
});

View File

@@ -15,13 +15,13 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const mouseMove = defineTool({
const mouseMove = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_move_xy',
@@ -34,23 +34,17 @@ const mouseMove = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Move mouse to (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
];
const action = () => tab.page.mouse.move(params.x, params.y);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
handle: async (tab, params, response) => {
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.x, params.y);
});
},
});
const mouseClick = defineTool({
const mouseClick = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_click_xy',
@@ -63,29 +57,23 @@ const mouseClick = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Click mouse at coordinates (${params.x}, ${params.y})`,
`await page.mouse.move(${params.x}, ${params.y});`,
`await page.mouse.down();`,
`await page.mouse.up();`,
];
const action = async () => {
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.up();`);
await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.x, params.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
});
},
});
const mouseDrag = defineTool({
const mouseDrag = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_drag_xy',
@@ -100,30 +88,21 @@ const mouseDrag = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const code = [
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
`await page.mouse.move(${params.startX}, ${params.startY});`,
`await page.mouse.down();`,
`await page.mouse.move(${params.endX}, ${params.endY});`,
`await page.mouse.up();`,
];
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
response.addCode(`await page.mouse.down();`);
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
response.addCode(`await page.mouse.up();`);
const action = async () => {
await tab.waitForCompletion(async () => {
await tab.page.mouse.move(params.startX, params.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(params.endX, params.endY);
await tab.page.mouse.up();
};
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
});
},
});

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTool, defineTabTool } from './tool.js';
const navigate = defineTool({
capability: 'core',
@@ -30,24 +30,16 @@ const navigate = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
const tab = await context.ensureTab();
await tab.navigate(params.url);
const code = [
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.setIncludeSnapshot();
response.addCode(`await page.goto('${params.url}');`);
},
});
const goBack = defineTool({
const goBack = defineTabTool({
capability: 'core',
schema: {
name: 'browser_navigate_back',
@@ -57,48 +49,14 @@ const goBack = defineTool({
type: 'readOnly',
},
handle: async context => {
const tab = await context.ensureTab();
handle: async (tab, params, response) => {
await tab.page.goBack();
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goForward = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
description: 'Go forward to the next page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = context.currentTabOrDie();
await tab.page.goForward();
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.setIncludeSnapshot();
response.addCode(`await page.goBack();`);
},
});
export default [
navigate,
goBack,
goForward,
];

View File

@@ -15,11 +15,11 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
import type * as playwright from 'playwright';
const requests = defineTool({
const requests = defineTabTool({
capability: 'core',
schema: {
@@ -30,19 +30,9 @@ const requests = defineTool({
type: 'readOnly',
},
handle: async context => {
const requests = context.currentTabOrDie().requests();
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
return {
code: [`// <internal code to list network requests>`],
action: async () => {
return {
content: [{ type: 'text', text: log }]
};
},
captureSnapshot: false,
waitForNetwork: false,
};
handle: async (tab, params, response) => {
const requests = tab.requests();
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
},
});

View File

@@ -15,16 +15,15 @@
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.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.'),
});
const pdf = defineTool({
const pdf = defineTabTool({
capability: 'pdf',
schema: {
@@ -35,21 +34,11 @@ const pdf = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
const code = [
`// Save page as ${fileName}`,
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
];
return {
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
handle: async (tab, params, response) => {
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
response.addResult(`Saved page as ${fileName}`);
await tab.page.pdf({ path: fileName });
},
});

View File

@@ -16,15 +16,14 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
@@ -41,7 +40,7 @@ const screenshotSchema = z.object({
path: ['fullPage']
});
const screenshot = defineTool({
const screenshot = defineTabTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
@@ -51,14 +50,12 @@ const screenshot = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
handle: async (tab, params, response) => {
const fileType = params.type || 'png';
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = {
type: fileType,
quality: fileType === 'png' ? undefined : 50,
quality: fileType === 'png' ? undefined : 90,
scale: 'css',
path: fileName,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
@@ -66,35 +63,27 @@ const screenshot = defineTool({
const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
const code = [
`// Screenshot ${screenshotTarget} and save it as ${fileName}`,
];
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
// Only get snapshot when element screenshot is needed
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
const includeBase64 = context.clientSupportsImages();
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false,
};
// 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

@@ -16,8 +16,8 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { defineTabTool, defineTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import { generateLocator } from './utils.js';
const snapshot = defineTool({
@@ -30,14 +30,9 @@ const snapshot = defineTool({
type: 'readOnly',
},
handle: async context => {
handle: async (context, params, response) => {
await context.ensureTab();
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
response.setIncludeSnapshot();
},
});
@@ -51,7 +46,7 @@ const clickSchema = elementSchema.extend({
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
});
const click = defineTool({
const click = defineTabTool({
capability: 'core',
schema: {
name: 'browser_click',
@@ -61,31 +56,29 @@ const click = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const locator = await tab.refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
const code: string[] = [];
if (params.doubleClick) {
code.push(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else {
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
if (params.doubleClick)
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
else
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
return {
code,
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.waitForCompletion(async () => {
if (params.doubleClick)
await locator.dblclick({ button });
else
await locator.click({ button });
});
},
});
const drag = defineTool({
const drag = defineTabTool({
capability: 'core',
schema: {
name: 'browser_drag',
@@ -100,26 +93,23 @@ const drag = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const code = [
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
const [startLocator, endLocator] = await tab.refLocators([
{ ref: params.startRef, element: params.startElement },
{ ref: params.endRef, element: params.endElement },
]);
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.waitForCompletion(async () => {
await startLocator.dragTo(endLocator);
});
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
},
});
const hover = defineTool({
const hover = defineTabTool({
capability: 'core',
schema: {
name: 'browser_hover',
@@ -129,21 +119,15 @@ const hover = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const code = [
`// Hover over ${params.element}`,
`await page.${await generateLocator(locator)}.hover();`
];
const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
return {
code,
action: () => locator.hover(),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.waitForCompletion(async () => {
await locator.hover();
});
},
});
@@ -151,7 +135,7 @@ const selectOptionSchema = elementSchema.extend({
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
});
const selectOption = defineTool({
const selectOption = defineTabTool({
capability: 'core',
schema: {
name: 'browser_select_option',
@@ -161,21 +145,15 @@ const selectOption = defineTool({
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
const locator = await tab.refLocator(params);
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
await tab.waitForCompletion(async () => {
await locator.selectOption(params.values);
});
},
});

View File

@@ -17,118 +17,48 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
const listTabs = defineTool({
const browserTabs = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_list',
title: 'List tabs',
description: 'List browser tabs',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.ensureTab();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: await context.listTabsMarkdown(),
}],
},
};
},
});
const selectTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_select',
title: 'Select a tab',
description: 'Select a tab by index',
name: 'browser_tabs',
title: 'Manage tabs',
description: 'List, create, close, or select a browser tab.',
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
const newTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_new',
title: 'Open a new tab',
description: 'Open a new tab',
inputSchema: z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
await context.newTab();
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
const closeTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_close',
title: 'Close a tab',
description: 'Close a tab',
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
action: z.enum(['list', 'new', 'close', 'select']).describe('Operation to perform'),
index: z.number().optional().describe('Tab index, used for close/select. If omitted for close, current tab is closed.'),
}),
type: 'destructive',
},
handle: async (context, params) => {
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
handle: async (context, params, response) => {
switch (params.action) {
case 'list': {
await context.ensureTab();
response.setIncludeTabs();
return;
}
case 'new': {
await context.newTab();
response.setIncludeTabs();
return;
}
case 'close': {
await context.closeTab(params.index);
response.setIncludeSnapshot();
return;
}
case 'select': {
if (params.index === undefined)
throw new Error('Tab index is required');
await context.selectTab(params.index);
response.setIncludeSnapshot();
return;
}
}
},
});
export default [
listTabs,
newTab,
selectTab,
closeTab,
browserTabs,
];

View File

@@ -14,21 +14,13 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
import type { Context } from '../context.js';
import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js';
export type ToolSchema<Input extends InputType> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
type InputType = z.Schema;
import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
import type { ToolSchema } from '../mcp/tool.js';
export type FileUploadModalState = {
type: 'fileChooser';
@@ -44,23 +36,35 @@ export type DialogModalState = {
export type ModalState = FileUploadModalState | DialogModalState;
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
export type ToolResult = {
code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
export type Tool<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
};
export type Tool<Input extends InputType = InputType> = {
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}
export type TabTool<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
};
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Tool<Input> {
return {
...tool,
handle: async (context, params, response) => {
const tab = context.currentTabOrDie();
const modalStates = tab.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
else if (!tool.clearsModalState && modalStates.length)
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
else
return tool.handle(tab, params, response);
},
};
}

View File

@@ -18,10 +18,9 @@
import { asLocator } from 'playwright-core/lib/utils';
import type * as playwright from 'playwright';
import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -65,21 +64,13 @@ export async function waitForCompletion<R>(context: Context, tab: Tab, callback:
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await context.waitForTimeout(1000);
await tab.waitForTimeout(1000);
return result;
} finally {
dispose();
}
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
try {
const { resolvedSelector } = await (locator as any)._resolveSelector();

149
src/tools/verify.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* 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 { defineTabTool } from './tool.js';
import * as javascript from '../utils/codegen.js';
import { generateLocator } from './utils.js';
const verifyElement = defineTabTool({
capability: 'verify',
schema: {
name: 'browser_verify_element_visible',
title: 'Verify element visible',
description: 'Verify element is visible on the page',
inputSchema: z.object({
role: z.string().describe('ROLE of the element. Can be found in the snapshot like this: \`- {ROLE} "Accessible Name":\`'),
accessibleName: z.string().describe('ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: \`- role "{ACCESSIBLE_NAME}"\`'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const locator = tab.page.getByRole(params.role as any, { name: params.accessibleName });
if (await locator.count() === 0) {
response.addError(`Element with role "${params.role}" and accessible name "${params.accessibleName}" not found`);
return;
}
response.addCode(`await expect(page.getByRole(${javascript.escapeWithQuotes(params.role)}, { name: ${javascript.escapeWithQuotes(params.accessibleName)} })).toBeVisible();`);
response.addResult('Done');
},
});
const verifyText = defineTabTool({
capability: 'verify',
schema: {
name: 'browser_verify_text_visible',
title: 'Verify text visible',
description: `Verify text is visible on the page. Prefer ${verifyElement.schema.name} if possible.`,
inputSchema: z.object({
text: z.string().describe('TEXT to verify. Can be found in the snapshot like this: \`- role "Accessible Name": {TEXT}\` or like this: \`- text: {TEXT}\`'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const locator = tab.page.getByText(params.text).filter({ visible: true });
if (await locator.count() === 0) {
response.addError('Text not found');
return;
}
response.addCode(`await expect(page.getByText(${javascript.escapeWithQuotes(params.text)})).toBeVisible();`);
response.addResult('Done');
},
});
const verifyList = defineTabTool({
capability: 'verify',
schema: {
name: 'browser_verify_list_visible',
title: 'Verify list visible',
description: 'Verify list is visible on the page',
inputSchema: z.object({
element: z.string().describe('Human-readable list description'),
ref: z.string().describe('Exact target element reference that points to the list'),
items: z.array(z.string()).describe('Items to verify'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const locator = await tab.refLocator({ ref: params.ref, element: params.element });
const itemTexts: string[] = [];
for (const item of params.items) {
const itemLocator = locator.getByText(item);
if (await itemLocator.count() === 0) {
response.addError(`Item "${item}" not found`);
return;
}
itemTexts.push((await itemLocator.textContent())!);
}
const ariaSnapshot = `\`
- list:
${itemTexts.map(t => ` - listitem: ${javascript.escapeWithQuotes(t, '"')}`).join('\n')}
\``;
response.addCode(`await expect(page.locator('body')).toMatchAriaSnapshot(${ariaSnapshot});`);
response.addResult('Done');
},
});
const verifyValue = defineTabTool({
capability: 'verify',
schema: {
name: 'browser_verify_value',
title: 'Verify value',
description: 'Verify element value',
inputSchema: z.object({
type: z.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']).describe('Type of the element'),
element: z.string().describe('Human-readable element description'),
ref: z.string().describe('Exact target element reference that points to the element'),
value: z.string().describe('Value to verify. For checkbox, use "true" or "false".'),
}),
type: 'readOnly',
},
handle: async (tab, params, response) => {
const locator = await tab.refLocator({ ref: params.ref, element: params.element });
const locatorSource = `page.${await generateLocator(locator)}`;
if (params.type === 'textbox' || params.type === 'slider' || params.type === 'combobox') {
const value = await locator.inputValue();
if (value !== params.value) {
response.addError(`Expected value "${params.value}", but got "${value}"`);
return;
}
response.addCode(`await expect(${locatorSource}).toHaveValue(${javascript.quote(params.value)});`);
} else if (params.type === 'checkbox' || params.type === 'radio') {
const value = await locator.isChecked();
if (value !== (params.value === 'true')) {
response.addError(`Expected value "${params.value}", but got "${value}"`);
return;
}
const matcher = value ? 'toBeChecked' : 'not.toBeChecked';
response.addCode(`await expect(${locatorSource}).${matcher}();`);
}
response.addResult('Done');
},
});
export default [
verifyElement,
verifyText,
verifyList,
verifyValue,
];

View File

@@ -32,14 +32,12 @@ const wait = defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
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,20 +46,17 @@ 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' });
}
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
response.setIncludeSnapshot();
},
});

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

25
src/utils/guid.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 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 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);
}

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import path from 'node:path';
import fs from 'fs';
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'));

6
src/vscode/DEPS.list Normal file
View File

@@ -0,0 +1,6 @@
[*]
../mcp/
../utils/
../config.js
../browserServerBackend.js
../browserContextFactory.js

149
src/vscode/host.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* 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 path from 'path';
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 { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import * as mcpServer from '../mcp/server.js';
import { logUnhandledError } from '../utils/log.js';
import { packageJSON } from '../utils/package.js';
import { FullConfig } from '../config.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { contextFactory } from '../browserContextFactory.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
const contextSwitchOptions = z.object({
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
lib: z.string().optional().describe('The library to use for the connection'),
});
class VSCodeProxyBackend implements ServerBackend {
name = 'Playwright MCP Client Switcher';
version = packageJSON.version;
private _currentClient: Client | undefined;
private _contextSwitchTool: Tool;
private _roots: Root[] = [];
private _clientVersion?: ClientVersion;
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
this._contextSwitchTool = this._defineContextSwitchTool();
}
async initialize(server: mcpServer.Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._clientVersion = clientVersion;
this._roots = roots;
const transport = await this._defaultTransportFactory();
await this._setCurrentClient(transport);
}
async listTools(): Promise<Tool[]> {
const response = await this._currentClient!.listTools();
return [
...response.tools,
this._contextSwitchTool,
];
}
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
if (name === this._contextSwitchTool.name)
return this._callContextSwitchTool(args as any);
return await this._currentClient!.callTool({
name,
arguments: args,
}) as CallToolResult;
}
serverClosed?(server: mcpServer.Server): void {
void this._currentClient?.close().catch(logUnhandledError);
}
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
if (!params.connectionString || !params.lib) {
const transport = await this._defaultTransportFactory();
await this._setCurrentClient(transport);
return {
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
};
}
await this._setCurrentClient(
new StdioClientTransport({
command: process.execPath,
cwd: process.cwd(),
args: [
path.join(fileURLToPath(import.meta.url), '..', 'main.js'),
JSON.stringify(this._config),
params.connectionString,
params.lib,
],
})
);
return {
content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }],
};
}
private _defineContextSwitchTool(): Tool {
return {
name: 'browser_connect',
description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.',
inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'],
annotations: {
title: 'Connect to a browser running in VS Code.',
readOnlyHint: true,
openWorldHint: false,
},
};
}
private async _setCurrentClient(transport: Transport) {
await this._currentClient?.close();
this._currentClient = undefined;
const client = new Client(this._clientVersion!);
client.registerCapabilities({
roots: {
listRoots: true,
},
});
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
client.setRequestHandler(PingRequestSchema, () => ({}));
await client.connect(transport);
this._currentClient = client;
}
}
export async function runVSCodeTools(config: FullConfig) {
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ vscode',
nameInConfig: 'playwright-vscode',
version: packageJSON.version,
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
};
await mcpServer.start(serverBackendFactory, config.server);
return;
}

75
src/vscode/main.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import * as mcpServer from '../mcp/server.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
import type { FullConfig } from '../config.js';
import type { BrowserContext } from 'playwright-core';
class VSCodeBrowserContextFactory implements BrowserContextFactory {
name = 'vscode';
description = 'Connect to a browser running in the Playwright VS Code extension';
constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise<void>; }> {
let launchOptions: any = this._config.browser.launchOptions;
if (this._config.browser.userDataDir) {
launchOptions = {
...launchOptions,
...this._config.browser.contextOptions,
userDataDir: this._config.browser.userDataDir,
};
}
const connectionString = new URL(this._connectionString);
connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions));
const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on
const browser = await browserType.connect(connectionString.toString());
const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions);
return {
browserContext: context,
close: async () => {
await browser.close();
}
};
}
}
async function main(config: FullConfig, connectionString: string, lib: string) {
const playwright = await import(lib).then(mod => mod.default ?? mod);
const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString);
await mcpServer.connect(
{
name: 'Playwright MCP',
nameInConfig: 'playwright-vscode',
create: () => new BrowserServerBackend(config, factory),
version: 'unused'
},
new StdioServerTransport(),
false
);
}
await main(
JSON.parse(process.argv[2]),
process.argv[3],
process.argv[4]
);

View File

@@ -24,6 +24,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_fill_form',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
@@ -31,16 +32,43 @@ test('test snapshot tool list', async ({ client }) => {
'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_tabs',
'browser_take_screenshot',
'browser_wait_for',
]));
});
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_fill_form',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate',
'browser_network_requests',
'browser_press_key',
'browser_resize',
'browser_snapshot',
'browser_tabs',
'browser_take_screenshot',
'browser_wait_for',
]));

View File

@@ -25,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
@@ -41,24 +43,21 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
})).toHaveResponse({
result: `Error: No open pages available. Use the "browser_navigate" tool to navigate to a page first.`,
isError: true,
});
expect(await client.callTool({
name: 'browser_snapshot',
})).toHaveTextContent(`
### Ran Playwright code
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD}
})).toHaveResponse({
pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
\`\`\`
`);
\`\`\``),
});
});
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
@@ -72,12 +71,17 @@ test('should throw connection error and allow re-connecting', async ({ cdpServer
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
})).toHaveResponse({
result: expect.stringContaining(`Error: browserType.connectOverCDP: connect ECONNREFUSED`),
isError: true,
});
await cdpServer.start();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.

Some files were not shown because too many files have changed in this diff Show More