106 Commits

Author SHA1 Message Date
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
Copilot
efe3ff0c7c Add test for browser_evaluate error handling (#719) 2025-07-19 20:12:32 -07:00
Yury Semikhatsky
e3df209b96 chore(extension): support running in http mode (#717) 2025-07-19 08:30:29 -07:00
Pavel Feldman
29711d07d3 chore: use streamable http by default (#716)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2025-07-18 18:31:00 -07:00
Copilot
b0be1ee256 chore: add GitHub Copilot agent YAML specification (#715) 2025-07-18 18:03:23 -07:00
Yury Semikhatsky
d3867affed chore: add mcp chrome extension (#710) 2025-07-18 17:12:44 -07:00
Copilot
1eee30fd45 feat: add fullPage mode to browser_take_screenshot (#704) 2025-07-18 13:56:43 -07:00
Copilot
29ac29e6bb fix: no-sandbox flag logic to only disable sandbox when explicitly passed (#709) 2025-07-18 13:56:01 -07:00
Adam Gastineau
9f8441daa5 chore(docs): make VSCode match other README sections (#706) 2025-07-18 11:21:29 -07:00
Pavel Feldman
64f950ae42 chore: mark v0.0.31 (#691) 2025-07-17 16:04:21 -07:00
Pavel Feldman
5bfff0a059 chore: include recent console logs in results (#689) 2025-07-17 14:58:44 -07:00
Pavel Feldman
c97bc6e2ae chore: allow right click (#687)
Fixes https://github.com/microsoft/playwright-mcp/issues/467
2025-07-17 13:24:05 -07:00
Pavel Feldman
fe0c0ffffe chore: mirror cli options w/ env vars (#685)
Fixes https://github.com/microsoft/playwright-mcp/issues/639
2025-07-17 10:19:18 -07:00
Pavel Feldman
9526910864 chore: sort install sections (#682) 2025-07-17 09:06:10 -07:00
Pavel Feldman
95454735bf chore: remove image reply special case in cursor (#680) 2025-07-16 18:32:07 -07:00
Pavel Feldman
e9f6433241 chore: remove server experiment (#681) 2025-07-16 18:05:47 -07:00
Pavel Feldman
d61aa16fee chore: turn vision into capability (#679)
Fixes https://github.com/microsoft/playwright-mcp/issues/420
2025-07-16 16:40:00 -07:00
Pavel Feldman
012c906500 chore: introduce browser_evaluate (#678)
Fixes https://github.com/microsoft/playwright-mcp/issues/424
2025-07-16 15:02:47 -07:00
Pavel Feldman
825a97d66e chore: remove generate_test tool for now - it adds no value (#675) 2025-07-16 13:33:05 -07:00
Pavel Feldman
3061d9aa56 chore: resolve dialog races (#673)
Fixes https://github.com/microsoft/playwright-mcp/issues/595
2025-07-16 13:32:54 -07:00
Pavel Feldman
da818d113a chore: make tab indexes 0-based (#674)
Fixes https://github.com/microsoft/playwright-mcp/issues/570
2025-07-16 09:55:08 -07:00
Pavel Feldman
a5a57df105 chore: include page errors in console messages (#671)
Fixes https://github.com/microsoft/playwright-mcp/issues/669
2025-07-15 15:46:09 -07:00
Pavel Feldman
be8adb1866 chore: migrate to locator._resolveSelector (#670) 2025-07-15 14:50:33 -07:00
Pavel Feldman
c5a2324aaf chore: mark v0.0.30 (#666) 2025-07-14 10:53:12 -07:00
Pavel Feldman
128474b4aa chore: remove extension code (#667) 2025-07-14 10:52:38 -07:00
Pavel Feldman
7fca8f50f8 chore: roll Playwright to 1.54.1 (#665) 2025-07-14 09:51:14 -07:00
Simon Knott
841bb417d1 chore: update to 1.54.0 (#653)
Closes https://github.com/microsoft/playwright-mcp/issues/535
2025-07-14 09:53:33 +02:00
Pavel Feldman
59f1d67a4e feat(dblclick): add double click (#654)
Fixes https://github.com/microsoft/playwright-mcp/issues/652
2025-07-11 16:45:39 -07:00
おがどら
1600ba6645 docs: Update README about imageResponses option. (#646) 2025-07-09 17:40:22 -07:00
Joah Gerstenberg
127c996e86 docs: add instructions to install in Goose (#580) 2025-07-09 17:39:41 -07:00
Sandor Major
4bd39c07e9 docs: adding installation steps for Gemini CLI (#625)
I just tried it out with Gemini CLI and it works like a charm, thanks
for creating this MCP server!
2025-07-09 17:37:29 -07:00
Max Schmitt
f5b68dc590 devops(docker): enhance Docker image publishing with ORAS end-of-life tagging (#641)
This tags the images we publish as EOL immediately in order to get
excluded from the image scanning. Like we do upstream in
microsoft/playwright.
2025-07-07 23:08:12 +02:00
Mehul Raheja
875bd3b6ec fix(docs): Fix typo of windsurf in readme (#620) 2025-07-02 09:54:36 +02:00
Yury Semikhatsky
137b74750c chore(extension): wrap CDP protocol (#604) 2025-06-26 16:21:59 -07:00
Yury Semikhatsky
ded00dc422 chore(extension): convert to typescript (#603) 2025-06-26 13:52:08 -07:00
Yury Semikhatsky
5df6c2431b chore(extension): support reconnect, implement relay-extension protocol (#602) 2025-06-26 11:12:23 -07:00
Simon Knott
9066988098 chore: improve "ref not found" error message (#561)
Helps the model better understand the error cause.
2025-06-17 14:09:29 +02:00
jito(지토)
1dc4977ff9 docs: add Claude Code installation instructions (#553)
Add installation instructions for Claude Code CLI to the README.
2025-06-16 13:35:46 +02:00
Yury Semikhatsky
96e234012d chore(extension): start relay before creating MCP server (#548)
* HTTPS server launched and the relay server is created before MCP
server. This way we can pass CDP endpoint to its constructor.
* MCP HTTP transport is added to precreated HTTP server.
* A bunch of renames to fix style issues.
2025-06-13 16:13:40 -07:00
Max Schmitt
6c3f3b6576 feat: add MCP Chrome extension (#325)
Instructions:

1. `git clone https://github.com/mxschmitt/playwright-mcp && git
checkout extension-drafft`
2. `npm ci && npm run build`
3. `chrome://extensions` in your normal Chrome, "load unpacked" and
select the extension folder.
4. `node cli.js --port=4242 --extension` - The URL it prints at the end
you can put into the extension popup.
5. 
Put either this into Claude Desktop (it does not support SSE yet hence
wrapping it or just put the URL into Cursor/VSCode)

```json
{
  "mcpServers": {
    "playwright": {
      "command": "bash",
      "args": [
        "-c",
        "source $HOME/.nvm/nvm.sh && nvm use --silent 22 && npx supergateway --streamableHttp http://127.0.0.1:4242/mcp"
      ]
    }
  }
}
```

Things like `Take a snapshot of my browser.` should now work in your
Prompt Chat.

----

- SSE only for now, since we already have a http server with a port
there
- Upstream "page tests" can be executed over this CDP relay via
https://github.com/microsoft/playwright/pull/36286
- Limitations for now are everything what happens outside of the tab its
session is shared with -> `window.open` / `target=_blank`.

---------

Co-authored-by: Yury Semikhatsky <yurys@chromium.org>
2025-06-13 13:15:17 -07:00
Dmitry Gozman
0df6d7a441 chore: roll playwright to Jun 10th, v1.53 (#542)
Co-authored-by: Simon Knott <simonknott@microsoft.com>
2025-06-11 15:53:14 +01:00
Dmitry Gozman
4ea7041ba9 chore: mark v0.0.29 (#541) 2025-06-11 12:00:52 +01:00
Dan O'Brien
7dae68de78 docs: add instructions for MCP server in Qodo Gen (#530) 2025-06-08 10:38:24 -07:00
Peter Goldstein
60495ed9b0 docs: include Cursor One-Click in README.md (#531) 2025-06-08 10:37:48 -07:00
cranemont
0aaef661b1 docs(readme): fix connection method call in programmatic usage example (#532) 2025-06-08 10:36:27 -07:00
Max Schmitt
abbe7858a2 test: add PWMCP_DEBUG env switch (#523) 2025-06-05 10:40:03 -07:00
Simon Knott
767af21e02 chore: fix Connection type (#517)
The external `Connection` type regressed in
https://github.com/microsoft/playwright-mcp/pull/490/files#diff-a6be0583428e46844273df76939f02077073da3075716fc57d291a5f2463eaf5,
where the `connect()` function was removed but not from the types. I've
changed the code so we import from there, similar to how we do it for
`config.d.ts`, so this shouldn't happen again.
2025-06-05 08:47:04 +02:00
Pavel Feldman
27c498e0e7 chore: rename browser agent to server (#521) 2025-06-04 16:43:11 -07:00
Pavel Feldman
0fb9646c4d chore: experimental agent mode (#516) 2025-06-04 09:14:50 -07:00
Simon Knott
9728527900 chore: typo (#513) 2025-06-03 11:10:47 -07:00
112 changed files with 9184 additions and 2575 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
@@ -32,11 +32,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -55,10 +54,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
@@ -83,3 +82,42 @@ jobs:
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1
test_extension:
strategy:
fail-fast: false
runs-on: macos-latest
defaults:
run:
working-directory: ./extension
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: extension
path: ./extension/dist
retention-days: 7
- name: Install and build MCP server
run: |
cd ..
npm ci
npm run build
npx playwright install chromium
- name: Run tests
run: |
if [[ "$(uname)" == "Linux" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
else
npm run test
fi
shell: bash

View File

@@ -0,0 +1,44 @@
name: "Copilot Setup Steps"
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# You can define any steps you want, and they will run before the agent starts.
# If you do not check out your code, Copilot will do this for you.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18.19"
cache: "npm"
- name: Install JavaScript dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Build
run: npm run build

View File

@@ -44,6 +44,7 @@ jobs:
- name: Login to ACR
run: az acr login --name playwright
- name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v6
with:
context: .
@@ -53,3 +54,17 @@ jobs:
tags: |
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
playwright.azurecr.io/public/playwright/mcp:latest
- uses: oras-project/setup-oras@v1
- name: Set oras tags
run: |
attach_eol_manifest() {
local image="$1"
local today=$(date -u +'%Y-%m-%d')
# oras is re-using Docker credentials, so we don't need to login.
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
}
# for each tag, attach the eol manifest
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
attach_eol_manifest $tag
done

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/

531
README.md
View File

@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Requirements
- Node.js 18 or newer
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
<!--
// Generate using:
@@ -19,7 +19,9 @@ node utils/generate-links.js
### Getting started
First, install the Playwright MCP server with your client. A typical configuration looks like this:
First, install the Playwright MCP server with your client.
**Standard config** works in most of the tools:
```js
{
@@ -37,9 +39,85 @@ First, install the Playwright MCP server with your client. A typical configurati
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
<details><summary><b>Install in VS Code</b></summary>
<details>
<summary>Claude Code</summary>
You can also install the Playwright MCP server using the VS Code CLI:
Use the Claude Code CLI to add the Playwright MCP server:
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
</details>
<details>
<summary>Claude Desktop</summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
</details>
<details>
<summary>Cursor</summary>
#### Click the button to install:
[![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`.
</details>
<details>
<summary>Gemini CLI</summary>
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
</details>
<details>
<summary>Goose</summary>
#### Click the button to install:
[![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
#### Or install manually:
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>Qodo Gen</summary>
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
Click <code>Save</code>.
</details>
<details>
<summary>VS Code</summary>
#### Click the button to install:
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
#### Or install manually:
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
```bash
# For VS Code
@@ -50,60 +128,10 @@ After installation, the Playwright MCP server will be available for use with you
</details>
<details>
<summary><b>Install in Cursor</b></summary>
<summary>Windsurf</summary>
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`.
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Windsurf</b></summary>
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Desktop</b></summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
### Configuration
@@ -124,9 +152,8 @@ Playwright MCP server supports following arguments. They can be provided in the
--block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge.
--caps <caps> comma-separated list of capabilities to enable,
possible values: tabs, pdf, history, wait, files,
install. Default is all.
--caps <caps> comma-separated list of additional capabilities
to enable, possible values: vision, pdf.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
@@ -138,9 +165,7 @@ Playwright MCP server supports following arguments. They can be provided in the
--isolated keep the browser profile in memory, do not save
it to disk.
--image-responses <mode> whether to send image responses to the client.
Can be "allow", "omit", or "auto". Defaults to
"auto", which sends images if the client can
display them.
Can be "allow" or "omit", Defaults to "allow".
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
@@ -149,6 +174,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
@@ -158,8 +185,6 @@ Playwright MCP server supports following arguments. They can be provided in the
specified, a temporary directory will be created.
--viewport-size <size> specify browser viewport size in pixels, for
example "1280, 720"
--vision Run server that uses screenshots (Aria snapshots
are used by default)
```
<!--- End of options generated section -->
@@ -260,21 +285,14 @@ npx @playwright/mcp@latest --config path/to/config.json
host?: string; // Host to bind to (default: localhost)
},
// List of enabled capabilities
// List of additional capabilities
capabilities?: Array<
'core' | // Core browser automation
'tabs' | // Tab management
'pdf' | // PDF generation
'history' | // Browser history
'wait' | // Wait utilities
'files' | // File handling
'install' | // Browser installation
'testing' // Testing
'pdf' | // PDF generation
'vision' | // Coordinate-based interactions
>;
// Enable vision mode (screenshots instead of accessibility snapshots)
vision?: boolean;
// Directory for output files
outputDir?: string;
@@ -288,9 +306,10 @@ npx @playwright/mcp@latest --config path/to/config.json
};
/**
* Do not send image responses to the client.
* Whether to send image responses to the client. Can be "allow" or "omit".
* Defaults to "allow".
*/
noImageResponses?: boolean;
imageResponses?: 'allow' | 'omit';
}
```
</details>
@@ -298,19 +317,19 @@ npx @playwright/mcp@latest --config path/to/config.json
### Standalone MCP server
When running headed browser on system w/o display or from worker processes of the IDEs,
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
```bash
npx @playwright/mcp@latest --port 8931
```
And then in MCP client config, set the `url` to the SSE endpoint:
And then in MCP client config, set the `url` to the HTTP endpoint:
```js
{
"mcpServers": {
"playwright": {
"url": "http://localhost:8931/sse"
"url": "http://localhost:8931/mcp"
}
}
}
@@ -354,7 +373,7 @@ http.createServer(async (req, res) => {
// Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport);
await connection.sever.connect(transport);
// ...
});
@@ -363,42 +382,10 @@ http.createServer(async (req, res) => {
### Tools
The tools are available in two modes:
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
2. **Vision Mode**: Uses screenshots for visual-based interactions
To use Vision Mode, add the `--vision` flag when starting the server:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--vision"
]
}
}
}
```
Vision Mode works best with the computer use models that are able to interact with elements using
X Y coordinate space, based on the provided screenshot.
<!--- Tools generated by update-readme.js -->
<details>
<summary><b>Interactions</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<summary><b>Core automation</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
@@ -408,10 +395,28 @@ X Y coordinate space, based on the provided screenshot.
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
- `button` (string, optional): Button to click, defaults to left
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_drag**
- Title: Drag mouse
- Description: Perform drag and drop between two elements
@@ -424,60 +429,17 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_hover**
- Title: Hover mouse
- Description: Hover over element on page
- **browser_evaluate**
- Title: Evaluate JavaScript
- Description: Evaluate JavaScript expression on page or element
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Title: Type text
- Description: Type text into editable element
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): 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.
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
- `ref` (string, optional): Exact target element reference from the page snapshot
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
@@ -495,10 +457,15 @@ X Y coordinate space, based on the provided screenshot.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false**
</details>
<!-- NOTE: This has been generated via update-readme.js -->
<details>
<summary><b>Navigation</b></summary>
- **browser_hover**
- Title: Hover mouse
- Description: Hover over element on page
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -525,32 +492,6 @@ X Y coordinate space, based on the provided screenshot.
- Parameters: None
- Read-only: **true**
</details>
<details>
<summary><b>Resources</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_take_screenshot**
- 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.
- `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.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters:
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_network_requests**
@@ -561,35 +502,15 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Read-only: **true**
</details>
<details>
<summary><b>Utilities</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_install**
- Title: Install the browser specified in the config
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Title: Resize browser window
- Description: Resize the browser window
@@ -598,10 +519,75 @@ X Y coordinate space, based on the provided screenshot.
- `height` (number): Height of the browser window
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_take_screenshot**
- 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:
- `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.
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Title: Type text
- Description: Type text into editable element
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): 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.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
</details>
<details>
<summary><b>Tabs</b></summary>
<summary><b>Tab management</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Title: Close a tab
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -629,60 +615,29 @@ X Y coordinate space, based on the provided screenshot.
- `index` (number): The index of the tab to select
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Title: Close a tab
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false**
</details>
<details>
<summary><b>Testing</b></summary>
<summary><b>Browser installation</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_generate_playwright_test**
- Title: Generate a Playwright test
- Description: Generate a Playwright test for given scenario
- Parameters:
- `name` (string): The name of the test
- `description` (string): The description of the test
- `steps` (array): The steps of the test
- Read-only: **true**
</details>
<details>
<summary><b>Vision mode</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_capture**
- Title: Take a screenshot
- Description: Take a screenshot of the current page
- **browser_install**
- Title: Install the browser specified in the config
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
- Read-only: **true**
- Read-only: **false**
</details>
<details>
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_move_mouse**
- Title: Move mouse
- Description: Move mouse to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_click**
- **browser_mouse_click_xy**
- Title: Click
- Description: Click left mouse button
- Description: Click left mouse button at a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
@@ -691,9 +646,9 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_drag**
- **browser_mouse_drag_xy**
- Title: Drag mouse
- Description: Drag left mouse button
- Description: Drag left mouse button to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
@@ -704,52 +659,28 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_type**
- Title: Type text
- Description: Type text
- **browser_mouse_move_xy**
- Title: Move mouse
- Description: Move mouse to a given position
- Parameters:
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
</details>
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- Read-only: **false**
<details>
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
- **browser_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters:
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false**
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
</details>

14
config.d.ts vendored
View File

@@ -16,7 +16,7 @@
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
export type Config = {
/**
@@ -80,19 +80,15 @@ export type Config = {
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'tabs': Tab management features.
* - 'pdf': PDF generation and manipulation.
* - 'history': Browser history access.
* - 'wait': Wait and timing utilities.
* - 'files': File upload/download support.
* - 'install': Browser installation utilities.
* - 'vision': Coordinate-based interactions.
*/
capabilities?: ToolCapability[];
/**
* Run server that uses screenshots (Aria snapshots are used by default).
* Whether to save the Playwright session into the output directory.
*/
vision?: boolean;
saveSession?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
@@ -119,5 +115,5 @@ export type Config = {
/**
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/
imageResponses?: 'allow' | 'omit' | 'auto';
imageResponses?: 'allow' | 'omit';
};

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,
},
},
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
extension/icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

BIN
extension/icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
extension/icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

40
extension/manifest.json Normal file
View File

@@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "1.0.0",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"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": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.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.32",
"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' } },
],
});

190
extension/src/background.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* 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 { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId: number;
windowId: number;
mcpRelayUrl: string;
};
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));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._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':
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
}
return false;
}
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('WebSocket error'));
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
const connection = new RelayConnection(socket);
connection.onclose = () => {
debugLog('Connection closed');
this._pendingTabSelection.delete(selectorTabId);
// TODO: show error in the selector tab?
};
this._pendingTabSelection.set(selectorTabId, { connection });
debugLog(`Connected to MCP relay`);
} catch (error: any) {
debugLog(`Failed to connect to MCP relay:`, error.message);
throw error;
}
}
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try {
this._activeConnection?.close('Another connection is requested');
} catch (error: any) {
debugLog(`Error closing active connection:`, error);
}
await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection)
throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => {
debugLog('MCP connection closed');
this._activeConnection = undefined;
void this._setConnectedTabId(null);
};
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;
}
}
private async _setConnectedTabId(tabId: number | null): Promise<void> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '', color: null });
if (tabId)
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
}
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
try {
await chrome.action.setBadgeText({ tabId, text });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
} catch (error: any) {
// Ignore errors as the tab may be closed already.
}
}
private async _onTabRemoved(tabId: number): Promise<void> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
return;
}
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
this._activeConnection = undefined;
this._connectedTabId = null;
}
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 (changeInfo.status === 'complete' && 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)));
}
}
new TabShareExtension();

View File

@@ -0,0 +1,178 @@
/**
* 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.
*/
export function debugLog(...args: unknown[]): void {
const enabled = true;
if (enabled) {
// eslint-disable-next-line no-console
console.log('[Extension]', ...args);
}
}
type ProtocolCommand = {
id: number;
method: string;
params?: any;
};
type ProtocolResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
export class RelayConnection {
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);
chrome.debugger.onEvent.addListener(this._eventListener);
chrome.debugger.onDetach.addListener(this._detachListener);
}
// Either setTabId or close is called after creating the connection.
setTabId(tabId: number): void {
this._debuggee = { tabId };
this._tabPromiseResolve();
}
close(message: string): void {
this._ws.close(1000, message);
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
// CDP events to the closed connection.
this._onClose();
}
private _onClose() {
if (this._closed)
return;
this._closed = true;
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
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._sendMessage({
method: 'forwardCDPEvent',
params: {
sessionId,
method,
params,
},
});
}
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
if (source.tabId !== this._debuggee.tabId)
return;
this.close(`Debugger detached: ${reason}`);
this._debuggee = { };
}
private _onMessage(event: MessageEvent): void {
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
}
private async _onMessageAsync(event: MessageEvent): Promise<void> {
let message: ProtocolCommand;
try {
message = JSON.parse(event.data);
} catch (error: any) {
debugLog('Error parsing message:', error);
this._sendError(-32700, `Error parsing message: ${error.message}`);
return;
}
debugLog('Received message:', message);
const response: ProtocolResponse = {
id: message.id,
};
try {
response.result = await this._handleCommand(message);
} catch (error: any) {
debugLog('Error handling command:', error);
response.error = error.message;
}
debugLog('Sending response:', response);
this._sendMessage(response);
}
private async _handleCommand(message: ProtocolCommand): Promise<any> {
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 {
targetInfo: result?.targetInfo,
};
}
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);
const debuggerSession: chrome.debugger.DebuggerSession = {
...this._debuggee,
sessionId,
};
// Forward CDP command to chrome.debugger
return await chrome.debugger.sendCommand(
debuggerSession,
method,
params
);
}
}
private _sendError(code: number, message: string): void {
this._sendMessage({
error: {
code,
message,
},
});
}
private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN)
this._ws.send(JSON.stringify(message));
}
}

View File

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

View File

@@ -0,0 +1,27 @@
<!--
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.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="connect.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="connect.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,205 @@
/**
* 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';
interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
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;
}
void connectToMCPRelay(relayUrl);
void loadTabs();
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
}, []);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
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]);
const handleReject = useCallback(() => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject();
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, []);
return (
<div className='app-container'>
<div className='content-wrapper'>
{status && (
<div className='status-container'>
<StatusBanner type={status.type} message={status.message} />
{showButtons && (
<Button variant='reject' onClick={handleReject}>
Reject
</Button>
)}
</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}
onConnect={() => handleConnectToTab(tab)}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
return <div className={`status-banner ${type}`}>{message}</div>;
};
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>
);
};
const TabItem: React.FC<{ tab: TabInfo; onConnect: () => void }> = ({
tab,
onConnect
}) => {
return (
<div className='tab-item'>
<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 variant='primary' onClick={onConnect}>
Connect
</Button>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}

View File

@@ -0,0 +1,4 @@
// Help VSCode to find right tsconfig file.
{
"extends": "../../tsconfig.ui.json"
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
type BrowserWithExtension = {
userDataDir: string;
launch: () => Promise<BrowserContext>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async () => {
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: [
`--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();
},
});
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
method: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});

21
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"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",
],
}

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

@@ -0,0 +1,55 @@
/**
* 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',
output: {
manualChunks: undefined,
inlineDynamicImports: true,
entryFileNames: 'lib/ui/[name].js',
chunkFileNames: 'lib/ui/[name].js',
assetFileNames: 'lib/ui/[name].[ext]'
}
}
}
});

11
index.d.ts vendored
View File

@@ -16,15 +16,8 @@
*/
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Config } from './config.js';
import type { BrowserContext } from 'playwright';
export type Connection = {
server: Server;
connect(transport: Transport): Promise<void>;
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 {};

924
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.28",
"version": "0.0.33",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -18,12 +18,14 @@
"scripts": {
"build": "tsc",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"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",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
@@ -35,25 +37,34 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"playwright": "1.53.0-alpha-2025-05-27",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-2025-08-07",
"playwright-core": "1.55.0-alpha-2025-08-07",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-2025-05-27",
"@playwright/test": "1.55.0-alpha-2025-08-07",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@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": {

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,39 +14,48 @@
* limitations under the License.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import fs from 'fs';
import net from 'net';
import path from 'path';
import debug from 'debug';
import * as playwright from 'playwright';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
import { logUnhandledError, testDebug } from './log.js';
import { createHash } from './utils.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
const testDebug = debug('pw:mcp:test');
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(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
readonly name: string;
readonly description: string;
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
protected _browserPromise: Promise<playwright.Browser> | undefined;
readonly name: string;
readonly description: string;
readonly config: FullConfig;
protected _browserPromise: Promise<playwright.Browser> | undefined;
protected _tracesDir: string | undefined;
constructor(name: string, browserConfig: FullConfig['browser']) {
constructor(name: string, description: string, config: FullConfig) {
this.name = name;
this.browserConfig = browserConfig;
this.description = description;
this.config = config;
}
protected async _obtainBrowser(): Promise<playwright.Browser> {
@@ -68,7 +77,10 @@ class BaseContextFactory implements BrowserContextFactory {
throw new Error('Not implemented');
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this.config.saveTrace)
this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser);
@@ -83,23 +95,25 @@ class BaseContextFactory implements BrowserContextFactory {
testDebug(`close browser context (${this.name})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(() => {});
await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
await browser.close().catch(() => {});
await browser.close().catch(logUnhandledError);
}
}
}
class IsolatedContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('isolated', browserConfig);
constructor(config: FullConfig) {
super('isolated', 'Create a new isolated browser context', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const browserType = playwright[this.browserConfig.browserName];
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
...this.browserConfig.launchOptions,
tracesDir: this._tracesDir,
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
@@ -110,35 +124,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', 'Connect to a browser over 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', 'Connect to a browser using a remote endpoint', 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> {
@@ -147,26 +161,34 @@ 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> }> {
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);
let tracesDir: string | undefined;
if (this.config.saveTrace)
tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
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,
});
@@ -194,18 +216,29 @@ 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;
}
}
async function injectCdpPort(browserConfig: FullConfig['browser']) {
if (browserConfig.browserName === 'chromium')
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
}
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}

142
src/browserServerBackend.ts Normal file
View File

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

View File

@@ -15,21 +15,20 @@
*/
import fs from 'fs';
import net from 'net';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './utils.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[];
blockedOrigins?: string[];
blockServiceWorkers?: boolean;
browser?: string;
caps?: string;
caps?: string[];
cdpEndpoint?: string;
config?: string;
device?: string;
@@ -38,18 +37,18 @@ export type CLIOptions = {
host?: string;
ignoreHttpsErrors?: boolean;
isolated?: boolean;
imageResponses?: 'allow' | 'omit' | 'auto';
sandbox: boolean;
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveSession?: boolean;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
vision?: boolean;
};
const defaultConfig: FullConfig = {
@@ -69,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']>;
@@ -81,7 +80,7 @@ export type FullConfig = Config & {
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
saveTrace: boolean;
server: NonNullable<Config['server']>,
};
@@ -91,17 +90,16 @@ export async function resolveConfig(config: Config): Promise<FullConfig> {
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions);
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
if (result.browser.browserName === 'chromium')
(result.browser.launchOptions as any).cdpPort = await findFreePort();
const envOverrides = configFromEnv();
const cliOverrides = configFromCLIOptions(cliOptions);
let result = defaultConfig;
result = mergeConfig(result, configInFile);
result = mergeConfig(result, envOverrides);
result = mergeConfig(result, cliOverrides);
return result;
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined;
switch (cliOptions.browser) {
@@ -133,7 +131,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
};
// --no-sandbox was passed, disable the sandbox
if (!cliOptions.sandbox)
if (cliOptions.sandbox === false)
launchOptions.chromiumSandbox = false;
if (cliOptions.proxyServer) {
@@ -144,6 +142,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
launchOptions.proxy.bypass = cliOptions.proxyBypass;
}
if (cliOptions.device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with cdpEndpoint.');
// Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
if (cliOptions.storageState)
@@ -182,12 +183,12 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
port: cliOptions.port,
host: cliOptions.host,
},
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
capabilities: cliOptions.caps as ToolCapability[],
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
@@ -196,15 +197,34 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
return result;
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
function configFromEnv(): Config {
const options: CLIOptions = {};
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
return configFromCLIOptions(options);
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
@@ -218,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> {
@@ -232,6 +256,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: FullConfig['browser'] = {
...pickDefined(base.browser),
...pickDefined(overrides.browser),
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: {
@@ -243,9 +269,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions),
},
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
};
if (browser.browserName !== 'chromium' && browser.launchOptions)
@@ -265,3 +288,33 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
},
} as FullConfig;
}
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(';').map(v => v.trim());
}
export function commaSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(',').map(v => v.trim());
}
function envToNumber(value: string | undefined): number | undefined {
if (!value)
return undefined;
return +value;
}
function envToBoolean(value: string | undefined): boolean | undefined {
if (value === 'true' || value === '1')
return true;
if (value === 'false' || value === '0')
return false;
return undefined;
}
function envToString(value: string | undefined): string | undefined {
return value ? value.trim() : undefined;
}

View File

@@ -1,98 +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 { snapshotTools, visionTools } 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 allTools = config.vision ? visionTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === '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,79 +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 './log.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.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 _isRunningTool: boolean = false;
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 === 'allow')
return true;
if (this.config.imageResponses === 'omit')
return false;
return !this.clientVersion?.name.includes('cursor');
}
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 of 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;
}
@@ -101,8 +90,12 @@ export class Context {
}
async selectTab(index: number) {
this._currentTab = this._tabs[index - 1];
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> {
@@ -112,162 +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 + 1}:${current} [${title}] (${url})`);
}
return lines.join('\n');
async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
const url = tab.page.url();
await tab.page.close();
return url;
}
async closeTab(index: number | undefined) {
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
await tab?.page.close();
return await this.listTabsMarkdown();
}
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;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
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.
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try {
if (waitForNetwork)
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else
actionResult = await racingAction?.() ?? 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'),
}],
};
}
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()} ...`);
}
result.push('');
}
if (this.tabs().length > 1)
result.push(await this.listTabsMarkdown(), '');
if (this.tabs().length > 1)
result.push('### Current tab');
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.title()}`
);
if (captureSnapshot && tab.hasSnapshot())
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) {
@@ -278,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;
@@ -287,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._isRunningTool;
}
setRunningTool(isRunningTool: boolean) {
this._isRunningTool = isRunningTool;
}
private async _closeBrowserContextImpl() {
if (!this._browserContextPromise)
return;
@@ -306,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'));
@@ -331,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();
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
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));
@@ -349,3 +221,56 @@ ${code.join('\n')}
return result;
}
}
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);
},
});
}
}

405
src/extension/cdpRelay.ts Normal file
View File

@@ -0,0 +1,405 @@
/**
* 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.
*/
/**
* WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp/guid - Full CDP interface for Playwright MCP
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import { spawn } from 'child_process';
import http from 'http';
import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../httpServer.js';
import { logUnhandledError } from '../log.js';
import { ManualPromise } from '../manualPromise.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
const debugLogger = debug('pw:mcp:relay');
type CDPCommand = {
id: number;
sessionId?: string;
method: string;
params?: any;
};
type CDPResponse = {
id?: number;
sessionId?: string;
method?: string;
params?: any;
result?: any;
error?: { code?: number; message: string };
};
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: {
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._resetExtensionConnection();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
cdpEndpoint() {
return `${this._wsHost}${this._cdpPath}`;
}
extensionEndpoint() {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
this._connectBrowser(clientInfo);
debugLogger('Waiting for incoming extension connection');
await Promise.race([
this._extensionConnectionPromise,
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]);
debugLogger('Extension connection established');
}
private _connectBrowser(clientInfo: ClientInfo) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString();
const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo)
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
const 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.closeConnections('Server stopped');
this._wss.close();
}
closeConnections(reason: string) {
this._closePlaywrightConnection(reason);
this._closeExtensionConnection(reason);
}
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
const url = new URL(`http://localhost${request.url}`);
debugLogger(`New connection to ${url.pathname}`);
if (url.pathname === this._cdpPath) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === this._extensionPath) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
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: any) {
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
}
});
ws.on('close', () => {
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);
});
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection(reason: string) {
this._extensionConnection?.close(reason);
this._extensionConnectionPromise.reject(new Error(reason));
this._resetExtensionConnection();
}
private _resetExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection = null;
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 {
if (this._extensionConnection) {
ws.close(1000, 'Another extension connection already established');
return;
}
this._extensionConnection = new ExtensionConnection(ws);
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._extensionConnectionPromise.resolve();
}
private _handleExtensionMessage(method: string, params: any) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
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})`);
const { id, sessionId, method, params } = message;
try {
const result = await this._handleCDPCommand(method, params, sessionId);
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
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));
}
}
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, error: Error }>();
private _lastId = 0;
onmessage?: (method: string, params: any) => void;
onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) {
this._ws = ws;
this._ws.on('message', this._onMessage.bind(this));
this._ws.on('close', this._onClose.bind(this));
this._ws.on('error', this._onError.bind(this));
}
async send(method: string, params?: any, sessionId?: string): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
const error = new Error(`Protocol error: ${method}`);
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error });
});
}
close(message: string) {
debugLogger('closing extension connection:', message);
if (this._ws.readyState === WebSocket.OPEN)
this._ws.close(1000, message);
}
private _onMessage(event: websocket.RawData) {
const eventData = event.toString();
let parsedJson;
try {
parsedJson = JSON.parse(eventData);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
this._ws.close();
return;
}
try {
this._handleParsedMessage(parsedJson);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
this._ws.close();
}
}
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) {
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);
}
}
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) {
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
this._dispose();
}
private _dispose() {
for (const callback of this._callbacks.values())
callback.reject(new Error('WebSocket closed'));
this._callbacks.clear();
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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 '../httpServer.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory {
name = 'extension';
description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string;
private _userDataDir?: string;
constructor(browserChannel: string, userDataDir: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
};
}
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
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);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return cdpRelayServer;
}
}

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

@@ -0,0 +1,31 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExtensionContextFactory } from './extensionContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig) {
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
await mcpTransport.start(serverBackendFactory, config.server);
}
export function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
}

37
src/fileUtils.ts Normal file
View File

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

44
src/httpServer.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* 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 assert from 'assert';
import http from 'http';
import type * as net from 'net';
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);
});
});
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}`;
}

View File

@@ -14,21 +14,26 @@
* limitations under the License.
*/
import { Connection, createConnection as createConnectionImpl } from './connection.js';
import { BrowserServerBackend } from './browserServerBackend.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.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(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>) {

View File

@@ -14,23 +14,12 @@
* limitations under the License.
*/
import type { Context } from '../context.js';
import debug from 'debug';
export type ResourceSchema = {
uri: string;
name: string;
description?: string;
mimeType?: string;
};
const errorsDebug = debug('pw:mcp:errors');
export type ResourceResult = {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
export function logUnhandledError(error: unknown) {
errorsDebug(error);
}
export type Resource = {
schema: ResourceSchema;
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
};
export const testDebug = debug('pw:mcp:test');

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

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

@@ -0,0 +1,77 @@
/**
* 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 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: '1.0.0' });
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer(new BrowserServerBackend(config, [browserContextFactory]), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.ToolResponse> {
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();
}
}

63
src/loopTools/main.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* 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 * as mcpTransport from '../mcp/transport.js';
import { packageJSON } from '../package.js';
import { Context } from './context.js';
import { perform } from './perform.js';
import { snapshot } from './snapshot.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 = () => new LoopToolsServerBackend(config);
await mcpTransport.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
readonly name = 'Playwright';
readonly version = packageJSON.version;
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);
}
tools(): mcpServer.ToolSchema<any>[] {
return this._tools.map(tool => tool.schema);
}
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any): Promise<mcpServer.ToolResponse> {
const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
return await tool.handle(this._context!, parsedArguments);
}
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);
},
});

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

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

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

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

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

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

@@ -0,0 +1,140 @@
/**
* 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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ManualPromise } from '../manualPromise.js';
import { logUnhandledError } from '../log.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type ClientCapabilities = {
roots?: {
listRoots?: boolean
};
};
export type ToolResponse = {
content: (TextContent | ImageContent)[];
isError?: boolean;
};
export type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
export type ToolHandler = (toolName: string, params: any) => Promise<ToolResponse>;
export interface ServerBackend {
name: string;
version: string;
initialize?(server: Server): Promise<void>;
tools(): ToolSchema<any>[];
callTool(schema: ToolSchema<any>, parsedArguments: any): Promise<ToolResponse>;
serverClosed?(): void;
}
export type ServerBackendFactory = () => ServerBackend;
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const backend = serverBackendFactory();
const server = createServer(backend, runHeartbeat);
await server.connect(transport);
}
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
const initializedPromise = new ManualPromise<void>();
const server = new Server({ name: backend.name, version: backend.version }, {
capabilities: {
tools: {},
}
});
const tools = backend.tools();
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: tools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: zodToJsonSchema(tool.inputSchema),
annotations: {
title: tool.title,
readOnlyHint: tool.type === 'readOnly',
destructiveHint: tool.type === 'destructive',
openWorldHint: true,
},
})) };
});
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
await initializedPromise;
if (runHeartbeat && !heartbeatRunning) {
heartbeatRunning = true;
startHeartbeat(server);
}
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: '### Result\n' + messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.name === request.params.name) as ToolSchema<any>;
if (!tool)
return errorResult(`Error: Tool "${request.params.name}" not found`);
try {
return await backend.callTool(tool, tool.inputSchema.parse(request.params.arguments || {}));
} catch (error) {
return errorResult(String(error));
}
});
addServerListener(server, 'initialized', () => {
backend.initialize?.(server).then(() => initializedPromise.resolve()).catch(logUnhandledError);
});
addServerListener(server, 'close', () => backend.serverClosed?.());
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();
};
}

View File

@@ -14,24 +14,34 @@
* limitations under the License.
*/
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
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 { httpAddressToString, startHttpServer } from '../httpServer.js';
import * as mcpServer from './server.js';
import type { Server } from './server.js';
import type { ServerBackendFactory } from './server.js';
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
if (options.port !== undefined) {
const httpServer = await startHttpServer(options);
startHttpTransport(httpServer, serverBackendFactory);
} else {
await startStdioTransport(serverBackendFactory);
}
}
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
}
const testDebug = debug('pw:mcp:test');
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
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) {
@@ -50,12 +60,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);
// eslint-disable-next-line no-console
void connection.close().catch(e => console.error(e));
});
return;
}
@@ -64,7 +72,7 @@ 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, StreamableHTTPServerTransport>) {
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);
@@ -79,15 +87,20 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: sessionId => {
onsessioninitialized: async sessionId => {
testDebug(`create http session: ${transport.sessionId}`);
await mcpServer.connect(serverBackendFactory, transport, true);
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
if (transport.sessionId)
sessions.delete(transport.sessionId);
if (!transport.sessionId)
return;
sessions.delete(transport.sessionId);
testDebug(`delete http session: ${transport.sessionId}`);
};
await server.createConnection(transport);
await transport.handleRequest(req, res);
return;
}
@@ -96,43 +109,29 @@ async function handleStreamable(server: Server, req: http.IncomingMessage, res:
res.end('Invalid request');
}
export function startHttpTransport(server: Server) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
function startHttpTransport(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('/mcp'))
await handleStreamable(server, req, res, streamableSessions);
if (url.pathname.startsWith('/sse'))
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
else
await handleSSE(server, req, res, url, sseSessions);
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
});
const { host, port } = server.config.server;
httpServer.listen(port, host, () => {
const address = httpServer.address();
assert(address, 'Could not bind server socket');
let url: string;
if (typeof address === 'string') {
url = address;
} else {
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
url = `http://${resolvedHost}:${resolvedPort}`;
}
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
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),
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
}
}, 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);
});
console.error(message);
}

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

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

@@ -14,14 +14,18 @@
* limitations under the License.
*/
import { program } from 'commander';
import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import * as mcpTransport from './mcp/transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { packageJSON } from './package.js';
import { createExtensionContextFactory, runWithExtension } from './extension/main.js';
import { BrowserServerBackend, FactoryList } from './browserServerBackend.js';
import { Context } from './context.js';
import { contextFactory } from './browserContextFactory.js';
import { runLoopTools } from './loopTools/main.js';
program
.version('Version ' + packageJSON.version)
@@ -30,7 +34,7 @@ program
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--config <path>', 'path to the configuration file.')
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
@@ -39,27 +43,47 @@ program
.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')
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--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"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
const config = await resolveCLIConfig(options);
const server = new Server(config);
server.setupExitWatchdog();
setupExitWatchdog();
if (config.server.port !== undefined)
startHttpTransport(server);
else
await startStdioTransport(server);
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);
if (options.extension) {
await runWithExtension(config);
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
const browserContextFactory = contextFactory(config);
const factories: FactoryList = [browserContextFactory];
if (options.connectTool)
factories.push(createExtensionContextFactory(config));
const serverBackendFactory = () => new BrowserServerBackend(config, factories);
await mcpTransport.start(serverBackendFactory, config.server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
@@ -70,8 +94,20 @@ program
}
});
function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim());
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 } 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) {
this.config = config;
this._browserConfig = config.browser;
this._contextFactory = contextFactory(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 './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,66 +14,151 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import { ManualPromise } from './manualPromise.js';
import { ModalState } from './tools/tool.js';
import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.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 _consoleMessages: playwright.ConsoleMessage[] = [];
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;
page.on('console', event => this._consoleMessages.push(event));
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
page.on('request', request => this._requests.set(request, null));
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() {
this._consoleMessages.length = 0;
this._recentConsoleMessages.length = 0;
this._requests.clear();
}
private _handleConsoleMessage(message: ConsoleMessage) {
this._consoleMessages.push(message);
this._recentConsoleMessages.push(message);
}
private _onClose() {
this._clearCollectedArtifacts();
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> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
@@ -83,31 +168,23 @@ export class Tab {
|| e.message.includes('Download is starting'); // firefox + webkit
if (!mightBeDownload)
throw e;
// 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, 500)),
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(): playwright.ConsoleMessage[] {
consoleMessages(): ConsoleMessage[] {
return this._consoleMessages;
}
@@ -115,7 +192,122 @@ 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: [],
};
}
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)));
});
}
}
export type ConsoleMessage = {
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
text: string;
toString(): string;
};
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
return {
type: message.type(),
text: message.text(),
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
};
}
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
if (errorOrValue instanceof Error) {
return {
type: undefined,
text: errorOrValue.message,
toString: () => errorOrValue.stack || errorOrValue.message,
};
}
return {
type: undefined,
text: String(errorOrValue),
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

@@ -17,6 +17,7 @@
import common from './tools/common.js';
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 install from './tools/install.js';
import keyboard from './tools/keyboard.js';
@@ -26,41 +27,30 @@ import pdf from './tools/pdf.js';
import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js';
import testing from './tools/testing.js';
import vision from './tools/vision.js';
import wait from './tools/wait.js';
import mouse from './tools/mouse.js';
import type { Tool } from './tools/tool.js';
import type { FullConfig } from './config.js';
export const snapshotTools: Tool<any>[] = [
...common(true),
export const allTools: Tool<any>[] = [
...common,
...console,
...dialogs(true),
...files(true),
...dialogs,
...evaluate,
...files,
...install,
...keyboard(true),
...navigate(true),
...keyboard,
...navigate,
...network,
...mouse,
...pdf,
...screenshot,
...snapshot,
...tabs(true),
...testing,
...wait(true),
...tabs,
...wait,
];
export const visionTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...tabs(false),
...testing,
...vision,
...wait(false),
];
export function filteredTools(config: FullConfig) {
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
}

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } 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: ToolFactory = captureSnapshot => defineTool({
const resize = defineTabTool({
capability: 'core',
schema: {
name: 'browser_resize',
@@ -51,28 +48,16 @@ const resize: ToolFactory = captureSnapshot => 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,
waitForNetwork: true
};
});
},
});
export default (captureSnapshot: boolean) => [
export default [
close,
resize(captureSnapshot)
resize
];

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.type().toUpperCase()}] ${message.text()}`).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, type ToolFactory } from './tool.js';
import { defineTabTool } from './tool.js';
const handleDialog: ToolFactory = captureSnapshot => defineTool({
const handleDialog = defineTabTool({
capability: 'core',
schema: {
@@ -31,32 +31,25 @@ const handleDialog: ToolFactory = captureSnapshot => 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,
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',
});
export default (captureSnapshot: boolean) => [
handleDialog(captureSnapshot),
export default [
handleDialog,
];

62
src/tools/evaluate.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* 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 '../javascript.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const evaluateSchema = z.object({
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
});
const evaluate = defineTabTool({
capability: 'core',
schema: {
name: 'browser_evaluate',
title: 'Evaluate JavaScript',
description: 'Evaluate JavaScript expression on page or element',
inputSchema: evaluateSchema,
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
locator = await tab.refLocator({ ref: params.ref, element: params.element });
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else {
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
}
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');
});
},
});
export default [
evaluate,
];

View File

@@ -15,10 +15,10 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
import { defineTabTool } from './tool.js';
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',
const uploadFile = defineTabTool({
capability: 'core',
schema: {
name: 'browser_file_upload',
@@ -30,30 +30,23 @@ const uploadFile: ToolFactory = captureSnapshot => 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,
waitForNetwork: true,
};
});
},
clearsModalState: 'fileChooser',
});
export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
export default [
uploadFile,
];

View File

@@ -16,14 +16,13 @@
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: 'install',
capability: 'core-install',
schema: {
name: 'browser_install',
title: 'Install the browser specified in the config',
@@ -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

@@ -15,9 +15,13 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
const pressKey: ToolFactory = captureSnapshot => defineTool({
import { defineTabTool } from './tool.js';
import { elementSchema } from './snapshot.js';
import { generateLocator } from './utils.js';
import * as javascript from '../javascript.js';
const pressKey = defineTabTool({
capability: 'core',
schema: {
@@ -30,25 +34,56 @@ const pressKey: ToolFactory = captureSnapshot => 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,
waitForNetwork: true
};
await tab.waitForCompletion(async () => {
await tab.page.keyboard.press(params.key);
});
},
});
export default (captureSnapshot: boolean) => [
pressKey(captureSnapshot),
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
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 = defineTabTool({
capability: 'core',
schema: {
name: 'browser_type',
title: 'Type text',
description: 'Type text into editable element',
inputSchema: typeSchema,
type: 'destructive',
},
handle: async (tab, params, response) => {
const locator = await tab.refLocator(params);
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.submit) {
response.setIncludeSnapshot();
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
await locator.press('Enter');
}
});
},
});
export default [
pressKey,
type,
];

113
src/tools/mouse.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* 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';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const mouseMove = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_move_xy',
title: 'Move mouse',
description: 'Move mouse to a given position',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
type: 'readOnly',
},
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 = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_click_xy',
title: 'Click',
description: 'Click left mouse button at a given position',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
type: 'destructive',
},
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();
});
},
});
const mouseDrag = defineTabTool({
capability: 'vision',
schema: {
name: 'browser_mouse_drag_xy',
title: 'Drag mouse',
description: 'Drag left mouse button to a given position',
inputSchema: elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
}),
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
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();`);
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();
});
},
});
export default [
mouseMove,
mouseClick,
mouseDrag,
];

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
import { defineTool, defineTabTool } from './tool.js';
const navigate: ToolFactory = captureSnapshot => defineTool({
const navigate = defineTool({
capability: 'core',
schema: {
@@ -30,25 +30,17 @@ const navigate: ToolFactory = captureSnapshot => 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,
waitForNetwork: false,
};
response.setIncludeSnapshot();
response.addCode(`await page.goto('${params.url}');`);
},
});
const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
const goBack = defineTabTool({
capability: 'core',
schema: {
name: 'browser_navigate_back',
title: 'Go back',
@@ -57,24 +49,15 @@ const goBack: ToolFactory = captureSnapshot => 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,
waitForNetwork: false,
};
response.setIncludeSnapshot();
response.addCode(`await page.goBack();`);
},
});
const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
const goForward = defineTabTool({
capability: 'core',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
@@ -82,23 +65,15 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = context.currentTabOrDie();
handle: async (tab, params, response) => {
await tab.page.goForward();
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
response.setIncludeSnapshot();
response.addCode(`await page.goForward();`);
},
});
export default (captureSnapshot: boolean) => [
navigate(captureSnapshot),
goBack(captureSnapshot),
goForward(captureSnapshot),
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';
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,26 +16,31 @@
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 { 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.'),
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
}).refine(data => {
return !(data.fullPage && (data.element || data.ref));
}, {
message: 'fullPage cannot be used with element screenshots.',
path: ['fullPage']
});
const screenshot = defineTool({
const screenshot = defineTabTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
@@ -45,43 +50,40 @@ 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}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
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 : 90,
scale: 'css',
path: fileName,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
};
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
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: true,
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,7 +16,7 @@
import { z } from 'zod';
import { defineTool } from './tool.js';
import { defineTabTool, defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
@@ -30,51 +30,55 @@ 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();
},
});
const elementSchema = z.object({
export const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
const click = defineTool({
const clickSchema = elementSchema.extend({
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
});
const click = defineTabTool({
capability: 'core',
schema: {
name: 'browser_click',
title: 'Click',
description: 'Perform click on a web page',
inputSchema: elementSchema,
inputSchema: clickSchema,
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
const code = [
`// Click ${params.element}`,
`await page.${await generateLocator(locator)}.click();`
];
const locator = await tab.refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
return {
code,
action: () => locator.click(),
captureSnapshot: true,
waitForNetwork: true,
};
if (params.doubleClick)
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
else
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
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',
@@ -89,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',
@@ -118,69 +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,
};
},
});
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
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({
capability: 'core',
schema: {
name: 'browser_type',
title: 'Type text',
description: 'Type text into editable element',
inputSchema: typeSchema,
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code: string[] = [];
const steps: (() => Promise<void>)[] = [];
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,
};
await tab.waitForCompletion(async () => {
await locator.hover();
});
},
});
@@ -188,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',
@@ -198,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);
});
},
});
@@ -221,6 +162,5 @@ export default [
click,
drag,
hover,
type,
selectOption,
];

