160 Commits

Author SHA1 Message Date
Max Schmitt
38f0222d0e devops: fix publishing (#1021)
Fixes
https://github.com/microsoft/playwright-mcp/actions/runs/17521002811/job/49764720465
Fixes https://github.com/microsoft/playwright-mcp/issues/1014
2025-09-08 16:43:04 +02:00
Pavel Feldman
78795fc7c7 chore: mark v0.0.37 (#1010) 2025-09-06 13:04:30 -07:00
Pavel Feldman
e4b545c438 chore: roll Playwright to latest (#1009) 2025-09-06 10:42:37 -07:00
Pavel Feldman
3d6a66fd08 chore: roll to the latest Playwright (#1003) 2025-09-05 12:31:08 -07:00
Yury Semikhatsky
971489536e chore: resore extension sources (#999) 2025-09-04 15:00:42 -07:00
Yury Semikhatsky
6171abd277 devops: get extension version from package.json, not tag (#1000) 2025-09-04 14:21:14 -07:00
Yury Semikhatsky
60d2b97b43 docs: link to extension readme (#997) 2025-09-04 11:22:21 -07:00
Pavel Feldman
3fe5b8a408 chore: point to monorepo for the source code (#995) 2025-09-04 08:30:23 -07:00
Pavel Feldman
c58b2a93da chore: use mcp implementation in Playwright (#992) 2025-09-04 06:40:19 -07:00
Pavel Feldman
d142f13d80 chore: prep folders for copying upstream (#987) 2025-09-03 16:02:07 -07:00
Pavel Feldman
2461f32d05 chore: move from esm to cjs before upstreaming (#986) 2025-09-03 08:00:58 -07:00
Pavel Feldman
8d86ce4958 chore: use mcp sdk via playwright (#973) 2025-09-03 07:58:36 -07:00
Pavel Feldman
87741662f4 docs: fix Cursor link (#985) 2025-09-02 14:20:20 -07:00
Simon Knott
b85dc6954a chore(vscode): expose debug controller (#979)
See https://github.com/microsoft/playwright-vscode/pull/684 for the
other side.
2025-09-02 08:59:36 +02:00
Pavel Feldman
e8e2af40b7 chore: mark v0.0.36 (#972) 2025-08-31 11:26:29 -07:00
Pavel Feldman
b176111891 chore: roll Playwright to latest (#971) 2025-08-29 18:25:21 -07:00
Pavel Feldman
29d468dac7 chore: add verification tools section (#957) 2025-08-28 21:39:35 -07:00
colin b. erdman
51ab77e04e docs: cursor manual install instructions should use @playwright/mcp@latest (#962) 2025-08-28 15:37:16 -07:00
Sangjin Park
7fb8b0dc3a fix: browser_tabs select action with index 0 failing due to falsy check (#964) 2025-08-28 15:36:31 -07:00
Pavel Feldman
fc04de2be5 chore: publish canary daily (#958) 2025-08-27 07:02:03 -07:00
Pavel Feldman
11480fa8ce chore: add eval object test (#952) 2025-08-26 08:58:33 -07:00
Pavel Feldman
78298c3448 chore: introduce verification tools (#951) 2025-08-25 18:08:07 -07:00
Yury Semikhatsky
7774ad93ca chore(extension): support custom executablePath (#947)
Fixes https://github.com/microsoft/playwright-mcp/issues/941
2025-08-25 13:48:52 -07:00
Yury Semikhatsky
1a64a51812 chore: version extension-relay protocol (#939) 2025-08-25 11:48:23 -07:00
Pavel Feldman
22043cb3ef chore: mark v0.0.35 (#938) 2025-08-23 15:34:49 -07:00
Yury Semikhatsky
0812df2f5e chore(extension): do not complain about old extension version (#937) 2025-08-22 17:53:30 -07:00
Pavel Feldman
3d1a60b7f3 chore: introduce form filling tool (#935) 2025-08-22 17:06:24 -07:00
Pavel Feldman
86eba2245a chore: remove navigate_forward due to low usage counts (#934) 2025-08-22 14:02:33 -07:00
Pavel Feldman
2521a67b2f chore: merge tabs tool into one (#933) 2025-08-22 13:46:52 -07:00
Pavel Feldman
fb28e99fa4 chore: mdb stub (#912) 2025-08-22 13:08:29 -07:00
Yury Semikhatsky
64af5f8763 chore(extension): do not show tab selector for browser_navigate (#923) 2025-08-22 10:02:09 -07:00
Yury Semikhatsky
fb65bc7559 chore: show download link on the status mismatch page (#928)
<img width="610" height="108" alt="image"
src="https://github.com/user-attachments/assets/bc1f7534-f282-44f0-bb58-e385b2bf82af"
/>
2025-08-22 09:10:53 -07:00
ach
94ca0763d5 docs: add instructions for installation in openai codex (#927) 2025-08-21 16:19:26 -07:00
Simon Knott
2ae7800ac1 chore(vscode): add vscode mcp factory (#868) 2025-08-21 14:28:35 +02:00
Yury Semikhatsky
f6862a39c3 chore: check version in page, link to instructions (#918) 2025-08-19 17:39:58 -07:00
Yury Semikhatsky
e664e0460c chore: check extension version on connect (#907) 2025-08-18 13:28:13 -07:00
Pavel Feldman
865eac2fee chore: do not wrap mcp in proxy by default, drive-by deps fix (#909) 2025-08-16 19:39:49 -07:00
Yury Semikhatsky
d5d810f896 chore: mark 0.0.34 (#901) 2025-08-15 17:38:58 -07:00
Yury Semikhatsky
1efd3b55e5 devops: update extension manifest version (#904) 2025-08-15 16:10:49 -07:00
Yury Semikhatsky
1d1db1e287 chore: fix copyright (#903) 2025-08-15 15:36:12 -07:00
Yury Semikhatsky
25f15e7f5e devops: set-version.js script (#902) 2025-08-15 15:28:59 -07:00
Yury Semikhatsky
c559243ef6 chore(extension): connected badge while loading (#899) 2025-08-15 13:44:17 -07:00
Yury Semikhatsky
91d5d24cab chore: handle list roots in the server, with timeout (#898) 2025-08-15 11:23:59 -07:00
Yury Semikhatsky
92554abfd1 devops: extension publishing job (#888) 2025-08-15 10:25:46 -07:00
Pavel Feldman
4370f2cdf2 chore: try macos15 runners (#892) 2025-08-15 10:19:52 -07:00
Yury Semikhatsky
ba726fb44a chore(extension): connection timeout when extension not installed (#896) 2025-08-15 09:09:35 -07:00
Yury Semikhatsky
2fc4e88048 chore(extension): add readme file, recommend --extension option (#894) 2025-08-14 16:01:14 -07:00
Adam Tarantino
3f148a4005 docs: add opencode installation instructions (#895) 2025-08-14 15:41:46 -07:00
Pavel Feldman
c92aefdc12 chore: close all clients in fixture (#878) 2025-08-14 10:57:07 -07:00
Pavel Feldman
badfd82202 chore: move tool schema to mcp as it is used by all servers (#887) 2025-08-13 18:23:25 -07:00
Yury Semikhatsky
12942b81d6 fix: wait for initialization to complete before listing tools (#886) 2025-08-13 17:29:10 -07:00
Pavel Feldman
73adb0fdf0 chore: steer towards mcp types a bit (#880) 2025-08-13 14:09:37 -07:00
Yury Semikhatsky
8572ab300c chore: separate proxy client from external (#877) 2025-08-12 18:05:45 -07:00
Pavel Feldman
c091a11d76 chore: extract utils folder (#876) 2025-08-12 14:33:00 -07:00
Pavel Feldman
dbd44110f1 chore: run test server per context (#874)
Fixes https://github.com/microsoft/playwright-mcp/issues/869
2025-08-12 13:41:08 -07:00
Pavel Feldman
2f41a3f6b1 chore: roll Playwright to latest (#875) 2025-08-12 13:30:32 -07:00
Yury Semikhatsky
7c4d67b3ae chore: tool definition without zod (#873) 2025-08-12 13:19:25 -07:00
Vicente Filho
53c6b6dcb1 fix: backtick quote escaping (#871) 2025-08-12 13:19:09 -07:00
Yury Semikhatsky
1fb2878271 fix(proxy): properly forward root requests and client metadata (#865) 2025-08-12 10:17:45 +02:00
Pavel Feldman
ab0ecc4075 chore: introduce check-deps (#864) 2025-08-11 17:21:26 -07:00
Yury Semikhatsky
f010164bf1 chore: mcp backend switcher (#854) 2025-08-11 14:16:43 -07:00
Pavel Feldman
db9cfe1720 chore: bump test workers to 2 on CI (#863) 2025-08-11 12:48:54 -07:00
Pavel Feldman
24f81a7a27 fix: emit code for waitfor (#862)
Fixes https://github.com/microsoft/playwright-mcp/issues/859
2025-08-11 11:58:45 -07:00
Yury Semikhatsky
21ced701b5 chore(extension): status page (#856) 2025-08-08 18:33:10 -07:00
Pavel Feldman
d3bf2eefc6 chore: mark 0.0.33 (#851) 2025-08-08 17:22:18 -07:00
Pavel Feldman
2ca899316d chore: roll Playwright to recent (#850) 2025-08-08 09:37:07 -07:00
Pavel Feldman
16f3523317 chore: do not return fullPage screenshots to the LLM (#849) 2025-08-08 09:36:51 -07:00
Omar Bahareth
6c2dda31ad fix(docs): Invalid MCP Install Link (#846) 2025-08-07 18:39:50 -07:00
Yury Semikhatsky
3b6ecf0a43 chore(extension): connect button for each page, style tweaks (#848)
<img width="643" height="709" alt="image"
src="https://github.com/user-attachments/assets/850f2455-b853-4c0f-8047-a7f2ced16b7b"
/>
2025-08-07 17:24:48 -07:00
Yury Semikhatsky
636f1956cc chore(extension): explicitly detach from debugger when connection closes (#847) 2025-08-07 14:45:52 -07:00
Yury Semikhatsky
5aef2aafcb devops: switch to node 20 on CI (#844)
Node 18 maintanence period ended in April 2025. Running on 18 already
caused a problem in https://github.com/microsoft/playwright-mcp/pull/842
2025-08-07 10:04:43 -07:00
Yury Semikhatsky
8ecc46c905 chore(extension): add test (#842)
* On Linux headed mode under xvfb-run fails to properly launch the
process. It works fine without xvfb-run, we don't have environment for
that on CI, so run on macOS instead.
* Node v18.20.8 stalls on `const uuid = crypto.randomUUID();`, so use
v20 for the extension tests.
2025-08-06 16:27:39 -07:00
Yury Semikhatsky
5dbb1504ba chore(extension): show error when connection is rejected due to inact… (#836)
…ivity
2025-08-05 15:08:57 -07:00
Yury Semikhatsky
20e1144c3b chore(extension): proper watchdog for inactive page selector (#835) 2025-08-05 14:18:04 -07:00
Yury Semikhatsky
eab20aa69e chore(extension): do not send if socket is already closed (#834)
* Remove debugger listeners if closed() is called as `ws.onclosed` is
dispatched asynchronously
* Tabs can be closed while update badge command is in flight
* Inflight CDP commands fail if the tab closes, do not try to send their
response to a closed socket
2025-08-05 13:47:08 -07:00
Yury Semikhatsky
46ce86f97e chore(extension): terminate connection if nothing has been selected (#827) 2025-08-05 09:47:39 -07:00
Yury Semikhatsky
4890b9d509 chore(extension): create relay per context (#828) 2025-08-05 08:32:54 -07:00
Yury Semikhatsky
3f6837baa9 fix: cursor does not respond to listRoots (#826) 2025-08-04 20:52:55 -07:00
Yury Semikhatsky
6d62c173c8 chore(extension): build into dist directory (#825) 2025-08-04 11:47:25 -07:00
Pavel Feldman
3c6eac9b21 chore: follow up with win test fix (#818) 2025-08-01 18:19:03 -07:00
Yury Semikhatsky
41a44f7abc chore(extension): terminate connection on debugger detach (#816) 2025-08-01 17:56:47 -07:00
Yury Semikhatsky
372395666a chore: allow to switch between browser connection methods (#815) 2025-08-01 17:34:28 -07:00
Pavel Feldman
a60d7b8cd1 chore: slice profile dirs by root in vscode (#814) 2025-08-01 16:59:59 -07:00
Pavel Feldman
ffe0117456 chore: refactor initialize (#812) 2025-08-01 13:06:36 -07:00
Yury Semikhatsky
7c07cc86eb chore(extension): bind relay lifetime to browser context (#804) 2025-07-31 22:25:40 -07:00
Pavel Feldman
3787439fc1 chore: serialize session entries for tool calls and user actions (#803) 2025-07-31 15:16:56 -07:00
Max Schmitt
2a86ac74e3 chore: use pngs by default for screenshots (#797)
1. Use PNG by default.
1. Increase JPG quality from `50` -> `90`.
2025-07-31 11:03:19 +02:00
Pavel Feldman
6dd44923da chore: make tab snapshot structured to mimic it in recorder (#799) 2025-07-30 20:57:34 -07:00
Pavel Feldman
f600234897 chore: record user actions in the session log (#798) 2025-07-30 18:26:13 -07:00
Pavel Feldman
4df162aff5 chore: parse response in tests (#796) 2025-07-30 12:47:22 -07:00
Yury Semikhatsky
65d99fe595 chore(extension): do not show chrome: tabs (#780) 2025-07-29 10:11:44 -07:00
Yury Semikhatsky
903c857f19 chore(extension): use separate package.json (#778) 2025-07-28 17:16:08 -07:00
Yury Semikhatsky
9b5f97b076 chore(extension): use react for connect dialog (#777) 2025-07-28 15:23:33 -07:00
Pavel Feldman
04988d8fac chore: mark v0.0.32 (#768) 2025-07-25 16:40:31 -07:00
Pavel Feldman
2bf57e22c6 chore: do not snapshot on fill (#767) 2025-07-25 15:54:18 -07:00
Yury Semikhatsky
dbf113d5e4 chore(extension): reject second http connection (#766) 2025-07-25 14:46:48 -07:00
Pavel Feldman
6710a78641 Revert "chore: recommend sse by default" (#765)
Reverts microsoft/playwright-mcp#758

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

View File

@@ -11,14 +11,13 @@ 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
- run: npm run build
- name: Run ESLint
run: npm run lint
- name: Ensure no changes
@@ -28,44 +27,35 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-15, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
# MS Edge is not preinstalled on macOS runners.
if: ${{ matrix.os == 'macos-latest' }}
run: npx playwright install msedge
- name: Build
run: npm run build
- name: Run tests
run: npm test
run: npm run test
test_docker:
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
- name: Playwright install
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
@@ -83,3 +73,39 @@ jobs:
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1
test_extension:
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 MCP server
run: |
cd ..
npm ci
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

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

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

View File

@@ -16,7 +16,6 @@ jobs:
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run lint
- run: npm run ctest
- run: npm publish --provenance
@@ -44,6 +43,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 +53,49 @@ 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
package-extension:
runs-on: ubuntu-latest
permissions:
contents: write # Needed to upload release assets
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install extension dependencies
working-directory: ./extension
run: npm ci
- name: Build extension
working-directory: ./extension
run: npm run build
- name: Get extension version
id: get-version
working-directory: ./extension
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Package extension
working-directory: ./extension
run: |
cd dist
zip -r ../playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip .
cd ..
- name: Upload extension to release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip

4
.gitignore vendored
View File

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

View File

@@ -1,7 +1,6 @@
**/*
README.md
LICENSE
!lib/**/*.js
!cli.js
!index.*
!config.d.ts

137
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,137 @@
# Contributing
## Choose an issue
Playwright MCP **requires an issue** for every contribution, except for minor documentation updates.
If you are passionate about a bug/feature, but cannot find an issue describing it, **file an issue first**. This will
facilitate the discussion, and you might get some early feedback from project maintainers before spending your time on
creating a pull request.
## Make a change
> [!WARNING]
> The core of the Playwright MCP was moved to the [Playwright monorepo](https://github.com/microsoft/playwright).
Clone the Playwright repository. If you plan to send a pull request, it might be better to [fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) first.
```bash
git clone https://github.com/microsoft/playwright
cd playwright
```
Install dependencies and run the build in watch mode.
```bash
# install deps and run watch
npm ci
npm run watch
npx playwright install
```
Source code for Playwright MCP is located at [packages/playwright/src/mcp](https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp).
```bash
# list source files
ls -la packages/playwright/src/mcp
```
Coding style is fully defined in [eslint.config.mjs](https://github.com/microsoft/playwright/blob/main/eslint.config.mjs). Before creating a pull request, or at any moment during development, run linter to check all kinds of things:
```bash
# lint the source base before sending PR
npm run flint
```
Comments should have an explicit purpose and should improve readability rather than hinder it. If the code would not be understood without comments, consider re-writing the code to make it self-explanatory.
## Add a test
Playwright requires a test for the new or modified functionality. An exception would be a pure refactoring, but chances are you are doing more than that.
There are multiple [test suites](https://github.com/microsoft/playwright/blob/main/tests) in Playwright that will be executed on the CI. Tests for Playwright MCP are located at [tests/mcp](https://github.com/microsoft/playwright/blob/main/tests/mcp).
```bash
# list test files
ls -la tests/mcp
```
To run the mcp tests, use
```bash
# fast path runs all MCP tests in Chromium
npm run mcp-ctest
```
```bash
# slow path runs all tests in three browsers
npm run mcp-test
```
Since Playwright tests are using Playwright under the hood, everything from our documentation applies, for example [this guide on running and debugging tests](https://playwright.dev/docs/running-tests#running-tests).
Note that tests should be *hermetic*, and not depend on external services. Tests should work on all three platforms: macOS, Linux and Windows.
## Write a commit message
Commit messages should follow the [Semantic Commit Messages](https://www.conventionalcommits.org/en/v1.0.0/) format:
```
label(namespace): title
description
footer
```
1. *label* is one of the following:
- `fix` - bug fixes
- `feat` - new features
- `docs` - documentation-only changes
- `test` - test-only changes
- `devops` - changes to the CI or build
- `chore` - everything that doesn't fall under previous categories
2. *namespace* is put in parentheses after label and is optional. Must be lowercase.
3. *title* is a brief summary of changes.
4. *description* is **optional**, new-line separated from title and is in present tense.
5. *footer* is **optional**, new-line separated from *description* and contains "fixes" / "references" attribution to GitHub issues.
Example:
```
feat(trace viewer): network panel filtering
This patch adds a filtering toolbar to the network panel.
<link to a screenshot>
Fixes #123, references #234.
```
## Send a pull request
All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose.
Make sure to keep your PR (diff) small and readable. If necessary, split your contribution into multiple PRs.
Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests.
After a successful code review, one of the maintainers will merge your pull request. Congratulations!
## More details
**No new dependencies**
There is a very high bar for new dependencies, including updating to a new version of an existing dependency. We recommend to explicitly discuss this in an issue and get a green light from a maintainer, before creating a pull request that updates dependencies.
## Contributor License Agreement
This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
When you submit a pull request, a CLA bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.
### Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.

View File

@@ -32,10 +32,6 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
# Copy the rest of the app
COPY *.json *.js *.ts .
COPY src src/
# Build the app
RUN npm run build
# ------------------------------
# Browser
@@ -63,7 +59,6 @@ USER ${USERNAME}
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]

810
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,123 @@ 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>Codex</summary>
Create or edit the configuration file `~/.codex/config.toml` and add:
```toml
[mcp_servers.playwright]
command = "npx"
args = ["@playwright/mcp@latest"]
```
For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
</details>
<details>
<summary>Cursor</summary>
#### Click the button to install:
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](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@latest`. 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>opencode</summary>
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "local",
"command": [
"npx",
"@playwright/mcp@latest"
],
"enabled": true
}
}
}
```
</details>
<details>
<summary>Qodo Gen</summary>
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,87 +166,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>
#### Click the button to install:
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?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`.
```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>
<details>
<summary><b>Install in Qodo Gen</b></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 following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
Click <code>Save</code>.
</details>
### Configuration
@@ -141,60 +180,72 @@ Playwright MCP server supports following arguments. They can be provided in the
```
> npx @playwright/mcp@latest --help
--allowed-origins <origins> semicolon-separated list of origins to allow the
browser to request. Default is to allow all.
--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.
--block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge.
--browser-agent <endpoint> Use browser agent (experimental).
--caps <caps> comma-separated list of capabilities to enable,
possible values: tabs, pdf, history, wait, files,
install. Default is all.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--executable-path <path> path to the browser executable.
--headless run browser in headless mode, headed by default
--host <host> host to bind server to. Default is localhost. Use
0.0.0.0 to bind to all interfaces.
--ignore-https-errors ignore https errors
--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.
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--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
sessions.
--user-agent <ua string> specify user agent string
--user-data-dir <path> path to the user data directory. If not
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)
--allowed-origins <origins> semicolon-separated list of origins to allow
the browser to request. Default is to allow
all.
--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.
--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 additional
capabilities to enable, possible values:
vision, pdf.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--cdp-header <headers...> CDP headers to send with the connect request,
multiple can be specified.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--executable-path <path> path to the browser executable.
--extension Connect to a running browser instance
(Edge/Chrome only). Requires the "Playwright
MCP Bridge" browser extension to be installed.
--headless run browser in headless mode, headed by
default
--host <host> host to bind server to. Default is localhost.
Use 0.0.0.0 to bind to all interfaces.
--ignore-https-errors ignore https errors
--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" 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.
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
--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.
--secrets <path> path to a file containing secrets in the
dotenv format
--storage-state <path> path to the storage state file for isolated
sessions.
--timeout-action <timeout> specify action timeout in milliseconds,
defaults to 5000ms
--timeout-navigation <timeout> specify navigation timeout in milliseconds,
defaults to 60000ms
--user-agent <ua string> specify user agent string
--user-data-dir <path> path to the user data directory. If not
specified, a temporary directory will be
created.
--viewport-size <size> specify browser viewport size in pixels, for
example "1280, 720"
```
<!--- End of options generated section -->
### User profile
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
**Persistent profile**
@@ -234,6 +285,10 @@ state [here](https://playwright.dev/docs/auth).
}
```
**Browser Extension**
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
### Configuration file
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
@@ -288,21 +343,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;
@@ -316,9 +364,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>
@@ -326,19 +375,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"
}
}
}
@@ -391,42 +440,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 -->
@@ -436,10 +453,29 @@ 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
- `modifiers` (array, optional): Modifier keys to press
- 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
@@ -452,60 +488,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
@@ -515,6 +508,15 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_fill_form**
- Title: Fill form
- Description: Fill multiple form fields
- Parameters:
- `fields` (array): Fields to fill in
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
@@ -523,10 +525,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 -->
@@ -547,40 +554,6 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Title: Go forward
- Description: Go forward to the next page
- Parameters: None
- Read-only: **true**
</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**
- Title: List network requests
- Description: Returns all network requests since loading the page
@@ -589,35 +562,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
@@ -626,127 +579,49 @@ X Y coordinate space, based on the provided screenshot.
- `height` (number): Height of the browser window
- Read-only: **true**
</details>
<!-- NOTE: This has been generated via update-readme.js -->
<details>
<summary><b>Tabs</b></summary>
- **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_tab_list**
- Title: List tabs
- Description: List browser tabs
- **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_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
<!-- 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>
<!-- 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**
- **browser_take_screenshot**
- Title: Take a screenshot
- Description: Take a screenshot of the current page
- Parameters: None
- 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_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**
- Title: Click
- Description: Click left mouse button
- 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: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_drag**
- Title: Drag mouse
- Description: Drag left mouse button
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_type**
- **browser_type**
- Title: Type text
- Description: 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)
- 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`
- `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 -->
@@ -760,25 +635,156 @@ X Y coordinate space, based on the provided screenshot.
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
</details>
<details>
<summary><b>Tab management</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- **browser_tabs**
- Title: Manage tabs
- Description: List, create, close, or select a browser tab.
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- `action` (string): Operation to perform
- `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
- Read-only: **false**
</details>
<details>
<summary><b>Browser installation</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
- 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_mouse_click_xy**
- Title: Click
- 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
- `y` (number): Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
- **browser_mouse_drag_xy**
- Title: Drag mouse
- Description: Drag left mouse button to a given position
- Parameters:
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_move_xy**
- 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**
</details>
<details>
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
<!-- 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**
</details>
<details>
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_element_visible**
- Title: Verify element visible
- Description: Verify element is visible on the page
- Parameters:
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_list_visible**
- Title: Verify list visible
- Description: Verify list is visible on the page
- Parameters:
- `element` (string): Human-readable list description
- `ref` (string): Exact target element reference that points to the list
- `items` (array): Items to verify
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_text_visible**
- Title: Verify text visible
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
- Parameters:
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_verify_value**
- Title: Verify value
- Description: Verify element value
- Parameters:
- `type` (string): Type of the element
- `element` (string): Human-readable element description
- `ref` (string): Exact target element reference that points to the element
- `value` (string): Value to verify. For checkbox, use "true" or "false".
- Read-only: **true**
</details>
<details>
<summary><b>Tracing (opt-in via --caps=tracing)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_start_tracing**
- Title: Start tracing
- Description: Start trace recording
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_stop_tracing**
- Title: Stop tracing
- Description: Stop trace recording
- Parameters: None
- Read-only: **true**
</details>

8
cli.js
View File

@@ -15,4 +15,10 @@
* limitations under the License.
*/
import './lib/program.js';
const { program } = require('playwright-core/lib/utilsBundle');
const { decorateCommand } = require('playwright/lib/mcp/program');
const packageJSON = require('./package.json');
const p = program.version('Version ' + packageJSON.version).name('Playwright MCP');
decorateCommand(p, packageJSON.version)
void program.parseAsync(process.argv);

43
config.d.ts vendored
View File

@@ -16,18 +16,13 @@
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' | 'verify';
export type Config = {
/**
* The browser to use.
*/
browser?: {
/**
* Use browser agent (experimental).
*/
browserAgent?: string;
/**
* The type of browser to use.
*/
@@ -64,6 +59,11 @@ export type Config = {
*/
cdpEndpoint?: string;
/**
* CDP headers to send with the connect request.
*/
cdpHeaders?: Record<string, string>;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
@@ -85,25 +85,28 @@ 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.
*/
saveTrace?: boolean;
/**
* Secrets are used to prevent LLM from getting sensitive data while
* automating scenarios such as authentication.
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
*/
secrets?: Record<string, string>;
/**
* The directory to save output files.
*/
@@ -121,8 +124,20 @@ export type Config = {
blockedOrigins?: string[];
};
timeouts?: {
/*
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
*/
action?: number;
/*
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
*/
navigation?: number;
};
/**
* 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

@@ -1,205 +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 typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import notice from "eslint-plugin-notice";
import path from "path";
import { fileURLToPath } from "url";
import stylistic from "@stylistic/eslint-plugin";
import importRules from "eslint-plugin-import";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const plugins = {
"@stylistic": stylistic,
"@typescript-eslint": typescriptEslint,
notice,
import: importRules,
};
export const baseRules = {
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
],
/**
* Enforced rules
*/
// syntax preferences
"object-curly-spacing": ["error", "always"],
quotes: [
2,
"single",
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
"jsx-quotes": [2, "prefer-single"],
"no-extra-semi": 2,
"@stylistic/semi": [2],
"comma-style": [2, "last"],
"wrap-iife": [2, "inside"],
"spaced-comment": [
2,
"always",
{
markers: ["*"],
},
],
eqeqeq: [2],
"accessor-pairs": [
2,
{
getWithoutSet: false,
setWithoutGet: false,
},
],
"brace-style": [2, "1tbs", { allowSingleLine: true }],
curly: [2, "multi-or-nest", "consistent"],
"new-parens": 2,
"arrow-parens": [2, "as-needed"],
"prefer-const": 2,
"quote-props": [2, "consistent"],
"nonblock-statement-body-position": [2, "below"],
// anti-patterns
"no-var": 2,
"no-with": 2,
"no-multi-str": 2,
"no-caller": 2,
"no-implied-eval": 2,
"no-labels": 2,
"no-new-object": 2,
"no-octal-escape": 2,
"no-self-compare": 2,
"no-shadow-restricted-names": 2,
"no-cond-assign": 2,
"no-debugger": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-unreachable": 2,
"no-unsafe-negation": 2,
radix: 2,
"valid-typeof": 2,
"no-implicit-globals": [2],
"no-unused-expressions": [
2,
{ allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true },
],
"no-proto": 2,
// es2015 features
"require-yield": 2,
"template-curly-spacing": [2, "never"],
// spacing details
"space-infix-ops": 2,
"space-in-parens": [2, "never"],
"array-bracket-spacing": [2, "never"],
"comma-spacing": [2, { before: false, after: true }],
"keyword-spacing": [2, "always"],
"space-before-function-paren": [
2,
{
anonymous: "never",
named: "never",
asyncArrow: "always",
},
],
"no-whitespace-before-property": 2,
"keyword-spacing": [
2,
{
overrides: {
if: { after: true },
else: { after: true },
for: { after: true },
while: { after: true },
do: { after: true },
switch: { after: true },
return: { after: true },
},
},
],
"arrow-spacing": [
2,
{
after: true,
before: true,
},
],
"@stylistic/func-call-spacing": 2,
"@stylistic/type-annotation-spacing": 2,
// file whitespace
"no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }],
"no-mixed-spaces-and-tabs": 2,
"no-trailing-spaces": 2,
"linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
indent: [
2,
2,
{ SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 },
],
"key-spacing": [
2,
{
beforeColon: false,
},
],
"eol-last": 2,
// copyright
"notice/notice": [
2,
{
mustMatch: "Copyright",
templateFile: path.join(__dirname, "utils", "copyright.js"),
},
],
// react
"react/react-in-jsx-scope": 0,
"no-console": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
parserOptions: {
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
}
};
export default [
{
ignores: ["**/*.js"],
},
{
files: ["**/*.ts", "**/*.tsx"],
plugins,
languageOptions,
rules: baseRules,
},
];

View File

@@ -1,10 +0,0 @@
Use Playwright tools to generate test for scenario:
## GitHub PR Checks Navigation Checklist
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
2. Click on the **Pull requests** tab.
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
4. Switch to the **Checks** tab for that pull request.
5. Expand the **infra** check suite to view its jobs.
6. Click on the **docs & lint** job to view its details.

48
extension/README.md Normal file
View File

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

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

35
extension/manifest.json Normal file
View File

@@ -0,0 +1,35 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "0.0.37",
"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.mjs",
"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

35
extension/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.37",
"description": "Playwright MCP Browser Extension",
"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 && vite build --config vite.sw.config.mts",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
"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

@@ -14,23 +14,18 @@
* limitations under the License.
*/
import type { Context } from '../context.js';
import { defineConfig } from '@playwright/test';
export type ResourceSchema = {
uri: string;
name: string;
description?: string;
mimeType?: string;
};
import type { TestOptions } from '../tests/fixtures';
export type ResourceResult = {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
};
export type Resource = {
schema: ResourceSchema;
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
};
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' } },
],
});

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

@@ -0,0 +1,222 @@
/**
* 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';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId?: number;
windowId?: number;
mcpRelayUrl: string;
} | {
type: 'getConnectionStatus';
} | {
type: 'disconnect';
};
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'connectToTab':
const tabId = message.tabId || sender.tab?.id!;
const windowId = message.windowId || sender.tab?.windowId!;
this._connectTab(sender.tab!.id!, tabId, windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
case 'getConnectionStatus':
sendResponse({
connectedTabId: this._connectedTabId
});
return false;
case 'disconnect':
this._disconnect().then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
}
return false;
}
private async _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) {
const message = `Failed to connect to MCP relay: ${error.message}`;
debugLog(message);
throw new Error(message);
}
}
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try {
this._activeConnection?.close('Another connection is requested');
} catch (error: any) {
debugLog(`Error closing active connection:`, error);
}
await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection)
throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => {
debugLog('MCP connection closed');
this._activeConnection = undefined;
void this._setConnectedTabId(null);
};
await Promise.all([
this._setConnectedTabId(tabId),
chrome.tabs.update(tabId, { active: true }),
chrome.windows.update(windowId, { focused: true }),
]);
debugLog(`Connected to MCP bridge`);
} catch (error: any) {
await this._setConnectedTabId(null);
debugLog(`Failed to connect tab ${tabId}:`, error.message);
throw error;
}
}
private async _setConnectedTabId(tabId: number | null): Promise<void> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '' });
if (tabId)
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
}
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
try {
await chrome.action.setBadgeText({ tabId, text });
await chrome.action.setTitle({ tabId, title: title || '' });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
} catch (error: any) {
// Ignore errors as the tab may be closed already.
}
}
private async _onTabRemoved(tabId: number): Promise<void> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
return;
}
if (this._connectedTabId !== tabId)
return;
this._activeConnection?.close('Browser tab closed');
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 (this._connectedTabId === tabId)
void this._setConnectedTabId(tabId);
}
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({});
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
}
private async _onActionClicked(): Promise<void> {
await chrome.tabs.create({
url: chrome.runtime.getURL('status.html'),
active: true
});
}
private async _disconnect(): Promise<void> {
this._activeConnection?.close('User disconnected');
this._activeConnection = undefined;
await this._setConnectedTabId(null);
}
}
new TabShareExtension();

View File

@@ -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,206 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
body {
margin: 0;
padding: 0;
}
/* Base styles */
.app-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: #ffffff;
color: #1f2328;
margin: 0;
padding: 16px;
min-height: 100vh;
font-size: 14px;
}
.content-wrapper {
max-width: 600px;
margin: 0 auto;
}
/* Status Banner */
.status-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-right: 12px;
}
.status-banner {
padding: 12px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.status-banner.connected {
color: #1f2328;
}
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
}
.status-banner.error {
color: #1f2328;
}
.status-banner.error::before {
content: "\274C";
margin-right: 8px;
}
/* Buttons */
.button-container {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
}
.button {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
margin-right: 8px;
min-width: 90px;
}
.button.primary {
background-color: #f8f9fa;
color: #3c4043;
border: 1px solid #dadce0;
}
.button.primary:hover {
background-color: #f1f3f4;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
}
.button.default {
background-color: #f6f8fa;
color: #24292f;
}
.button.default:hover {
background-color: #f3f4f6;
}
.button.reject {
background-color: #da3633;
color: #ffffff;
border: 1px solid #da3633;
}
.button.reject:hover {
background-color: #c73836;
border-color: #c73836;
}
/* Tab selection */
.tab-section-title {
padding-left: 12px;
font-size: 12px;
font-weight: 400;
margin-bottom: 12px;
color: #656d76;
}
.tab-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background-color: #ffffff;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.tab-item:hover {
background-color: #f8f9fa;
}
.tab-item.selected {
background-color: #f6f8fa;
}
.tab-item.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.tab-radio {
margin-right: 12px;
flex-shrink: 0;
}
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-content {
flex: 1;
min-width: 0;
}
.tab-title {
font-weight: 500;
color: #1f2328;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url {
font-size: 12px;
color: #656d76;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Link-style button */
.link-button {
background: none;
border: none;
color: #0066cc;
text-decoration: underline;
cursor: pointer;
padding: 0;
font: inherit;
}

View File

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

View File

@@ -0,0 +1,233 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem';
type Status =
| { type: 'connecting'; message: string }
| { type: 'connected'; message: string }
| { type: 'error'; message: string }
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
const SUPPORTED_PROTOCOL_VERSION = 1;
const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]);
const [status, setStatus] = useState<Status | null>(null);
const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true);
const [clientInfo, setClientInfo] = useState('unknown');
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
const [newTab, setNewTab] = useState<boolean>(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
const extensionVersion = chrome.runtime.getManifest().version;
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
extensionVersion,
}
});
return;
}
void connectToMCPRelay(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
if (params.get('newTab') === 'true') {
setNewTab(true);
setShowTabList(false);
} else {
void loadTabs();
}
}, []);
const handleReject = useCallback((message: string) => {
setShowButtons(false);
setShowTabList(false);
setStatus({ type: 'error', message });
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
handleReject(response.error);
}, [handleReject]);
const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success)
setTabs(response.tabs);
else
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}, []);
const handleConnectToTab = useCallback(async (tab?: TabInfo) => {
setShowButtons(false);
setShowTabList(false);
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToTab',
mcpRelayUrl,
tabId: tab?.id,
windowId: tab?.windowId,
});
if (response?.success) {
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
} else {
setStatus({
type: 'error',
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
});
}
} catch (e) {
setStatus({
type: 'error',
message: `MCP client "${clientInfo}" failed to connect: ${e}`
});
}
}, [clientInfo, mcpRelayUrl]);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject('Connection timed out.');
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, [handleReject]);
return (
<div className='app-container'>
<div className='content-wrapper'>
{status && (
<div className='status-container'>
<StatusBanner status={status} />
{showButtons && (
<div className='button-container'>
{newTab ? (
<>
<Button variant='primary' onClick={() => handleConnectToTab()}>
Allow
</Button>
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
</>
) : (
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
Reject
</Button>
)}
</div>
)}
</div>
)}
{showTabList && (
<div>
<div className='tab-section-title'>
Select page to expose to MCP server:
</div>
<div>
{tabs.map(tab => (
<TabItem
key={tab.id}
tab={tab}
button={
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
Connect
</Button>
}
/>
))}
</div>
</div>
)}
</div>
</div>
);
};
const VersionMismatchError: React.FC<{ extensionVersion: string }> = ({ extensionVersion }) => {
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
const latestReleaseUrl = 'https://github.com/microsoft/playwright-mcp/releases/latest';
return (
<div>
Playwright MCP version trying to connect requires newer extension version (current version: {extensionVersion}).{' '}
<a href={latestReleaseUrl}>Click here</a> to download latest version of the extension, then drag and drop it into the Chrome Extensions page.{' '}
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a> for more details.
</div>
);
};
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
return (
<div className={`status-banner ${status.type}`}>
{'versionMismatch' in status ? (
<VersionMismatchError
extensionVersion={status.versionMismatch.extensionVersion}
/>
) : (
status.message
)}
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<ConnectApp />);
}

View File

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

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

@@ -0,0 +1,110 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem';
interface ConnectionStatus {
isConnected: boolean;
connectedTabId: number | null;
connectedTab?: TabInfo;
}
const StatusApp: React.FC = () => {
const [status, setStatus] = useState<ConnectionStatus>({
isConnected: false,
connectedTabId: null
});
useEffect(() => {
void loadStatus();
}, []);
const loadStatus = async () => {
// Get current connection status from background script
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
if (connectedTabId) {
const tab = await chrome.tabs.get(connectedTabId);
setStatus({
isConnected: true,
connectedTabId,
connectedTab: {
id: tab.id!,
windowId: tab.windowId!,
title: tab.title!,
url: tab.url!,
favIconUrl: tab.favIconUrl
}
});
} else {
setStatus({
isConnected: false,
connectedTabId: null
});
}
};
const openConnectedTab = async () => {
if (!status.connectedTabId)
return;
await chrome.tabs.update(status.connectedTabId, { active: true });
window.close();
};
const disconnect = async () => {
await chrome.runtime.sendMessage({ type: 'disconnect' });
window.close();
};
return (
<div className='app-container'>
<div className='content-wrapper'>
{status.isConnected && status.connectedTab ? (
<div>
<div className='tab-section-title'>
Page with connected MCP client:
</div>
<div>
<TabItem
tab={status.connectedTab}
button={
<Button variant='primary' onClick={disconnect}>
Disconnect
</Button>
}
onClick={openConnectedTab}
/>
</div>
</div>
) : (
<div className='status-banner'>
No MCP clients are currently connected.
</div>
)}
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<StatusApp />);
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
export interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
export interface TabItemProps {
tab: TabInfo;
onClick?: () => void;
button?: React.ReactNode;
}
export const TabItem: React.FC<TabItemProps> = ({
tab,
onClick,
button
}) => {
return (
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>
{tab.title || 'Untitled'}
</div>
<div className='tab-url'>{tab.url}</div>
</div>
{button}
</div>
);
};

View File

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

View File

@@ -0,0 +1,306 @@
/**
* 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 { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures';
type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
};
type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(path.resolve(__dirname, '../dist'));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async (mode?: 'disable-extension') => {
browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: mcpBrowser,
// Opening the browser singleton only works in headed.
headless: false,
// Automation disables singleton browser process behavior, which is necessary for the extension.
ignoreDefaultArgs: ['--enable-automation'],
args: mode === 'disable-extension' ? [] : [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// for manifest v3:
let [serviceWorker] = browserContext.serviceWorkers();
if (!serviceWorker)
serviceWorker = await browserContext.waitForEvent('serviceworker');
return browserContext;
}
});
await browserContext?.close();
},
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
overrideProtocolVersion: async ({}, use) => {
await use((version: number) => {
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
});
process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined;
}
});
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
name: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
return client;
}
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
return client;
}
const testWithOldExtensionVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = path.resolve(__dirname, '../dist');
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
for (const [mode, startClientMethod] of [
['connect-tool', startAndCallConnectTool],
['extension-flag', startWithExtensionFlag],
] as const) {
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto(server.HELLO_WORLD);
// Another empty page.
await browserContext.newPage();
expect(browserContext.pages()).toHaveLength(3);
const client = await startClientMethod(browserWithExtension, startClient);
expect(browserContext.pages()).toHaveLength(3);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_snapshot',
arguments: { },
});
const selectorPage = await confirmationPagePromise;
expect(browserContext.pages()).toHaveLength(4);
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(browserContext.pages()).toHaveLength(4);
});
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(100);
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
isError: true,
});
await confirmationPagePromise;
});
testWithOldExtensionVersion(`works with old extension version (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`extension needs update (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
useShortConnectionTimeout(500);
overrideProtocolVersion(1000);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startClientMethod(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
});
}
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(1000);
const executablePath = test.info().outputPath('echo.sh');
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
launchOptions: {
executablePath,
},
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
timeout: 1000,
});
expect(await navigateResponse).toHaveResponse({
result: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
});

22
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"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",
"noEmit": true
},
"include": [
"src",
],
"exclude": [
"src/ui",
]
}

View File

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

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

@@ -0,0 +1,54 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
viteStaticCopy({
targets: [
{
src: '../../icons/*',
dest: 'icons'
},
{
src: '../../manifest.json',
dest: '.'
}
]
})
],
root: resolve(__dirname, 'src/ui'),
build: {
outDir: resolve(__dirname, 'dist/'),
emptyOutDir: false,
minify: false,
rollupOptions: {
input: ['src/ui/connect.html', 'src/ui/status.html'],
output: {
manualChunks: undefined,
entryFileNames: 'lib/ui/[name].js',
chunkFileNames: 'lib/ui/[name].js',
assetFileNames: 'lib/ui/[name].[ext]'
}
}
}
});

View File

@@ -14,11 +14,18 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { resolve } from 'path';
import { defineConfig } from 'vite';
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.`);
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/background.ts'),
fileName: 'lib/background',
formats: ['es']
},
outDir: 'dist',
emptyOutDir: false,
minify: false
}
});

9
index.d.ts vendored
View File

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

View File

@@ -15,5 +15,5 @@
* limitations under the License.
*/
import { createConnection } from './lib/index.js';
export { createConnection };
const { createConnection } = require('playwright/lib/mcp/index');
module.exports = { createConnection };

3356
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
{
"name": "@playwright/mcp",
"version": "0.0.29",
"version": "0.0.37",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
@@ -16,17 +15,15 @@
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"lint": "npm run update-readme",
"update-readme": "node update-readme.js",
"docker-build": "docker build --no-cache -t playwright-mcp-dev:latest .",
"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"
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
"npm-publish": "npm run clean && npm run test && npm publish"
},
"exports": {
"./package.json": "./package.json",
@@ -36,29 +33,16 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.53.0",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"typescript": "^5.8.2"
"playwright": "1.56.0-alpha-2025-09-06",
"playwright-core": "1.56.0-alpha-2025-09-06"
},
"bin": {
"mcp-server-playwright": "cli.js"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.17.5",
"@playwright/test": "1.56.0-alpha-2025-09-06",
"@types/node": "^24.3.0",
"zod-to-json-schema": "^3.24.6"
}
}

View File

@@ -16,19 +16,16 @@
import { defineConfig } from '@playwright/test';
import type { TestOptions } from './tests/fixtures.js';
import type { TestOptions } from './tests/fixtures';
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 2 : undefined,
reporter: 'list',
projects: [
{ name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{
name: 'chromium-docker',
grep: /browser_navigate|browser_click/,
@@ -37,7 +34,5 @@ export default defineConfig<TestOptions>({
mcpMode: 'docker' as const
}
}] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
],
});

3
src/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Where is the source?
Playwright MCP source code is located in the Playwright monorepo. Please refer to the contributor's guide in [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.

View File

@@ -1,266 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import os from 'node:os';
import debug from 'debug';
import * as playwright from 'playwright';
import { userDataDir } from './fileUtils.js';
import type { FullConfig } from './config.js';
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.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);
if (browserConfig.browserAgent)
return new BrowserServerContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig);
}
export interface BrowserContextFactory {
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
protected _browserPromise: Promise<playwright.Browser> | undefined;
readonly name: string;
constructor(name: string, browserConfig: FullConfig['browser']) {
this.name = name;
this.browserConfig = browserConfig;
}
protected async _obtainBrowser(): Promise<playwright.Browser> {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this.name})`);
this._browserPromise = this._doObtainBrowser();
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
});
}).catch(() => {
this._browserPromise = undefined;
});
return this._browserPromise;
}
protected async _doObtainBrowser(): Promise<playwright.Browser> {
throw new Error('Not implemented');
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
}
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
throw new Error('Not implemented');
}
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
testDebug(`close browser context (${this.name})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(() => {});
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
await browser.close().catch(() => {});
}
}
}
class IsolatedContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('isolated', browserConfig);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
await injectCdpPort(this.browserConfig);
const browserType = playwright[this.browserConfig.browserName];
return browserType.launch({
...this.browserConfig.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
});
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(this.browserConfig.contextOptions);
}
}
class CdpContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('cdp', browserConfig);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
}
}
class RemoteContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('remote', browserConfig);
}
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));
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext();
}
}
class PersistentContextFactory implements BrowserContextFactory {
readonly browserConfig: FullConfig['browser'];
private _userDataDirs = new Set<string>();
constructor(browserConfig: FullConfig['browser']) {
this.browserConfig = browserConfig;
}
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.browserConfig);
testDebug('create browser context (persistent)');
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
const browserType = playwright[this.browserConfig.browserName];
for (let i = 0; i < 5; i++) {
try {
const browserContext = await browserType.launchPersistentContext(userDataDir, {
...this.browserConfig.launchOptions,
...this.browserConfig.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
const close = () => this._closeBrowserContext(browserContext, userDataDir);
return { browserContext, close };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
// User data directory is already in use, try again.
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
throw error;
}
}
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
}
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
testDebug('close browser context (persistent)');
testDebug('release user data dir', userDataDir);
await browserContext.close().catch(() => {});
this._userDataDirs.delete(userDataDir);
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`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
}
export class BrowserServerContextFactory extends BaseContextFactory {
constructor(browserConfig: FullConfig['browser']) {
super('persistent', browserConfig);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
method: 'POST',
body: JSON.stringify({
browserType: this.browserConfig.browserName,
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
launchOptions: this.browserConfig.launchOptions,
contextOptions: this.browserConfig.contextOptions,
} as LaunchBrowserRequest),
});
const info = await response.json() as BrowserInfo;
if (info.error)
throw new Error(info.error);
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
}
private async _createUserDataDir() {
const dir = await userDataDir(this.browserConfig);
await fs.promises.mkdir(dir, { recursive: true });
return dir;
}
}
async function injectCdpPort(browserConfig: FullConfig['browser']) {
if (browserConfig.browserName === 'chromium')
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
}
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);
});
}

View File

@@ -1,197 +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.
*/
/* eslint-disable no-console */
import net from 'net';
import { program } from 'commander';
import playwright from 'playwright';
import { HttpServer } from './httpServer.js';
import { packageJSON } from './package.js';
import type http from 'http';
export type LaunchBrowserRequest = {
browserType: string;
userDataDir: string;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
};
export type BrowserInfo = {
browserType: string;
userDataDir: string;
cdpPort: number;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
error?: string;
};
type BrowserEntry = {
browser?: playwright.Browser;
info: BrowserInfo;
};
class BrowserServer {
private _server = new HttpServer();
private _entries: BrowserEntry[] = [];
constructor() {
this._setupExitHandler();
}
async start(port: number) {
await this._server.start({ port });
this._server.routePath('/json/list', (req, res) => {
this._handleJsonList(res);
});
this._server.routePath('/json/launch', async (req, res) => {
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
});
this._setEntries([]);
}
private _handleJsonList(res: http.ServerResponse) {
const list = this._entries.map(browser => browser.info);
res.end(JSON.stringify(list));
}
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
const request = await readBody<LaunchBrowserRequest>(req);
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
if (!info || info.error)
info = await this._newBrowser(request);
res.end(JSON.stringify(info));
}
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
const cdpPort = await findFreePort();
(request.launchOptions as any).cdpPort = cdpPort;
const info: BrowserInfo = {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
};
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
...request.launchOptions,
...request.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).then(context => {
return { browser: context.browser()!, error: undefined };
}).catch(error => {
return { browser: undefined, error: error.message };
});
this._setEntries([...this._entries, {
browser,
info: {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
error,
},
}]);
browser?.on('disconnected', () => {
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
});
return info;
}
private _updateReport() {
// Clear the current line and move cursor to top of screen
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
if (this._entries.length === 0) {
process.stdout.write('No browsers currently running\n');
return;
}
process.stdout.write('Running browsers:\n');
for (const entry of this._entries) {
const status = entry.browser ? 'running' : 'error';
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
if (entry.info.error)
process.stdout.write(` Error: ${entry.info.error}\n`);
}
}
private _setEntries(entries: BrowserEntry[]) {
this._entries = entries;
this._updateReport();
}
private _setupExitHandler() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
for (const entry of this._entries)
await entry.browser?.close().catch(() => {});
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}
program
.name('browser-agent')
.option('-p, --port <port>', 'Port to listen on', '9224')
.action(async options => {
await main(options);
});
void program.parseAsync(process.argv);
async function main(options: { port: string }) {
const server = new BrowserServer();
await server.start(+options.port);
}
function readBody<T>(req: http.IncomingMessage): Promise<T> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
});
}
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);
});
}

View File

@@ -1,254 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
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;
browserAgent?: string;
caps?: string;
cdpEndpoint?: string;
config?: string;
device?: string;
executablePath?: string;
headless?: boolean;
host?: string;
ignoreHttpsErrors?: boolean;
isolated?: boolean;
imageResponses?: 'allow' | 'omit' | 'auto';
sandbox: boolean;
outputDir?: string;
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
vision?: boolean;
};
const defaultConfig: FullConfig = {
browser: {
browserName: 'chromium',
launchOptions: {
channel: 'chrome',
headless: os.platform() === 'linux' && !process.env.DISPLAY,
chromiumSandbox: true,
},
contextOptions: {
viewport: null,
},
},
network: {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
};
type BrowserUserConfig = NonNullable<Config['browser']>;
export type FullConfig = Config & {
browser: Omit<BrowserUserConfig, 'browserName'> & {
browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
server: NonNullable<Config['server']>,
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
return mergeConfig(defaultConfig, config);
}
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');
return result;
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined;
switch (cliOptions.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'chromium':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = cliOptions.browser;
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
}
// Launch options
const launchOptions: LaunchOptions = {
channel,
executablePath: cliOptions.executablePath,
headless: cliOptions.headless,
};
// --no-sandbox was passed, disable the sandbox
if (!cliOptions.sandbox)
launchOptions.chromiumSandbox = false;
if (cliOptions.proxyServer) {
launchOptions.proxy = {
server: cliOptions.proxyServer
};
if (cliOptions.proxyBypass)
launchOptions.proxy.bypass = cliOptions.proxyBypass;
}
// Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
if (cliOptions.storageState)
contextOptions.storageState = cliOptions.storageState;
if (cliOptions.userAgent)
contextOptions.userAgent = cliOptions.userAgent;
if (cliOptions.viewportSize) {
try {
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
if (isNaN(width) || isNaN(height))
throw new Error('bad values');
contextOptions.viewport = { width, height };
} catch (e) {
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
}
}
if (cliOptions.ignoreHttpsErrors)
contextOptions.ignoreHTTPSErrors = true;
if (cliOptions.blockServiceWorkers)
contextOptions.serviceWorkers = 'block';
const result: Config = {
browser: {
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
browserName,
isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir,
launchOptions,
contextOptions,
cdpEndpoint: cliOptions.cdpEndpoint,
},
server: {
port: cliOptions.port,
host: cliOptions.host,
},
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
};
return result;
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
if (!configFile)
return {};
try {
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
} catch (error) {
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
}
}
export async function outputFile(config: FullConfig, name: string): Promise<string> {
await fs.promises.mkdir(config.outputDir, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(config.outputDir, fileName);
}
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
return Object.fromEntries(
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
) as 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: {
...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions),
...{ assistantMode: true },
},
contextOptions: {
...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions),
},
};
if (browser.browserName !== 'chromium' && browser.launchOptions)
delete browser.launchOptions.channel;
return {
...pickDefined(base),
...pickDefined(overrides),
browser,
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
},
server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig;
}

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

@@ -1,351 +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 debug from 'debug';
import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.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>;
};
const testDebug = debug('pw:mcp:test');
export class Context {
readonly tools: Tool[];
readonly config: FullConfig;
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;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools;
this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
}
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;
}
tabs(): Tab[] {
return this._tabs;
}
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
return this._currentTab;
}
async newTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
}
async selectTab(index: number) {
this._currentTab = this._tabs[index - 1];
await this._currentTab.page.bringToFront();
}
async ensureTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab)
await browserContext.newPage();
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) {
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;
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
if (!this._currentTab)
this._currentTab = tab;
}
private _onPageClosed(tab: Tab) {
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
this._tabs.splice(index, 1);
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (!this._tabs.length)
void this.close();
}
async close() {
if (!this._browserContextPromise)
return;
testDebug('close context');
const promise = this._browserContextPromise;
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, close }) => {
if (this.config.saveTrace)
await browserContext.tracing.stop();
await close();
});
}
private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));
for (const origin of this.config.network.allowedOrigins)
await context.route(`*://${origin}/**`, route => route.continue());
}
if (this.config.network?.blockedOrigins?.length) {
for (const origin of this.config.network.blockedOrigins)
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
}
}
private _ensureBrowserContext() {
if (!this._browserContextPromise) {
this._browserContextPromise = this._setupBrowserContext();
this._browserContextPromise.catch(() => {
this._browserContextPromise = undefined;
});
}
return this._browserContextPromise;
}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext();
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
}
return result;
}
}

View File

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

View File

@@ -1,232 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import http from 'http';
import net from 'net';
import mime from 'mime';
import { ManualPromise } from './manualPromise.js';
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
export type Transport = {
sendEvent?: (method: string, params: any) => void;
close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void;
};
export class HttpServer {
private _server: http.Server;
private _urlPrefixPrecise: string = '';
private _urlPrefixHumanReadable: string = '';
private _port: number = 0;
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._server = http.createServer(this._onRequest.bind(this));
decorateServer(this._server);
}
server() {
return this._server;
}
routePrefix(prefix: string, handler: ServerRouteHandler) {
this._routes.push({ prefix, handler });
}
routePath(path: string, handler: ServerRouteHandler) {
this._routes.push({ exact: path, handler });
}
port(): number {
return this._port;
}
private async _tryStart(port: number | undefined, host: string) {
const errorPromise = new ManualPromise();
const errorListener = (error: Error) => errorPromise.reject(error);
this._server.on('error', errorListener);
try {
this._server.listen(port, host);
await Promise.race([
new Promise(cb => this._server!.once('listening', cb)),
errorPromise,
]);
} finally {
this._server.removeListener('error', errorListener);
}
}
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
const host = options.host || 'localhost';
if (options.preferredPort) {
try {
await this._tryStart(options.preferredPort, host);
} catch (e: any) {
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
throw e;
await this._tryStart(undefined, host);
}
} else {
await this._tryStart(options.port, host);
}
const address = this._server.address();
if (typeof address === 'string') {
this._urlPrefixPrecise = address;
this._urlPrefixHumanReadable = address;
} else {
this._port = address!.port;
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
}
}
async stop() {
await new Promise(cb => this._server!.close(cb));
}
urlPrefix(purpose: 'human-readable' | 'precise'): string {
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
}
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
try {
for (const [name, value] of Object.entries(headers || {}))
response.setHeader(name, value);
if (request.headers.range)
this._serveRangeFile(request, response, absoluteFilePath);
else
this._serveFile(response, absoluteFilePath);
return true;
} catch (e) {
return false;
}
}
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
const content = fs.readFileSync(absoluteFilePath);
response.statusCode = 200;
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Length', content.byteLength);
response.end(content);
}
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
const range = request.headers.range;
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
response.statusCode = 400;
return response.end('Bad request');
}
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
let start: number;
let end: number;
const size = fs.statSync(absoluteFilePath).size;
if (startStr !== '' && endStr === '') {
// No end specified: use the whole file
start = +startStr;
end = size - 1;
} else if (startStr === '' && endStr !== '') {
// No start specified: calculate start manually
start = size - +endStr;
end = size - 1;
} else {
start = +startStr;
end = +endStr;
}
// Handle unavailable range request
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
response.writeHead(416, {
'Content-Range': `bytes */${size}`
});
return response.end();
}
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
response.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
});
const readable = fs.createReadStream(absoluteFilePath, { start, end });
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
}
request.on('error', () => response.end());
try {
if (!request.url) {
response.end();
return;
}
const url = new URL('http://localhost' + request.url);
for (const route of this._routes) {
if (route.exact && url.pathname === route.exact) {
route.handler(request, response);
return;
}
if (route.prefix && url.pathname.startsWith(route.prefix)) {
route.handler(request, response);
return;
}
}
response.statusCode = 404;
response.end();
} catch (e) {
response.end();
}
}
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -1,46 +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 as createConnectionImpl } from './connection.js';
import type { Connection } from '../index.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
return createConnectionImpl(config, factory);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {
private readonly _contextGetter: () => Promise<BrowserContext>;
constructor(contextGetter: () => Promise<BrowserContext>) {
this._contextGetter = contextGetter;
}
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
const browserContext = await this._contextGetter();
return {
browserContext,
close: () => browserContext.close()
};
}
}

View File

@@ -1,53 +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.
*/
// adapted from:
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
// NOTE: this function should not be used to escape any selectors.
export function escapeWithQuotes(text: string, char: string = '\'') {
const stringified = JSON.stringify(text);
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
if (char === '\'')
return char + escapedText.replace(/[']/g, '\\\'') + char;
if (char === '"')
return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`')
return char + escapedText.replace(/[`]/g, '`') + char;
throw new Error('Invalid escape char');
}
export function quote(text: string) {
return escapeWithQuotes(text, '\'');
}
export function formatObject(value: any, indent = ' '): string {
if (typeof value === 'string')
return quote(value);
if (Array.isArray(value))
return `[${value.map(o => formatObject(o)).join(', ')}]`;
if (typeof value === 'object') {
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length)
return '{}';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${key}: ${formatObject(value[key])}`);
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
}
return String(value);
}

View File

@@ -1,127 +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.
*/
export class ManualPromise<T = void> extends Promise<T> {
private _resolve!: (t: T) => void;
private _reject!: (e: Error) => void;
private _isDone: boolean;
constructor() {
let resolve: (t: T) => void;
let reject: (e: Error) => void;
super((f, r) => {
resolve = f;
reject = r;
});
this._isDone = false;
this._resolve = resolve!;
this._reject = reject!;
}
isDone() {
return this._isDone;
}
resolve(t: T) {
this._isDone = true;
this._resolve(t);
}
reject(e: Error) {
this._isDone = true;
this._reject(e);
}
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'ManualPromise';
}
}
export class LongStandingScope {
private _terminateError: Error | undefined;
private _closeError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
reject(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())
p.resolve(error);
}
close(error: Error) {
this._isClosed = true;
this._closeError = error;
for (const [p, frames] of this._terminatePromises)
p.resolve(cloneError(error, frames));
}
isClosed() {
return this._isClosed;
}
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
return this._race([promise], true, defaultValue);
}
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
const terminatePromise = new ManualPromise<Error>();
const frames = captureRawStack();
if (this._terminateError)
terminatePromise.resolve(this._terminateError);
if (this._closeError)
terminatePromise.resolve(cloneError(this._closeError, frames));
this._terminatePromises.set(terminatePromise, frames);
try {
return await Promise.race([
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
...promises
]);
} finally {
this._terminatePromises.delete(terminatePromise);
}
}
}
function cloneError(error: Error, frames: string[]) {
const clone = new Error();
clone.name = error.name;
clone.message = error.message;
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
return clone;
}
function captureRawStack(): string[] {
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 50;
const error = new Error();
const stack = error.stack || '';
Error.stackTraceLimit = stackTraceLimit;
return stack.split('\n');
}

View File

@@ -1,22 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import path from 'node:path';
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

@@ -1,78 +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 { program } 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 { packageJSON } from './package.js';
program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
.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('--browser-agent <endpoint>', 'Use browser agent (experimental).')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.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"')
.option('--executable-path <path>', 'path to the browser executable.')
.option('--headless', 'run browser in headless mode, headed by default')
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--ignore-https-errors', 'ignore https errors')
.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('--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-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)')
.action(async options => {
const config = await resolveCLIConfig(options);
const server = new Server(config);
server.setupExitWatchdog();
if (config.server.port !== undefined)
startHttpTransport(server);
else
await startStdioTransport(server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
}
});
function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim());
}
void program.parseAsync(process.argv);

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

View File

@@ -1,120 +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 { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _consoleMessages: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._consoleMessages.push(event));
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({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.on('download', download => {
void this.context.downloadStarted(this, download);
});
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._consoleMessages.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
const e = _e as Error;
const mightBeDownload =
e.message.includes('net::ERR_ABORTED') // chromium
|| 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, 1000)),
]);
if (!download)
throw e;
}
// 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[] {
return this._consoleMessages;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}

View File

@@ -1,66 +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 common from './tools/common.js';
import console from './tools/console.js';
import dialogs from './tools/dialogs.js';
import files from './tools/files.js';
import install from './tools/install.js';
import keyboard from './tools/keyboard.js';
import navigate from './tools/navigate.js';
import network from './tools/network.js';
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 type { Tool } from './tools/tool.js';
export const snapshotTools: Tool<any>[] = [
...common(true),
...console,
...dialogs(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
...network,
...pdf,
...screenshot,
...snapshot,
...tabs(true),
...testing,
...wait(true),
];
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),
];

View File

@@ -1,78 +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, type ToolFactory } from './tool.js';
const close = defineTool({
capability: 'core',
schema: {
name: 'browser_close',
title: 'Close browser',
description: 'Close the page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.close();
return {
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
const resize: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
title: 'Resize browser window',
description: 'Resize the browser window',
inputSchema: z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
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.page.setViewportSize({ width: params.width, height: params.height });
};
return {
code,
action,
captureSnapshot,
waitForNetwork: true
};
},
});
export default (captureSnapshot: boolean) => [
close,
resize(captureSnapshot)
];

View File

@@ -1,47 +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 console = defineTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
title: 'Get console messages',
description: 'Returns all console messages',
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,
};
},
});
export default [
console,
];

View File

@@ -1,62 +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, type ToolFactory } from './tool.js';
const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_handle_dialog',
title: 'Handle a dialog',
description: 'Handle a dialog',
inputSchema: z.object({
accept: z.boolean().describe('Whether to accept the dialog.'),
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const dialogState = context.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,
};
},
clearsModalState: 'dialog',
});
export default (captureSnapshot: boolean) => [
handleDialog(captureSnapshot),
];

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 { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',
schema: {
name: 'browser_file_upload',
title: 'Upload files',
description: 'Upload one or multiple files',
inputSchema: z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const modalState = context.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(', ')}`,
];
const action = async () => {
await modalState.fileChooser.setFiles(params.paths);
context.clearModalState(modalState);
};
return {
code,
action,
captureSnapshot,
waitForNetwork: true,
};
},
clearsModalState: 'fileChooser',
});
export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
];

View File

@@ -1,63 +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 { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool.js';
import { fileURLToPath } from 'node:url';
const install = defineTool({
capability: 'install',
schema: {
name: '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.',
inputSchema: z.object({}),
type: 'destructive',
},
handle: async context => {
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');
const child = fork(cliPath, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];
child.stdout?.on('data', data => output.push(data.toString()));
child.stderr?.on('data', data => output.push(data.toString()));
await new Promise<void>((resolve, reject) => {
child.on('close', code => {
if (code === 0)
resolve();
else
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
return {
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
},
});
export default [
install,
];

View File

@@ -1,54 +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, type ToolFactory } from './tool.js';
const pressKey: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_press_key',
title: 'Press a key',
description: 'Press a key on the keyboard',
inputSchema: z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
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
};
},
});
export default (captureSnapshot: boolean) => [
pressKey(captureSnapshot),
];

View File

@@ -1,104 +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, type ToolFactory } from './tool.js';
const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_navigate',
title: 'Navigate to a URL',
description: 'Navigate to a URL',
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
type: 'destructive',
},
handle: async (context, params) => {
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,
};
},
});
const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_back',
title: 'Go back',
description: 'Go back to the previous page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = await context.ensureTab();
await tab.page.goBack();
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
description: 'Go forward to the next page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = context.currentTabOrDie();
await tab.page.goForward();
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
export default (captureSnapshot: boolean) => [
navigate(captureSnapshot),
goBack(captureSnapshot),
goForward(captureSnapshot),
];

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 { z } from 'zod';
import { defineTool } from './tool.js';
import type * as playwright from 'playwright';
const requests = defineTool({
capability: 'core',
schema: {
name: 'browser_network_requests',
title: 'List network requests',
description: 'Returns all network requests since loading the page',
inputSchema: z.object({}),
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,
};
},
});
function renderRequest(request: playwright.Request, response: playwright.Response | null) {
const result: string[] = [];
result.push(`[${request.method().toUpperCase()}] ${request.url()}`);
if (response)
result.push(`=> [${response.status()}] ${response.statusText()}`);
return result.join(' ');
}
export default [
requests,
];

View File

@@ -1,58 +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';
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({
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
title: 'Save as PDF',
description: 'Save page as PDF',
inputSchema: pdfSchema,
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,
};
},
});
export default [
pdf,
];

View File

@@ -1,90 +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';
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.'),
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.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
});
const screenshot = defineTool({
capability: 'core',
schema: {
name: '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.`,
inputSchema: screenshotSchema,
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 };
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
code.push(`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',
}] : []
};
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: false,
};
}
});
export default [
screenshot,
];

View File

@@ -1,226 +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';
import { generateLocator } from './utils.js';
const snapshot = defineTool({
capability: 'core',
schema: {
name: 'browser_snapshot',
title: 'Page snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.ensureTab();
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
},
});
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({
capability: 'core',
schema: {
name: 'browser_click',
title: 'Click',
description: 'Perform click on a web page',
inputSchema: elementSchema,
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
const code = [
`// Click ${params.element}`,
`await page.${await generateLocator(locator)}.click();`
];
return {
code,
action: () => locator.click(),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_drag',
title: 'Drag mouse',
description: 'Perform drag and drop between two elements',
inputSchema: z.object({
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
startRef: z.string().describe('Exact source element reference from the page snapshot'),
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
endRef: z.string().describe('Exact target element reference from the page snapshot'),
}),
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 });
const code = [
`// Drag ${params.startElement} to ${params.endElement}`,
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
];
return {
code,
action: () => startLocator.dragTo(endLocator),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
const hover = defineTool({
capability: 'core',
schema: {
name: 'browser_hover',
title: 'Hover mouse',
description: 'Hover over element on page',
inputSchema: elementSchema,
type: 'readOnly',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code = [
`// Hover over ${params.element}`,
`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,
};
},
});
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({
capability: 'core',
schema: {
name: 'browser_select_option',
title: 'Select option',
description: 'Select an option in a dropdown',
inputSchema: selectOptionSchema,
type: 'destructive',
},
handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
return {
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
});
export default [
snapshot,
click,
drag,
hover,
type,
selectOption,
];

View File

@@ -1,134 +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, type ToolFactory } from './tool.js';
const listTabs = defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_list',
title: 'List tabs',
description: 'List browser tabs',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.ensureTab();
return {
code: [`// <internal code to list tabs>`],
captureSnapshot: false,
waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: await context.listTabsMarkdown(),
}],
},
};
},
});
const selectTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_select',
title: 'Select a tab',
description: 'Select a tab by index',
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
const newTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_new',
title: 'Open a new tab',
description: 'Open a new tab',
inputSchema: z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
await context.newTab();
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
const closeTab: ToolFactory = captureSnapshot => defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_close',
title: 'Close a tab',
description: 'Close a tab',
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
}),
type: 'destructive',
},
handle: async (context, params) => {
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
code,
captureSnapshot,
waitForNetwork: false
};
},
});
export default (captureSnapshot: boolean) => [
listTabs,
newTab(captureSnapshot),
selectTab(captureSnapshot),
closeTab(captureSnapshot),
];

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

@@ -1,68 +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 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;
export type FileUploadModalState = {
type: 'fileChooser';
description: string;
fileChooser: playwright.FileChooser;
};
export type DialogModalState = {
type: 'dialog';
description: string;
dialog: playwright.Dialog;
};
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 InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export type ToolFactory = (snapshot: boolean) => Tool<any>;
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -1,86 +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 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> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
const waitBarrier = new Promise<void>(f => { waitCallback = f; });
const requestListener = (request: playwright.Request) => requests.add(request);
const requestFinishedListener = (request: playwright.Request) => {
requests.delete(request);
if (!requests.size)
waitCallback();
};
const frameNavigateListener = (frame: playwright.Frame) => {
if (frame.parentFrame())
return;
frameNavigated = true;
dispose();
clearTimeout(timeout);
void tab.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
dispose();
waitCallback();
};
tab.page.on('request', requestListener);
tab.page.on('requestfinished', requestFinishedListener);
tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
tab.page.off('request', requestListener);
tab.page.off('requestfinished', requestFinishedListener);
tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
try {
const result = await callback();
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await context.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();
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
}

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

@@ -1,70 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait_for',
title: 'Wait for',
description: 'Wait for text to appear or disappear or a specified time to pass',
inputSchema: z.object({
time: z.number().optional().describe('The time to wait in seconds'),
text: z.string().optional().describe('The text to wait for'),
textGone: z.string().optional().describe('The text to wait for to disappear'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
const code: string[] = [];
if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
}
const tab = context.currentTabOrDie();
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
await locator.waitFor({ state: 'visible' });
}
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
export default (captureSnapshot: boolean) => [
wait(captureSnapshot),
];

View File

@@ -1,138 +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 http from 'node:http';
import assert from 'node:assert';
import crypto from 'node: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 type { Server } from './server.js';
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
}
const testDebug = debug('pw:mcp:test');
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
return res.end('Missing sessionId');
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
return res.end('Session not found');
}
return await transport.handlePostMessage(req, res);
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
testDebug(`create SSE session: ${transport.sessionId}`);
const connection = await server.createConnection(transport);
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;
}
res.statusCode = 405;
res.end('Method not allowed');
}
async function handleStreamable(server: Server, 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);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
return;
}
return await transport.handleRequest(req, res);
}
if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: sessionId => {
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
if (transport.sessionId)
sessions.delete(transport.sessionId);
};
await server.createConnection(transport);
await transport.handleRequest(req, res);
return;
}
res.statusCode = 400;
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) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp'))
await handleStreamable(server, req, res, streamableSessions);
else
await handleSSE(server, req, res, url, sseSessions);
});
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`
}
}
}, undefined, 2),
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
});
}

View File

@@ -1,77 +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 path from 'path';
import url from 'node:url';
import { spawn } from 'child_process';
import { test as baseTest, expect } from './fixtures.js';
import type { ChildProcess } from 'child_process';
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
agentEndpoint: async ({}, use) => {
let cp: ChildProcess | undefined;
await use(async (options?: { args?: string[] }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../lib/browserServer.js'),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stdout = '';
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stdout: () => stdout };
});
cp?.kill('SIGTERM');
},
});
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
const { url: agentUrl } = await agentEndpoint();
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
await client1.close();
await client2.close();
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
@@ -22,8 +22,9 @@ test('test snapshot tool list', async ({ client }) => {
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_fill_form',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
@@ -31,62 +32,75 @@ test('test snapshot tool list', async ({ client }) => {
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_snapshot',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_tabs',
'browser_take_screenshot',
'browser_wait_for',
]));
});
test('test vision tool list', async ({ visionClient }) => {
const { tools: visionTools } = await visionClient.listTools();
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
test('test tool list proxy mode', async ({ startClient }) => {
const { client } = await startClient({
args: ['--connect-tool'],
});
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_connect', // the extra tool
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_fill_form',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_network_requests',
'browser_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_snapshot',
'browser_tabs',
'browser_take_screenshot',
'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

@@ -1,77 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
expect(await client.callTool({
name: 'browser_snapshot',
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Hello, world!
\`\`\`
`);
});
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpServer.start();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});

View File

@@ -14,32 +14,30 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('browser_network_requests', async ({ client, server }) => {
test('browser_click', async ({ client, server, mcpBrowser }) => {
server.setContent('/', `
<button onclick="fetch('/json')">Click me</button>
<title>Title</title>
<button>Submit</button>
`, 'text/html');
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
arguments: { url: server.PREFIX },
})).toHaveResponse({
code: `await page.goto('${server.PREFIX}');`,
pageState: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
});
await client.callTool({
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me button',
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]`),
});
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
});

View File

@@ -1,63 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server }, testInfo) => {
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
const config: Config = {
browser: {
userDataDir: testInfo.outputPath('user-data-dir'),
},
};
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Hello, world!`);
const files = await fs.promises.readdir(config.browser!.userDataDir!);
expect(files.length).toBeGreaterThan(0);
});
test.describe(() => {
test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
const config: Config = {
browser: {
browserName: 'firefox',
},
};
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
})).toContainTextContent(`Firefox`);
});
});

View File

@@ -1,44 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_console_messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
console.log("Hello, world!");
console.error("Error");
</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).toHaveTextContent([
'[LOG] Hello, world!',
'[ERROR] Error',
].join('\n'));
});

View File

@@ -14,226 +14,19 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
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 },
});
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 }) => {
server.setContent('/', `
<title>Title</title>
<select>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 'e2',
values: ['bar'],
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Select options [bar] in Select
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=e2]:
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
});
test('browser_select_option (multiple)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<select multiple>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
<option value="baz">Baz</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
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
- 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,
},
});
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 }) => {
server.setContent('/', `
<title>Resize Test</title>
<body>
<div id="size">Waiting for resize...</div>
<script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
</script>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
const response = await client.callTool({
name: 'browser_resize',
arguments: {
width: 390,
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');
- generic [active] [ref=e1]: Hello, world!
\`\`\``,
});
});

View File

@@ -1,43 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => {
const { client } = await startClient({
args: ['--device', 'iPhone 15'],
});
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body></body>
<script>
document.body.textContent = window.innerWidth + "x" + window.innerHeight;
</script>
`);
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`393x659`);
});

View File

@@ -1,212 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
// 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]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
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"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
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>
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
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`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result).not.toContainTextContent('### Modal state');
});
test('confirm dialog (true)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: 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 }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: false,
},
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: "false"
\`\`\``);
});
test('prompt dialog', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
const result = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
promptText: 'Answer',
},
});
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Answer
\`\`\``);
});