View File

@@ -15,10 +15,10 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
import { defineTool } from './tool.js';
const listTabs = defineTool({
capability: 'tabs',
capability: 'core-tabs',
schema: {
name: 'browser_tab_list',
@@ -28,24 +28,14 @@ const listTabs = defineTool({
type: 'readOnly',
},
handle: async context => {
handle: async (context, params, response) => {
await context.ensureTab();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: await context.listTabsMarkdown(),
}],
},
};
response.setIncludeTabs();
},
});
const selectTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
const selectTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_select',
@@ -57,22 +47,14 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
response.setIncludeSnapshot();
},
});
const newTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
const newTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_new',
@@ -84,24 +66,16 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
type: 'readOnly',
},
handle: async (context, params) => {
await context.newTab();
handle: async (context, params, response) => {
const tab = await context.newTab();
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
await tab.navigate(params.url);
response.setIncludeSnapshot();
},
});
const closeTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
const closeTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_close',
@@ -113,22 +87,15 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
type: 'destructive',
},
handle: async (context, params) => {
handle: async (context, params, response) => {
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
response.setIncludeSnapshot();
},
});
export default (captureSnapshot: boolean) => [
export default [
listTabs,
newTab(captureSnapshot),
selectTab(captureSnapshot),
closeTab(captureSnapshot),
newTab,
selectTab,
closeTab,
];

View File

@@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
const generateTestSchema = z.object({
name: z.string().describe('The name of the test'),
description: z.string().describe('The description of the test'),
steps: z.array(z.string()).describe('The steps of the test'),
});
const generateTest = defineTool({
capability: 'testing',
schema: {
name: 'browser_generate_playwright_test',
title: 'Generate a Playwright test',
description: 'Generate a Playwright test for given scenario',
inputSchema: generateTestSchema,
type: 'readOnly',
},
handle: async (context, params) => {
return {
resultOverride: {
content: [{
type: 'text',
text: instructions(params),
}],
},
code: [],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const instructions = (params: { name: string, description: string, steps: string[] }) => [
`## Instructions`,
`- You are a playwright test generator.`,
`- You are given a scenario and you need to generate a playwright test for it.`,
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
'- Save generated test file in the tests directory',
`Test name: ${params.name}`,
`Description: ${params.description}`,
`Steps:`,
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
].join('\n');
export default [
generateTest,
];

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/server.js';
export type FileUploadModalState = {
type: 'fileChooser';
@@ -44,25 +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 type ToolFactory = (snapshot: boolean) => Tool<any>;
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

@@ -14,11 +14,13 @@
* limitations under the License.
*/
// @ts-ignore
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 = () => {};
@@ -62,23 +64,20 @@ 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> {
return (locator as any)._generateLocatorString();
try {
const { resolvedSelector } = await (locator as any)._resolveSelector();
return asLocator('javascript', resolvedSelector);
} catch (e) {
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
}
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {

View File

@@ -1,213 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_capture',
title: 'Take a screenshot',
description: 'Take a screenshot of the current page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = await context.ensureTab();
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
const code = [
`// Take a screenshot of the current page`,
`await page.screenshot(${javascript.formatObject(options)});`,
];
const action = () => tab.page.screenshot(options).then(buffer => {
return {
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
};
});
return {
code,
action,
captureSnapshot: false,
waitForNetwork: false
};
},
});
const moveMouse = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
title: 'Move mouse',
description: 'Move mouse to a given position',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
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
};
},
});
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_click',
title: 'Click',
description: 'Click left mouse button',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
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 () => {
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 drag = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_drag',
title: 'Drag mouse',
description: 'Drag left mouse button',
inputSchema: elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
startY: z.number().describe('Start Y coordinate'),
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
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();`,
];
const action = 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,
};
},
});
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_type',
title: 'Type text',
description: 'Type text',
inputSchema: z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code = [
`// Type ${params.text}`,
`await page.keyboard.type('${params.text}');`,
];
const action = async () => {
await tab.page.keyboard.type(params.text);
if (params.submit)
await tab.page.keyboard.press('Enter');
};
if (params.submit) {
code.push(`// Submit text`);
code.push(`await page.keyboard.press('Enter');`);
}
return {
code,
action,
captureSnapshot: false,
waitForNetwork: true,
};
},
});
export default [
screenshot,
moveMouse,
click,
drag,
type,
];

View File

@@ -15,10 +15,10 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
import { defineTool } from './tool.js';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
const wait = defineTool({
capability: 'core',
schema: {
name: 'browser_wait_for',
@@ -32,7 +32,7 @@ const wait: ToolFactory = captureSnapshot => 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');
@@ -40,7 +40,7 @@ const wait: ToolFactory = captureSnapshot => defineTool({
if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
}
const tab = context.currentTabOrDie();
@@ -57,14 +57,11 @@ const wait: ToolFactory = captureSnapshot => defineTool({
await locator.waitFor({ state: 'visible' });
}
return {
code,
captureSnapshot,
waitForNetwork: false,
};
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
response.setIncludeSnapshot();
},
});
export default (captureSnapshot: boolean) => [
wait(captureSnapshot),
export default [
wait,
];

29
src/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* 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 createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

View File

@@ -22,8 +22,8 @@ test('test snapshot tool list', async ({ client }) => {
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
@@ -34,7 +34,6 @@ test('test snapshot tool list', async ({ client }) => {
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_snapshot',
@@ -47,46 +46,33 @@ test('test snapshot tool list', async ({ client }) => {
]));
});
test('test vision tool list', async ({ visionClient }) => {
const { tools: visionTools } = await visionClient.listTools();
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
'browser_console_messages',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait_for',
]));
});
test('test capabilities', async ({ startClient }) => {
test('test capabilities (pdf)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--caps="core"'],
args: ['--caps=pdf'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).not.toContain('browser_file_upload');
expect(toolNames).not.toContain('browser_pdf_save');
expect(toolNames).not.toContain('browser_screen_capture');
expect(toolNames).not.toContain('browser_screen_click');
expect(toolNames).not.toContain('browser_screen_drag');
expect(toolNames).not.toContain('browser_screen_move_mouse');
expect(toolNames).not.toContain('browser_screen_type');
expect(toolNames).toContain('browser_pdf_save');
});
test('test capabilities (vision)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--caps=vision'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy');
expect(toolNames).toContain('browser_mouse_click_xy');
expect(toolNames).toContain('browser_mouse_drag_xy');
});
test('support for legacy --vision option', async ({ startClient }) => {
const { client } = await startClient({
args: ['--vision'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy');
expect(toolNames).toContain('browser_mouse_click_xy');
expect(toolNames).toContain('browser_mouse_drag_xy');
});

View File

@@ -14,6 +14,9 @@
* limitations under the License.
*/
import url from 'node:url';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpServer, startClient, server }) => {
@@ -22,7 +25,9 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
@@ -38,23 +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 of 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 URL: ${server.HELLO_WORLD}
})).toHaveResponse({
pageState: expect.stringContaining(`- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
- Page Snapshot:
\`\`\`yaml
- generic [ref=e1]: Hello, world!
\`\`\`
`);
- generic [active] [ref=e1]: Hello, world!
\`\`\``),
});
});
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
@@ -68,10 +71,27 @@ 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 [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.
const __filename = url.fileURLToPath(import.meta.url);
test('does not support --device', async () => {
const result = spawnSync('node', [
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
});

99
tests/click.spec.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_click', async ({ client, server, mcpBrowser }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 'e2',
},
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
pageState: expect.stringContaining(`- button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]`),
});
});
test('browser_click (double)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<script>
function handle() {
document.querySelector('h1').textContent = 'Double clicked';
}
</script>
<h1 ondblclick="handle()">Click me</h1>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
doubleClick: true,
},
})).toHaveResponse({
code: `await page.getByRole('heading', { name: 'Click me' }).dblclick();`,
pageState: expect.stringContaining(`- heading "Double clicked" [level=1] [ref=e3]`),
});
});
test('browser_click (right)', async ({ client, server }) => {
server.setContent('/', `
<button oncontextmenu="handle">Menu</button>
<script>
document.addEventListener('contextmenu', event => {
event.preventDefault();
document.querySelector('button').textContent = 'Right clicked';
});
</script>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
const result = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Menu',
ref: 'e2',
button: 'right',
},
});
expect(result).toHaveResponse({
code: `await page.getByRole('button', { name: 'Menu' }).click({ button: 'right' });`,
pageState: expect.stringContaining(`- button "Right clicked"`),
});
});

View File

@@ -19,7 +19,7 @@ import fs from 'node:fs';
import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server }, testInfo) => {
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
@@ -37,7 +37,9 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`Hello, world!`),
});
const files = await fs.promises.readdir(config.browser!.userDataDir!);
expect(files.length).toBeGreaterThan(0);
@@ -45,7 +47,7 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
test.describe(() => {
test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient, mcpMode }, testInfo) => {
const config: Config = {
browser: {
browserName: 'firefox',
@@ -58,6 +60,25 @@ test.describe(() => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
})).toContainTextContent(`Firefox`);
})).toHaveResponse({
pageState: expect.stringContaining(`Firefox`),
});
});
});
test.describe('sandbox configuration', () => {
test('should enable sandbox by default (no --no-sandbox flag)', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: undefined });
// When --no-sandbox is not passed, chromiumSandbox should not be set to false
// This allows the default (true) to be used
expect(config.browser?.launchOptions?.chromiumSandbox).toBeUndefined();
});
test('should disable sandbox when --no-sandbox flag is passed', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: false });
// When --no-sandbox is passed, chromiumSandbox should be explicitly set to false
expect(config.browser?.launchOptions?.chromiumSandbox).toBe(false);
});
});

View File

@@ -37,8 +37,64 @@ test('browser_console_messages', async ({ client, server }) => {
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent([
'[LOG] Hello, world!',
'[ERROR] Error',
].join('\n'));
expect(resource).toHaveResponse({
result: `[LOG] Hello, world! @ ${server.PREFIX}:4
[ERROR] Error @ ${server.PREFIX}:5`,
});
});
test('browser_console_messages (page error)', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
throw new Error("Error in script");
</script>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveResponse({
result: expect.stringContaining(`Error: Error in script`),
});
expect(resource).toHaveResponse({
result: expect.stringContaining(server.PREFIX),
});
});
test('recent console messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<button onclick="console.log('Hello, world!');">Click me</button>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(response).toHaveResponse({
consoleMessages: expect.stringContaining(`- [LOG] Hello, world! @`),
});
});

View File

@@ -20,54 +20,15 @@ test('browser_navigate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Navigate to ${server.HELLO_WORLD}
await page.goto('${server.HELLO_WORLD}');
\`\`\`
- Page URL: ${server.HELLO_WORLD}
})).toHaveResponse({
code: `await page.goto('${server.HELLO_WORLD}');`,
pageState: `- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
- Page Snapshot:
\`\`\`yaml
- generic [ref=e1]: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
- generic [active] [ref=e1]: Hello, world!
\`\`\``,
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 'e2',
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Click Submit button
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=e2]
\`\`\`
`);
});
test('browser_select_option', async ({ client, server }) => {
@@ -91,22 +52,17 @@ test('browser_select_option', async ({ client, server }) => {
ref: 'e2',
values: ['bar'],
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Select options [bar] in Select
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
- Page URL: ${server.PREFIX}
})).toHaveResponse({
code: `await page.getByRole('combobox').selectOption(['bar']);`,
pageState: `- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
- Page Snapshot:
\`\`\`yaml
- combobox [ref=e2]:
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
\`\`\``,
});
});
test('browser_select_option (multiple)', async ({ client, server }) => {
@@ -131,82 +87,14 @@ test('browser_select_option (multiple)', async ({ client, server }) => {
ref: 'e2',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Select options [bar, baz] in Select
await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
})).toHaveResponse({
code: `await page.getByRole('listbox').selectOption(['bar', 'baz']);`,
pageState: expect.stringContaining(`
- listbox [ref=e2]:
- option "Foo" [ref=e3]
- option "Bar" [selected] [ref=e4]
- option "Baz" [selected] [ref=e5]
\`\`\`
`);
});
test('browser_type', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
- option "Baz" [selected] [ref=e5]`),
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
submit: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
});
test('browser_type (slowly)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'Hi!',
submit: true,
slowly: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveTextContent([
'[LOG] Key pressed: H Text: ',
'[LOG] Key pressed: i Text: H',
'[LOG] Key pressed: ! Text: Hi',
'[LOG] Key pressed: Enter Text: Hi!',
].join('\n'));
});
test('browser_resize', async ({ client, server }) => {
@@ -230,10 +118,73 @@ test('browser_resize', async ({ client, server }) => {
height: 780,
},
});
expect(response).toContainTextContent(`- Ran Playwright code:
\`\`\`js
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
expect(response).toHaveResponse({
code: `await page.setViewportSize({ width: 390, height: 780 });`,
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
pageState: expect.stringContaining(`Window size: 390x780`),
});
});
test('old locator error message', async ({ client, server }) => {
server.setContent('/', `
<button>Button 1</button>
<button>Button 2</button>
<script>
document.querySelector('button').addEventListener('click', () => {
document.querySelectorAll('button')[1].remove();
});
</script>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toHaveResponse({
pageState: expect.stringContaining(`
- button "Button 1" [ref=e2]
- button "Button 2" [ref=e3]`),
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button 1',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button 2',
ref: 'e3',
},
})).toHaveResponse({
result: expect.stringContaining(`Ref e3 not found in the current page snapshot. Try capturing new snapshot.`),
isError: true,
});
});
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
server.setContent('/', `
<div style="visibility: hidden;">
<div style="visibility: visible;">
<button>Button</button>
</div>
</div>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_snapshot'
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button"`),
});
});