View File

@@ -1,147 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
test('browser_file_upload', async ({ client, server }, testInfo) => {
server.setContent('/', `
<input type="file" />
<button>Button</button>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- 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());
}
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
const filePath = testInfo.outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
const response = await client.callTool({
name: 'browser_file_upload',
arguments: {
paths: [filePath],
},
});
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 'e2',
},
});
expect(response).toContainTextContent('- [File chooser]: can be handled by the \"browser_file_upload\" tool');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 'e3',
},
});
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`);
}
});
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
server.setContent('/download', 'Data', 'text/plain');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- link "Download" [ref=e2]');
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
});
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, 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');
server.route('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Disposition': 'attachment; filename=test.txt',
});
res.end('Hello world!');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX + 'download',
},
})).toContainTextContent('### Downloads');
});

View File

@@ -15,19 +15,22 @@
*/
import fs from 'fs';
import url from 'url';
import path from 'path';
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 { TestServer } from './testserver/index.ts';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from './testserver/index';
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 = {
mcpArgs: string[] | undefined;
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
};
@@ -37,10 +40,18 @@ type CDPServer = {
start: () => Promise<BrowserContext>;
};
export type StartClient = (options?: {
clientName?: string,
args?: string[],
config?: Config,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
}) => Promise<{ client: Client, stderr: () => string }>;
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
startClient: StartClient;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
@@ -54,25 +65,19 @@ type WorkerFixtures = {
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
mcpArgs: [undefined, { option: true }],
client: async ({ startClient }, use) => {
const { client } = await startClient();
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 = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, mcpArgs }, use, testInfo) => {
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
const clients: Client[] = [];
await use(async options => {
const args: string[] = [];
if (userDataDir)
args.push('--user-data-dir', userDataDir);
const args: string[] = mcpArgs ?? [];
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
@@ -87,20 +92,30 @@ 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 => {
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
if (options.rootsResponseDelay)
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
return {
roots: options.roots,
};
});
}
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);
stderr += data.toString();
stderrBuffer += data.toString();
});
clients.push(client);
await client.connect(transport);
await client.ping();
return { client, stderr: () => stderr };
return { client, stderr: () => stderrBuffer };
});
await client?.close();
await Promise.all(clients.map(client => client.close()));
},
wsEndpoint: async ({ }, use) => {
@@ -117,6 +132,8 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
if (browserContext)
throw new Error('CDP server already exists');
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
channel: mcpBrowser,
headless: true,
@@ -138,7 +155,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);
@@ -164,71 +181,52 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
});
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
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), '..'),
args: [path.join(__dirname, '../cli.js'), ...args],
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,
@@ -245,3 +243,48 @@ export const expect = baseExpect.extend({
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
}
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

@@ -1,50 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
for (const mcpHeadless of [false, true]) {
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
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) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<body></body>
<script>
document.body.textContent = navigator.userAgent;
</script>
`);
});
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
expect(response).toContainTextContent(`Mozilla/5.0`);
if (mcpHeadless)
expect(response).toContainTextContent(`HeadlessChrome`);
else
expect(response).not.toContainTextContent(`HeadlessChrome`);
});
});
}

View File

@@ -1,44 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
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]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [ref=f1e1]:
- button "World" [ref=f1e2]
- main [ref=f1e3]:
- iframe [ref=f1e4]:
- paragraph [ref=f2e2]: Nested
\`\`\``);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1e2',
},
})).toContainTextContent(`// Click World`);
});