View File

@@ -16,7 +16,7 @@
import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => {
test('--device should work', async ({ startClient, server, mcpMode }) => {
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
});
@@ -39,5 +39,7 @@ test('--device should work', async ({ startClient, server }) => {
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`393x659`);
})).toHaveResponse({
pageState: expect.stringContaining(`393x659`),
});
});

View File

@@ -16,15 +16,14 @@
import { test, expect } from './fixtures.js';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
test('alert dialog', async ({ client, server }) => {
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -32,40 +31,34 @@ test('alert dialog', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
});
### Modal state
- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toHaveResponse({
code: undefined,
modalState: `- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`,
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- button "Button"`),
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot
\`\`\`yaml
- button "Button" [ref=e2]
\`\`\`
`);
});
test('two alert dialogs', async ({ client, server }) => {
test.fixme(true, 'Race between the dialog and ariaSnapshot');
server.setContent('/', `
<title>Title</title>
<body>
@@ -76,7 +69,9 @@ test('two alert dialogs', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -84,14 +79,10 @@ test('two alert dialogs', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
// Click Button
await page.getByRole('button', { name: 'Button' }).click();
\`\`\`
### Modal state
- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -100,7 +91,20 @@ await page.getByRole('button', { name: 'Button' }).click();
},
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toHaveResponse({
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
});
const result2 = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result2).not.toHaveResponse({
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`),
});
});
test('confirm dialog (true)', async ({ client, server }) => {
@@ -114,7 +118,9 @@ test('confirm dialog (true)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -122,22 +128,19 @@ test('confirm dialog (true)', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "true"`),
});
expect(result).not.toContainTextContent('### Modal state');
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: "true"
\`\`\``);
});
test('confirm dialog (false)', async ({ client, server }) => {
@@ -151,7 +154,9 @@ test('confirm dialog (false)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -159,20 +164,19 @@ test('confirm dialog (false)', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
expect(await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: false,
},
})).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- generic [active] [ref=e1]: "false"`),
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: "false"
\`\`\``);
});
test('prompt dialog', async ({ client, server }) => {
@@ -186,7 +190,9 @@ test('prompt dialog', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
@@ -194,8 +200,9 @@ test('prompt dialog', async ({ client, server }) => {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
@@ -205,8 +212,44 @@ test('prompt dialog', async ({ client, server }) => {
},
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Answer
\`\`\``);
expect(result).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Answer`),
});
});
test('alert dialog w/ race', async ({ client, server }) => {
server.setContent('/', `<button onclick="setTimeout(() => alert('Alert'), 100)">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`- button "Button" [ref=e2]`),
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toHaveResponse({
code: `await page.getByRole('button', { name: 'Button' }).click();`,
modalState: expect.stringContaining(`- ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`),
});
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).toHaveResponse({
modalState: undefined,
pageState: expect.stringContaining(`- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`),
});
});

80
tests/evaluate.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_evaluate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
pageState: expect.stringContaining(`- Page Title: Title`),
});
expect(await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => document.title',
},
})).toHaveResponse({
result: `"Title"`,
code: `await page.evaluate('() => document.title');`,
});
});
test('browser_evaluate (element)', async ({ client, server }) => {
server.setContent('/', `
<body style="background-color: red">Hello, world!</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_evaluate',
arguments: {
function: 'element => element.style.backgroundColor',
element: 'body',
ref: 'e1',
},
})).toHaveResponse({
result: `"red"`,
code: `await page.getByText('Hello, world!').evaluate('element => element.style.backgroundColor');`,
});
});
test('browser_evaluate (error)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
pageState: expect.stringContaining(`- Page Title: Title`),
});
const result = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => nonExistentVariable',
},
});
expect(result.isError).toBe(true);
expect(result.content?.[0]?.text).toContain('nonExistentVariable');
// Check for common error patterns across browsers
const errorText = result.content?.[0]?.text || '';
expect(errorText).toMatch(/not defined|Can't find variable/);
});

View File

@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
import { test, expect } from './fixtures.js';
test('browser_file_upload', async ({ client, server }, testInfo) => {
server.setContent('/', `
@@ -26,22 +26,21 @@ test('browser_file_upload', async ({ client, server }, testInfo) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
- button "Button" [ref=e3]`),
});
{
expect(await client.callTool({
name: 'browser_file_upload',
arguments: { paths: [] },
})).toHaveTextContent(`
The tool "browser_file_upload" can only be used when there is related modal state present.
### Modal state
- There is no modal state present
`.trim());
})).toHaveResponse({
isError: true,
result: expect.stringContaining(`The tool "browser_file_upload" can only be used when there is related modal state present.`),
modalState: expect.stringContaining(`- There is no modal state present`),
});
}
expect(await client.callTool({
@@ -50,8 +49,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
element: 'Textbox',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
})).toHaveResponse({
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
});
const filePath = testInfo.outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
@@ -64,13 +64,10 @@ The tool "browser_file_upload" can only be used when there is related modal stat
},
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
expect(response).toHaveResponse({
code: expect.stringContaining(`await fileChooser.setFiles(`),
modalState: undefined,
});
}
{
@@ -82,7 +79,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
},
});
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
expect(response).toHaveResponse({
modalState: `- [File chooser]: can be handled by the "browser_file_upload" tool`,
});
}
{
@@ -94,13 +93,14 @@ The tool "browser_file_upload" can only be used when there is related modal stat
},
});
expect(response).toContainTextContent(`Tool "browser_click" does not handle the modal state.
### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
expect(response).toHaveResponse({
result: `Error: Tool "browser_click" does not handle the modal state.`,
modalState: expect.stringContaining(`- [File chooser]: can be handled by the "browser_file_upload" tool`),
});
}
});
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
@@ -111,7 +111,9 @@ test('clicking on download link emits download', async ({ startClient, server },
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- link "Download" [ref=e2]');
})).toHaveResponse({
pageState: expect.stringContaining(`- link "Download" [ref=e2]`),
});
await client.callTool({
name: 'browser_click',
arguments: {
@@ -119,17 +121,17 @@ test('clicking on download link emits download', async ({ startClient, server },
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toHaveResponse({
downloads: `- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`,
});
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
test.skip(mcpBrowser !== 'chromium', 'This test is racy');
server.route('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
@@ -143,5 +145,7 @@ test('navigating to download link emits download', async ({ startClient, server,
arguments: {
url: server.PREFIX + 'download',
},
})).toContainTextContent('### Downloads');
})).toHaveResponse({
downloads: expect.stringContaining(`- Downloaded file test.txt to`),
});
});

View File

@@ -22,10 +22,13 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from './testserver/index.ts';
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Stream } from 'stream';
export type TestOptions = {
mcpBrowser: string | undefined;
@@ -39,8 +42,12 @@ type CDPServer = {
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
startClient: (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
}) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -59,18 +66,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use(client);
},
visionClient: async ({ startClient }, use) => {
const { client } = await startClient({ args: ['--vision'] });
await use(client);
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
await use(async options => {
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
const args: string[] = [];
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
@@ -85,15 +86,24 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
args.push(`--config=${path.relative(configDir, configFile)}`);
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode);
let stderr = '';
transport.stderr?.on('data', data => {
stderr += data.toString();
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
return {
roots: options.roots,
};
});
}
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
process.stderr.write(data);
stderrBuffer += data.toString();
});
await client.connect(transport);
await client.ping();
return { client, stderr: () => stderr };
return { client, stderr: () => stderrBuffer };
});
await client?.close();
@@ -134,7 +144,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpMode: [undefined, { option: true }],
_workerServers: [async ({}, use, workerInfo) => {
_workerServers: [async ({ }, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port);
@@ -160,71 +170,54 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
});
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
return new StdioClientTransport({
const transport = new StdioClientTransport({
command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
});
return {
transport,
stderr: transport.stderr,
};
}
return new StdioClientTransport({
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
cwd: path.dirname(test.info().config.configFile!),
stderr: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
},
});
return {
transport,
stderr: transport.stderr!,
};
}
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({
toHaveTextContent(response: Response, content: string | RegExp) {
toHaveResponse(response: Response, object: any) {
const parsed = parseResponse(response);
const isNot = this.isNot;
try {
const text = (response.content as any)[0].text;
if (typeof content === 'string') {
if (isNot)
baseExpect(text.trim()).not.toBe(content.trim());
else
baseExpect(text.trim()).toBe(content.trim());
} else {
if (isNot)
baseExpect(text).not.toMatch(content);
else
baseExpect(text).toMatch(content);
}
} catch (e) {
return {
pass: isNot,
message: () => e.message,
};
}
return {
pass: !isNot,
message: () => ``,
};
},
toContainTextContent(response: Response, content: string | string[]) {
const isNot = this.isNot;
try {
content = Array.isArray(content) ? content : [content];
const texts = (response.content as any).map(c => c.text);
for (let i = 0; i < texts.length; i++) {
if (isNot)
expect(texts[i]).not.toContain(content[i]);
else
expect(texts[i]).toContain(content[i]);
}
if (isNot)
expect(parsed).not.toEqual(expect.objectContaining(object));
else
expect(parsed).toEqual(expect.objectContaining(object));
} catch (e) {
return {
pass: isNot,
@@ -239,5 +232,50 @@ export const expect = baseExpect.extend({
});
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
}
function parseResponse(response: any) {
const text = response.content[0].text;
const sections = parseSections(text);
const result = sections.get('Result');
const code = sections.get('Ran Playwright code');
const tabs = sections.get('Open tabs');
const pageState = sections.get('Page state');
const consoleMessages = sections.get('New console messages');
const modalState = sections.get('Modal state');
const downloads = sections.get('Downloads');
const codeNoFrame = code?.replace(/^```js\n/, '').replace(/\n```$/, '');
const isError = response.isError;
const attachments = response.content.slice(1);
return {
result,
code: codeNoFrame,
tabs,
pageState,
consoleMessages,
modalState,
downloads,
isError,
attachments,
};
}
function parseSections(text: string): Map<string, string> {
const sections = new Map<string, string>();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
for (const section of sectionHeaders) {
const firstNewlineIndex = section.indexOf('\n');
if (firstNewlineIndex === -1)
continue;
const sectionName = section.substring(0, firstNewlineIndex);
const sectionContent = section.substring(firstNewlineIndex + 1).trim();
sections.set(sectionName, sectionContent);
}
return sections;
}

View File

@@ -21,6 +21,7 @@ for (const mcpHeadless of [false, true]) {
test.use({ mcpHeadless });
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
test('browser', async ({ client, server, mcpBrowser }) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
server.route('/', (req, res) => {
@@ -40,11 +41,9 @@ for (const mcpHeadless of [false, true]) {
},
});
expect(response).toContainTextContent(`Mozilla/5.0`);
if (mcpHeadless)
expect(response).toContainTextContent(`HeadlessChrome`);
else
expect(response).not.toContainTextContent(`HeadlessChrome`);
expect(response).toHaveResponse({
pageState: (mcpHeadless ? expect : expect.not).stringContaining(`HeadlessChrome`),
});
});
});
}

259
tests/http.spec.ts Normal file
View File

@@ -0,0 +1,259 @@
/**
* 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 'node:fs';
import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test as baseTest, expect } from './fixtures.js';
import type { Config } from '../config.d.ts';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;
const userDataDir = testInfo.outputPath('user-data-dir');
await use(async (options?: { args?: string[], noPort?: boolean }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../cli.js'),
...(options?.noPort ? [] : ['--port=0']),
'--user-data-dir=' + userDataDir,
...(mcpHeadless ? ['--headless'] : []),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stderr = '';
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stderr: () => stderr };
});
cp?.kill('SIGTERM');
},
});
test('http transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport (config)', async ({ serverEndpoint }) => {
const config: Config = {
server: {
port: 0,
}
};
const configFile = test.info().outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
/**
* src/client/streamableHttp.ts
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*/
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
}).toPass();
});
test('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client3 = new Client({ name: 'test', version: '1.0.0' });
await client3.connect(transport3);
await client3.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await transport3.terminateSession();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
}).toPass();
});
test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
}).toPass();
});
test('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
const response = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response.isError).toBe(true);
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
await client1.close();
await client2.close();
});
test('http transport (default)', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

@@ -22,17 +22,17 @@ test('stitched aria frames', async ({ client }) => {
arguments: {
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
},
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [ref=f1e1]:
- generic [active] [ref=f1e1]:
- button "World" [ref=f1e2]
- main [ref=f1e3]:
- iframe [ref=f1e4]:
- paragraph [ref=f2e2]: Nested
\`\`\``);
\`\`\``),
});
expect(await client.callTool({
name: 'browser_click',
@@ -40,5 +40,7 @@ test('stitched aria frames', async ({ client }) => {
element: 'World',
ref: 'f1e2',
},
})).toContainTextContent(`// Click World`);
})).toHaveResponse({
code: `await page.locator('iframe').first().contentFrame().getByRole('button', { name: 'World' }).click();`,
});
});