View File

@@ -1,157 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ startClient, server }) => {
const { client, stderr } = await startClient();
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
name: 'browser_close',
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
if (process.platform === 'win32')
return;
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
'create context',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
]);
});
test('executable path', async ({ startClient, server }) => {
const { client } = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response).toContainTextContent(`executable doesn't exist`);
});
test('persistent context', async ({ startClient, server }) => {
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
localStorage.setItem('test', 'test');
</script>
`, 'text/html');
const { client } = await startClient();
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await new Promise(resolve => setTimeout(resolve, 3000));
await client.callTool({
name: 'browser_close',
});
const { client: client2 } = await startClient();
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response2).toContainTextContent(`Storage: YES`);
});
test('isolated context', async ({ startClient, server }) => {
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
localStorage.setItem('test', 'test');
</script>
`, 'text/html');
const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await client1.callTool({
name: 'browser_close',
});
const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response2).toContainTextContent(`Storage: NO`);
});
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
const storageStatePath = testInfo.outputPath('storage-state.json');
await fs.promises.writeFile(storageStatePath, JSON.stringify({
origins: [
{
origin: server.PREFIX,
localStorage: [{ name: 'test', value: 'session-value' }],
},
],
}));
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
</script>
`, 'text/html');
const { client } = await startClient({ args: [
`--isolated`,
`--storage-state=${storageStatePath}`,
] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
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 { test, expect } from './fixtures.js';
import fs from 'node:fs/promises';
import child_process from 'node:child_process';
import child_process from 'child_process';
import fs from 'fs/promises';
import { test, expect } from './fixtures';
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

@@ -1,83 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => {
const { client } = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
name: 'browser_pdf_save',
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
expect(await client.callTool({
name: 'browser_pdf_save',
arguments: {
filename: 'output.pdf',
},
})).toEqual({
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
expect(pdfFiles).toHaveLength(1);
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
});

View File

@@ -1,82 +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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test, expect } from './fixtures.ts';
const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
const fetchPage = async (client: Client, url: string) => {
const result = await client.callTool({
name: 'browser_navigate',
arguments: {
url,
},
});
return JSON.stringify(result, null, 2);
};
test('default to allow all', async ({ server, client }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
test('blocked works', async ({ startClient }) => {
const { client } = await startClient({
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('allowed works', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
test('blocked takes precedence', async ({ startClient }) => {
const { client } = await startClient({
args: [
'--blocked-origins', 'example.com',
'--allowed-origins', 'example.com',
],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
const { client } = await startClient({
args: ['--allowed-origins', 'playwright.dev'],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--blocked-origins', 'example.com'],
});
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});

View File

@@ -1,232 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
element: 'hello button',
ref: 'e1',
},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
],
});
});
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(fs.existsSync(outputDir)).toBeTruthy();
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
});
for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const ext = raw ? 'png' : 'jpeg';
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: { raw },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
);
});
}
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
filename: 'output.jpeg',
},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output\.jpeg$/);
});
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: {
outputDir,
imageResponses: 'omit',
},
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
clientName: 'cursor:vscode',
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});

View File

@@ -1,246 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
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('sse transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse 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 SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(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 client1.close();
const transport2 = new SSEClientTransport(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 client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE 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('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(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 SSEClientTransport(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 client1.close();
const transport3 = new SSEClientTransport(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 client2.close();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete SSE 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('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new SSEClientTransport(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 client1.close();
const transport2 = new SSEClientTransport(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 client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE 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('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new SSEClientTransport(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 SSEClientTransport(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('streamable 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();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

@@ -1,152 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
async function createTab(client: Client, title: string, body: string) {
return await client.callTool({
name: 'browser_tab_new',
arguments: {
url: `data:text/html,<title>${title}</title><body>${body}</body>`,
},
});
}
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
});
test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to open a new tab>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Body two
\`\`\``);
});
test('select tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 2,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to select tab 2>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Body one
\`\`\``);
});
test('close tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
await createTab(client, 'Tab two', 'Body two');
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 3,
},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// <internal code to close tab 3>
\`\`\`
### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Current tab
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
const browserContext = await cdpServer.start();
const pages = browserContext.pages();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(pages.length).toBe(1);
expect(await pages[0].title()).toBe('Title');
});

View File

@@ -16,7 +16,6 @@
*/
import fs from 'fs';
import url from 'node:url';
import http from 'http';
import https from 'https';
import path from 'path';
@@ -25,9 +24,6 @@ import debug from 'debug';
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
export class TestServer {
private _server: http.Server;
readonly debugServer: any;

View File

@@ -1,35 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
});

View File

@@ -1,85 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_wait_for(text)', async ({ client, server }) => {
server.setContent('/', `
<script>
function update() {
setTimeout(() => {
document.querySelector('div').textContent = 'Text to appear';
}, 1000);
}
</script>
<body>
<button onclick="update()">Click me</button>
<div>Text to disappear</div>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { text: 'Text to appear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
});
test('browser_wait_for(textGone)', async ({ client, server }) => {
server.setContent('/', `
<script>
function update() {
setTimeout(() => {
document.querySelector('div').textContent = 'Text to appear';
}, 1000);
}
</script>
<body>
<button onclick="update()">Click me</button>
<div>Text to disappear</div>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { textGone: 'Text to disappear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
});

View File

@@ -1,38 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'firefox');
test.skip(mcpBrowser === 'webkit');
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<body></body>
<script>
document.body.textContent = 'webdriver: ' + navigator.webdriver;
</script>
`);
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('webdriver: false');
});

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.js"],
}

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