View File

@@ -20,5 +20,7 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`No open pages available.`);
})).toHaveResponse({
tabs: expect.stringContaining(`No open tabs`),
});
});

View File

@@ -18,7 +18,7 @@ import fs from 'fs';
import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ startClient, server }) => {
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient();
await client.callTool({
name: 'browser_navigate',
@@ -27,12 +27,17 @@ test('test reopen browser', async ({ startClient, server }) => {
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent('No open pages available');
})).toHaveResponse({
code: `await page.close()`,
tabs: `No open tabs. Use the "browser_navigate" tool to navigate to a page first.`,
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
})).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
await client.close();
@@ -62,7 +67,10 @@ test('executable path', async ({ startClient, server }) => {
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response).toContainTextContent(`executable doesn't exist`);
expect(response).toHaveResponse({
result: expect.stringContaining(`executable doesn't exist`),
isError: true,
});
});
test('persistent context', async ({ startClient, server }) => {
@@ -76,11 +84,12 @@ test('persistent context', async ({ startClient, server }) => {
`, 'text/html');
const { client } = await startClient();
const response = await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response).toContainTextContent(`Storage: NO`);
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -89,12 +98,12 @@ test('persistent context', async ({ startClient, server }) => {
});
const { client: client2 } = await startClient();
const response2 = await client2.callTool({
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: YES`),
});
expect(response2).toContainTextContent(`Storage: YES`);
});
test('isolated context', async ({ startClient, server }) => {
@@ -108,22 +117,24 @@ test('isolated context', async ({ startClient, server }) => {
`, 'text/html');
const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client1.callTool({
expect(await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response).toContainTextContent(`Storage: NO`);
await client1.callTool({
name: 'browser_close',
});
const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: NO`),
});
expect(response2).toContainTextContent(`Storage: NO`);
});
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
@@ -149,9 +160,10 @@ test('isolated context with storage state', async ({ startClient, server }, test
`--isolated`,
`--storage-state=${storageStatePath}`,
] });
const response = await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toHaveResponse({
pageState: expect.stringContaining(`Storage: session-value`),
});
expect(response).toContainTextContent(`Storage: session-value`);
});

View File

@@ -13,9 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import child_process from 'child_process';
import fs from 'fs/promises';
import { test, expect } from './fixtures.js';
import fs from 'node:fs/promises';
import child_process from 'node:child_process';
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
const file = testInfo.outputPath('main.cjs');

View File

@@ -40,6 +40,8 @@ test('browser_network_requests', async ({ client, server }) => {
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
})).toHaveResponse({
result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`),
});
});

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