178 Commits

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

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

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

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

----

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

---------

Co-authored-by: Yury Semikhatsky <yurys@chromium.org>
2025-06-13 13:15:17 -07:00
Dmitry Gozman
0df6d7a441 chore: roll playwright to Jun 10th, v1.53 (#542)
Co-authored-by: Simon Knott <simonknott@microsoft.com>
2025-06-11 15:53:14 +01:00
Dmitry Gozman
4ea7041ba9 chore: mark v0.0.29 (#541) 2025-06-11 12:00:52 +01:00
Dan O'Brien
7dae68de78 docs: add instructions for MCP server in Qodo Gen (#530) 2025-06-08 10:38:24 -07:00
Peter Goldstein
60495ed9b0 docs: include Cursor One-Click in README.md (#531) 2025-06-08 10:37:48 -07:00
cranemont
0aaef661b1 docs(readme): fix connection method call in programmatic usage example (#532) 2025-06-08 10:36:27 -07:00
Max Schmitt
abbe7858a2 test: add PWMCP_DEBUG env switch (#523) 2025-06-05 10:40:03 -07:00
Simon Knott
767af21e02 chore: fix Connection type (#517)
The external `Connection` type regressed in
https://github.com/microsoft/playwright-mcp/pull/490/files#diff-a6be0583428e46844273df76939f02077073da3075716fc57d291a5f2463eaf5,
where the `connect()` function was removed but not from the types. I've
changed the code so we import from there, similar to how we do it for
`config.d.ts`, so this shouldn't happen again.
2025-06-05 08:47:04 +02:00
Pavel Feldman
27c498e0e7 chore: rename browser agent to server (#521) 2025-06-04 16:43:11 -07:00
Pavel Feldman
0fb9646c4d chore: experimental agent mode (#516) 2025-06-04 09:14:50 -07:00
Simon Knott
9728527900 chore: typo (#513) 2025-06-03 11:10:47 -07:00
Pavel Feldman
675b083db3 chore: mark v0.0.28 (#503) 2025-06-01 14:30:42 -07:00
Pavel Feldman
0b74cdaaf8 chore: sort out signal handling (#506) 2025-06-01 14:11:42 -07:00
Pavel Feldman
f31ef598bc test: verify the log in close/navigate test (#505) 2025-06-01 12:49:30 -07:00
Pavel Feldman
656779531c chore: respect server settings from config (#502) 2025-05-30 18:17:51 -07:00
Pavel Feldman
eec177d3ac chore: reuse browser in server mode (#495) 2025-05-30 15:15:37 -07:00
Pavel Feldman
54ed7c3200 chore: refactor server, prepare for browser reuse (#490) 2025-05-28 16:55:47 -07:00
nabepa
3cd74a824a docs: fixed typo in README.md (#487) 2025-05-27 20:33:36 -07:00
Pavel Feldman
177b008328 chore: mark v0.0.27 (#470) 2025-05-27 16:47:54 -07:00
Pavel Feldman
9429463951 chore: roll Playwright to 5/27 (#485) 2025-05-27 16:47:22 -07:00
Simon Knott
45f493da6c chore: make library test run under older Node versions (#479) 2025-05-27 13:19:25 -07:00
Pavel Feldman
9e5ffd2ccf fix(cursor): allow enforcing images for cursor --image-responses=allow (#478)
Fixes https://github.com/microsoft/playwright-mcp/issues/449
2025-05-27 10:25:09 +02:00
Simon Knott
1051ea810a fix: import from cjs (#476)
Closes https://github.com/microsoft/playwright-mcp/issues/456
2025-05-26 14:18:03 -07:00
Pavel Feldman
f20ae22ec6 chore: roll Playwright, remove localOutputDir (#471) 2025-05-24 11:44:57 -07:00
Simon Knott
13cd1b4bd9 fix: respect browserName in config (#461)
Resolves https://github.com/microsoft/playwright-mcp/issues/458
2025-05-23 15:13:34 -07:00
Pavel Feldman
c318f13895 chore: mark v0.0.26 (#441) 2025-05-17 08:20:37 -07:00
Pavel Feldman
1318e39fac chore: fix operation over cdp (#440)
Ref https://github.com/microsoft/playwright-mcp/issues/439
2025-05-17 08:20:22 -07:00
Pavel Feldman
c2b7fb29de chore: start trace server (#427) 2025-05-14 20:15:09 -07:00
Pavel Feldman
aa6ac51f92 feat(trace): allow saving trajectory as trace (#426) 2025-05-14 18:08:44 -07:00
Pavel Feldman
fea50e6840 chore: introduce resolved config (#425) 2025-05-14 16:01:08 -07:00
Pavel Feldman
746c9fc124 chore: mark v0.0.25 (#414) 2025-05-13 16:24:04 -07:00
Pavel Feldman
ee33097abe chore: normalize --no- options (#413) 2025-05-13 16:17:45 -07:00
Pavel Feldman
ab20175826 chore: generate readme options (#411) 2025-05-13 15:52:30 -07:00
Pavel Feldman
c506027aec chore: run w/ sandbox by default (#412) 2025-05-13 15:30:02 -07:00
Pavel Feldman
7be0c8872e feat(args): allow configuring proxy, UA, viewport, https errors (#410) 2025-05-13 14:40:03 -07:00
Pavel Feldman
ce72367208 feat(storage): allow passing storage state for isolated contexts (#409)
Fixes https://github.com/microsoft/playwright-mcp/issues/403
Ref https://github.com/microsoft/playwright-mcp/issues/367
2025-05-13 13:14:04 -07:00
Pavel Feldman
949f956378 feat(ephemeral): allow for non-persistent context operation (#405)
Ref: https://github.com/microsoft/playwright-mcp/issues/367
Ref: https://github.com/microsoft/playwright-mcp/issues/393
2025-05-12 18:18:53 -07:00
Pavel Feldman
a1eee8351e chore: collapse readme (#404) 2025-05-12 16:42:47 -07:00
Pavel Feldman
fea3f26e85 chore: mark v0.0.24 (#401) 2025-05-12 09:40:59 -07:00
Pavel Feldman
dd5b41f1d8 chore: account for undefined arguments (#400) 2025-05-12 09:35:33 -07:00
Pavel Feldman
05dc5d915b chore: mark v0.0.23 (#399) 2025-05-12 09:13:48 -07:00
Taiga Mikami
65a229c79f Fix import in README from createServer to createConnection (#396)
Probably, `createServer` is not from `@playwright/mcp`.
2025-05-12 08:46:21 -07:00
Max Schmitt
84664d4b09 test: unflake 'should throw connection error and allow re-connecting' (#398)
Fixes
https://github.com/microsoft/playwright-mcp/actions/runs/14940263450/job/41976152764#step:8:315
2025-05-12 09:45:09 +02:00
Pavel Feldman
445170a76b chore: roll playwright 5/9 (#394) 2025-05-09 18:01:17 -07:00
Pavel Feldman
c28b480b51 feat(wait): allow waiting for given text (#390)
Fixes https://github.com/microsoft/playwright-mcp/issues/389
2025-05-09 15:35:28 -07:00
Max Schmitt
65716b60dd fix: createConnection() via public API (#384)
Fixes https://github.com/microsoft/playwright-mcp/issues/382
2025-05-09 21:50:38 +02:00
Max Schmitt
75f74a54bc docs: reference to new Docker image (#380) 2025-05-09 21:01:10 +02:00
Max Schmitt
ef41c626ef chore: unset skipLibCheck in tsconfig.json (#386)
Follow-up for
https://github.com/microsoft/playwright-mcp/pull/385#discussion_r2081541865.

> `skipLibCheck`: Skip type checking all .d.ts files.
2025-05-09 14:35:09 +02:00
Max Schmitt
95ca08fdb7 fix: use of wrong launchOptions type in public API (#385) 2025-05-09 14:16:04 +02:00
Max Schmitt
053c2f3d32 test: fix SSE MCP SDK imports (#383) 2025-05-09 14:08:19 +02:00
Pavel Feldman
57b3c14276 chore: only reset network log upon explicit navigation (#377)
Fixes https://github.com/microsoft/playwright-mcp/issues/376
2025-05-08 17:02:09 -07:00
おがどら
85c85bd2fb chore: support custom filename in screenshot function (#349) 2025-05-08 11:04:18 -07:00
Max Schmitt
09ba7989c3 test: run tests on MCP server inside Docker (#361)
https://github.com/microsoft/playwright-mcp/issues/346
2025-05-07 18:04:20 +02:00
Max Schmitt
a115c31953 chore: rename console to consoleMessages (#372)
Motivation: `console` is a global object in Node.js and having a method
like that confuses intellisense.
2025-05-07 16:40:08 +02:00
Max Schmitt
b5be37e5e7 chore: mark v0.0.22 (#370) 2025-05-07 12:49:11 +02:00
Simon Knott
c2255246a3 fix: don't error on navigating to a download link (#328) 2025-05-07 12:47:45 +02:00
Max Schmitt
950d0d1d34 devops: fix Docker publishing (#369) 2025-05-07 11:46:33 +02:00
Max Schmitt
cdeba454b5 chore: mark v0.0.21 (#364) 2025-05-07 11:30:11 +02:00
Max Schmitt
91ae93c167 chore: change import assert to readFile (#368) 2025-05-07 11:30:01 +02:00
Max Schmitt
35e6c49d7c devops: publish Docker image to :latest as well (#365)
We don't do that for normal Playwright because we expect the user to
mount/add/copy their own Playwright folder and there the version has to
match. In this case publishing to `:latest` seems fine since its a
isolated product.
2025-05-07 11:14:05 +02:00
Pavel Feldman
e95b5b1dd6 chore: get rid of connection factory (#362)
Drive-by User-Agent sniffing and disabling of image type in Cursor.
2025-05-06 14:27:28 -07:00
Max Schmitt
23a2e5fee7 devops: add Docker publishing (#356) 2025-05-06 23:14:41 +02:00
Pavel Feldman
d01aa19ffa chore: annotate tools (#351)
Fixes https://github.com/microsoft/playwright-mcp/issues/215
2025-05-05 17:38:22 -07:00
kanchi
8cd7d5a753 chore(docker): optimize Dockerfile by excluding unnecessary files and using non-root user (#273) 2025-05-05 14:38:02 -07:00
Ross Wollman
42faa3ccf8 feat: add --(allowed|blocked)-origins (#319)
Useful to limit the agent when using the playwright-mcp server with an
agent in auto-invocation mode.

Not intended to be a security feature.
2025-05-05 11:28:14 -07:00
Pavel Feldman
4694d60fc5 fix(config): allow specifying user data dir in config (#342)
Fixes https://github.com/microsoft/playwright-mcp/issues/340
2025-05-05 08:23:24 -07:00
Max Schmitt
7dc689eee7 fix: installation tool on Windows (#345) 2025-05-04 06:56:59 -07:00
おがどら
5df011ad4b feat(cli): set outputDir via cli options (#338) 2025-05-03 20:11:17 -07:00
Pavel Feldman
200cf737bb chore: use import.meta.resolve to lookup Playwright (#337) 2025-05-03 14:38:58 -07:00
Pavel Feldman
d8a59e0d0d chore: mark v0.0.20 (#336) 2025-05-02 21:31:06 -07:00
Pavel Feldman
21533d9000 chore: installation test added (#335) 2025-05-02 21:30:55 -07:00
Ryosuke Iwanaga
49979641fa fix: require is not defined (#334)
Since it's moved to ESM, `require` isn't defined.
This hotfix is just recreating `require` to workaround this issue.
2025-05-02 21:19:54 -07:00
Pavel Feldman
43aa4001b5 chore: mark v0.0.19 (#332) 2025-05-02 18:38:20 -07:00
Pavel Feldman
7e087af6a6 chore: slightly adjust gen test prompt (#333) 2025-05-02 18:38:06 -07:00
Pavel Feldman
927a1280f1 chore: allow generating tests for script (#331) 2025-05-02 17:41:58 -07:00
Pavel Feldman
292e75d464 chore: roll Playwright to remove empty generic nodes (#330) 2025-05-02 16:10:48 -07:00
Simon Knott
2c9376e50f chore: don't sanitize file extension away (#327) 2025-05-02 10:58:48 -07:00
Max Schmitt
062cdd0704 fix: sticky launch errors (#324)
This fixes an issue that there were sticky launch errors. When the
[following code
path](a15f0f301b/src/context.ts (L307-L339))
was throwing, the Error was stored in the Promise and not cleared
afterwards, this meant:

- If a browser was not there and the user tried to install it via
`browser_install` it was never working since the error was sticky.
- If other errors like CDP is not available yet etc. error appear a
re-connect would not work - the MCP server would require a restart.

Test plan: Since we don't have any `browser_install` tests I added a CDP
test for now to cover this bug.
2025-05-02 15:32:37 +02:00
Max Schmitt
a713300c5b test: use TestOptions type in config (#326) 2025-05-02 13:50:03 +02:00
Simon Knott
a15f0f301b chore: save downloads to outputDir (#310) 2025-05-02 10:57:31 +02:00
Pavel Feldman
23ce973377 lint: ban console output (#317) 2025-04-30 14:15:32 -07:00
Max Schmitt
685dea9e19 chore: migrate to ESM (#303)
- [Why do I need `.js`
extension?](https://stackoverflow.com/a/77150985/6512681)
- [Why setting `rootDir` in the
`tsconfig.json`?](https://stackoverflow.com/a/58941798/6512681)
- [How to ensure that we add the `.js` extension via
ESLint](https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/extensions.md#importextensions)

Fixes https://github.com/microsoft/playwright-mcp/issues/302
2025-04-30 23:06:56 +02:00
Pavel Feldman
878be97668 chore: mark v0.0.18 (#315) 2025-04-30 13:07:55 -07:00
Pavel Feldman
6d6b1a384b chore: fix merge config (#311) 2025-04-30 08:41:19 -07:00
Pavel Feldman
fd22def4c5 chore: fix test harness, close the client (#312) 2025-04-30 08:07:54 -07:00
Simon Knott
1b60870f50 chore: bump to 0.0.17 (#306) 2025-04-30 12:30:03 +02:00
Simon Knott
1c760b3826 fix: default to headful (#305)
See https://github.com/microsoft/playwright-mcp/issues/304

Regressed in
69703cc882.
2025-04-30 12:23:30 +02:00
Pavel Feldman
9efaea6a1c chore: mark v0.0.16 (#298) 2025-04-29 19:51:57 -07:00
Pavel Feldman
3f72fe53ec chore: add support for device (#300)
Fixes https://github.com/microsoft/playwright-mcp/issues/294
2025-04-29 19:51:00 -07:00
Pavel Feldman
40d125f0bb docs: document configuration file (#299) 2025-04-29 15:29:56 -07:00
Pavel Feldman
21d2f80fef chore: store channel profiles separately (#297) 2025-04-29 13:34:56 -07:00
Simon Knott
6efdc90078 fix: show custom error for modal state (#240)
Calling a tool that resolves modal state, when there's no such modal
state visible, currently shows this misleading message:

```md
Tool "browser_file_upload" does not handle the modal state.
### Modal state
```

Instead, we should show the error message from the tool implementation.
2025-04-29 18:48:52 +02:00
zwmmm
ad4147da54 docs: Fix the default path to User data directory (#290)
Fix the default path to User data directory
2025-04-29 08:53:30 -07:00
Pavel Feldman
69703cc882 chore: follow up to exposing playwright config options (#289) 2025-04-29 08:53:03 -07:00
Max Schmitt
4147e21a3a chore: fix update-readme TS linting (#296) 2025-04-29 16:12:17 +02:00
Pavel Feldman
80c9b93b72 chore: allow configuring raw Playwright options (#287)
Fixes: https://github.com/microsoft/playwright-mcp/issues/272
2025-04-28 20:17:16 -07:00
Pavel Feldman
12e72a96c4 chore: allow configuring screenshot tool (#286)
Fixes: https://github.com/microsoft/playwright-mcp/issues/277
2025-04-28 17:21:23 -07:00
Pavel Feldman
697a69a8c2 chore: allow specifying output dir (#285)
Ref: https://github.com/microsoft/playwright-mcp/issues/279
2025-04-28 16:35:33 -07:00
Pavel Feldman
6e76d5e550 chore: split context.ts into files (#284) 2025-04-28 16:14:16 -07:00
Pavel Feldman
26779ceb20 chore: allow passing config file (#281) 2025-04-28 15:04:59 -07:00
Pavel Feldman
23704ace1f chore: update docs on lint (#283) 2025-04-28 14:56:00 -07:00
Pavel Feldman
b02370df2f chore: roll playwright to latest (#269) 2025-04-28 13:44:24 -07:00
Simon Knott
bf7dbabca4 feat: support streamable http transport (#243)
Adds support for the new StreamableHttp transport. I'm not aware of any
clients that implement it, but somebody's gotta make the start! Once
some clients support it, we can also advertise it in the README.
2025-04-28 11:11:31 +02:00
Zheng Xi Zhou
7256ee3701 docs(readme): Fix syntax error and improve formatting (#263)
The commit fixes a syntax error in the `npx` command by removing
an extra backtick. It also improves the formatting by adding line
breaks before code blocks to enhance readability.
2025-04-24 10:30:35 +02:00
Zheng Xi Zhou
0ed0bcd914 feat(server): add host option to SSE server configuration (#261) 2025-04-23 23:04:00 -07:00
Zheng Xi Zhou
4d95761f66 chore(gitignore): Add .idea and .DS_Store to .gitignore (#262) 2025-04-23 22:05:06 -07:00
Max Schmitt
b9dc323734 chore: enable @typescript-eslint/no-floating-promises rule (#260) 2025-04-23 16:03:30 +02:00
Pavel Feldman
586492a3f0 chore: mark v0.0.15 (#250) 2025-04-22 16:17:36 -07:00
Pavel Feldman
f7e9bae571 chore: roll playwright to 1745357020000 (#249) 2025-04-22 16:04:50 -07:00
Pavel Feldman
1bc3c761de feat(network): implement listing network requests (#247)
Fixes: https://github.com/microsoft/playwright-mcp/issues/242
2025-04-22 16:04:25 -07:00
Simon Knott
c80f7cf222 chore: infer tool params (#241)
Moves the `schema.parse` call to the calling side of the handler, so we
don't have to duplicate it everywhere.
2025-04-22 13:24:38 +02:00
Pavel Feldman
9578a5b2af chore: mark v0.0.14 (#237) 2025-04-21 17:52:35 -07:00
Pavel Feldman
cd5aa344f1 docs: push docker doc down the readme (#236) 2025-04-21 17:31:18 -07:00
Cody Rigney
dc955c73a3 Add Docker support (#220) 2025-04-21 17:26:50 -07:00
Rui Figueira
d4f8f87b03 docs: fix "programmatic usage with custom transports" code snippet (#235)
Fixes: #230
2025-04-21 15:09:58 -07:00
Max Schmitt
0c3792d231 chore: auto update tools in README (#219)
Motivation: Keeping the readme up to date is a manual effort - this
keeps it automatically up to date and prevents things like
https://github.com/microsoft/playwright-mcp/pull/214 and other
consistency errors in the future.
2025-04-21 20:22:57 +02:00
Pavel Feldman
7695717546 docs: provide missing docs (#214) 2025-04-17 14:49:22 -07:00
Pavel Feldman
6a070a0dd8 chore: restore page-side timeout (#213) 2025-04-17 14:25:27 -07:00
Pavel Feldman
6481100bdf feat(dialog): handle dialogs (#212) 2025-04-17 14:03:13 -07:00
Pavel Feldman
4b261286bf chore: test list tabs (#208) 2025-04-17 09:58:02 +02:00
Pavel Feldman
7e4a964b0a chore: flatten tool calling, prep for timeout handling (#205) 2025-04-16 19:36:48 -07:00
Pavel Feldman
cea347d067 chore: introduce modal states (#204) 2025-04-16 15:21:45 -07:00
Pavel Feldman
6054290d9a chore: follow up to the element screenshot change (#199) 2025-04-16 12:53:27 -07:00
Andrei-Daniel Barzu
6d4adfe5c6 feat: add element screenshot action for snapshots (#182) 2025-04-16 10:28:44 -07:00
Simon Knott
e7c7709b33 chore: include "playwright" keyword, add examples (#196) 2025-04-16 08:18:40 -07:00
Pavel Feldman
5c2e11017d chore: convert console resource to tool (#193) 2025-04-15 18:01:59 -07:00
Pavel Feldman
e4331313f9 chore: update exported types (#192)
Fixes https://github.com/microsoft/playwright-mcp/issues/186
2025-04-15 16:39:52 -07:00
Pavel Feldman
bc48600a49 chore: mark v0.0.13 (#190) 2025-04-15 15:27:29 -07:00
Yury Semikhatsky
0d6bb2f547 devops: add bots for other browsers/platforms (#174) 2025-04-15 13:16:56 -07:00
Pavel Feldman
795a9d578a chore: generalize status & action as code (#188) 2025-04-15 12:54:45 -07:00
Simon Knott
4a19e18999 feat: respond with action and generated locator (#181)
Closes https://github.com/microsoft/playwright-mcp/issues/163
2025-04-15 10:55:20 -07:00
Simon Knott
4d59e06184 test: fix flaky test (#180)
Closes https://github.com/microsoft/playwright-mcp/issues/177

`ResizeObserver` isn't instant!
2025-04-15 16:10:49 +02:00
Pavel Feldman
6891a525b3 chore: add npx install step to the publish workflow (#178) 2025-04-14 20:09:38 -07:00
Yury Semikhatsky
0f7fd1362f chore: mark 0.0.12 (#176) 2025-04-14 19:39:10 -07:00
Yury Semikhatsky
de08c24b96 fix: consider DISPLAY only on linux (#175) 2025-04-14 19:07:39 -07:00
99 changed files with 8224 additions and 2118 deletions

View File

@@ -7,32 +7,79 @@ on:
branches: [ main ]
jobs:
build-and-test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- run: npm run build
- name: Run ESLint
run: npm run lint
- name: Ensure no changes
run: git diff --exit-code
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Run linting
run: npm run lint
- 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: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run tests
run: npm test
test_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
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
uses: docker/build-push-action@v6
with:
tags: playwright-mcp-dev:latest
cache-from: type=gha
cache-to: type=gha,mode=max
load: true
- name: Run tests
shell: bash
run: |
# Used for the Docker tests to share the test-results folder with the container.
umask 0000
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1

View File

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

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
id-token: write # Needed for npm provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -15,9 +15,56 @@ jobs:
node-version: 18
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 test
- run: npm run ctest
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-docker:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Needed for OIDC login to Azure
environment: allow-publishing-docker-to-acr
steps:
- uses: actions/checkout@v4
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx # Needed for multi-platform builds
uses: docker/setup-buildx-action@v3
- name: Azure Login via OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
- 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: .
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
platforms: linux/amd64,linux/arm64
push: true
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

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
lib/
node_modules/
test-results/
playwright-report/
.vscode/mcp.json
.idea
.DS_Store

View File

@@ -4,3 +4,4 @@ LICENSE
!lib/**/*.js
!cli.js
!index.*
!config.d.ts

69
Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# ------------------------------
# Base
# ------------------------------
# Base stage: Contains only the minimal dependencies required for runtime
# (node_modules and Playwright system dependencies)
FROM node:22-bookworm-slim AS base
ARG PLAYWRIGHT_BROWSERS_PATH
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
# Set the working directory
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
npm ci --omit=dev && \
# Install system dependencies for playwright
npx -y playwright-core install-deps chromium
# ------------------------------
# Builder
# ------------------------------
FROM base AS builder
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
npm ci
# Copy the rest of the app
COPY *.json *.js *.ts .
COPY src src/
# Build the app
RUN npm run build
# ------------------------------
# Browser
# ------------------------------
# Cache optimization:
# - Browser is downloaded only when node_modules or Playwright system dependencies change
# - Cache is reused when only source code changes
FROM base AS browser
RUN npx -y playwright-core install --no-shell chromium
# ------------------------------
# Runtime
# ------------------------------
FROM base
ARG PLAYWRIGHT_BROWSERS_PATH
ARG USERNAME=node
ENV NODE_ENV=production
# Set the correct ownership for the runtime user on production `node_modules`
RUN chown -R ${USERNAME}:${USERNAME} node_modules
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"]

701
README.md
View File

@@ -4,18 +4,24 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Key Features
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
- **LLM-friendly**: No vision models needed, operates purely on structured data.
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
- **LLM-friendly**. No vision models needed, operates purely on structured data.
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
### Use Cases
### Requirements
- Node.js 18 or newer
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
- Web navigation and form-filling
- Data extraction from structured content
- Automated testing driven by LLMs
- General-purpose browser interaction for agents
<!--
// Generate using:
node utils/generate-links.js
-->
### Example config
### Getting started
First, install the Playwright MCP server with your client.
**Standard config** works in most of the tools:
```js
{
@@ -30,68 +36,173 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
}
```
[<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)
#### Installation in VS Code
Install the Playwright MCP server in VS Code using one of these buttons:
<details>
<summary>Claude Code</summary>
<!--
// Generate using?:
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
-->
Use the Claude Code CLI to add the Playwright MCP server:
[<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-y%2522%252C%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-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
</details>
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
<details>
<summary>Claude Desktop</summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
</details>
<details>
<summary>Cursor</summary>
#### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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`.
</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>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
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
```
```bash
# For VS Code Insiders
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
```
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
</details>
### CLI Options
<details>
<summary>Windsurf</summary>
The Playwright MCP server supports the following command-line options:
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
- `chrome`, `firefox`, `webkit`, `msedge`
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--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
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
- `--port <port>`: Port to listen on for SSE transport
- `--user-data-dir <path>`: Path to the user data directory
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
</details>
### User data directory
### Configuration
Playwright MCP will launch the browser with the new profile, located at
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
<!--- Options generated by update-readme.js -->
```
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
> 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.
--caps <caps> comma-separated list of additional capabilities
to enable, possible values: vision, pdf.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--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" 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-trace Whether to save the Playwright Trace of the
session into the output directory.
--save-session Whether to save the session log with tool calls
and snapshots 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"
```
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
<!--- End of options generated section -->
### User profile
### Running headless browser (Browser without GUI).
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
This mode is useful for background or batch operations.
**Persistent profile**
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
```bash
# Windows
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
# macOS
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
# Linux
- ~/.cache/ms-playwright/mcp-{channel}-profile
```
**Isolated**
In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
the session is closed and all the storage state for this session is lost. You can provide initial storage state
to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
state [here](https://playwright.dev/docs/auth).
```js
{
@@ -100,97 +211,338 @@ This mode is useful for background or batch operations.
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
"--isolated",
"--storage-state={path/to/storage.json}"
]
}
}
}
```
### Running headed browser on Linux w/o DISPLAY
### Configuration file
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
using the `--config` command line option:
```bash
npx @playwright/mcp@latest --config path/to/config.json
```
<details>
<summary>Configuration file schema</summary>
```typescript
{
// Browser configuration
browser?: {
// Browser type to use (chromium, firefox, or webkit)
browserName?: 'chromium' | 'firefox' | 'webkit';
// Keep the browser profile in memory, do not save it to disk.
isolated?: boolean;
// Path to user data directory for browser profile persistence
userDataDir?: string;
// Browser launch options (see Playwright docs)
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
launchOptions?: {
channel?: string; // Browser channel (e.g. 'chrome')
headless?: boolean; // Run in headless mode
executablePath?: string; // Path to browser executable
// ... other Playwright launch options
};
// Browser context options
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
contextOptions?: {
viewport?: { width: number, height: number };
// ... other Playwright context options
};
// CDP endpoint for connecting to existing browser
cdpEndpoint?: string;
// Remote Playwright server endpoint
remoteEndpoint?: string;
},
// Server configuration
server?: {
port?: number; // Port to listen on
host?: string; // Host to bind to (default: localhost)
},
// List of additional capabilities
capabilities?: Array<
'tabs' | // Tab management
'install' | // Browser installation
'pdf' | // PDF generation
'vision' | // Coordinate-based interactions
>;
// Directory for output files
outputDir?: string;
// Network configuration
network?: {
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
allowedOrigins?: string[];
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
blockedOrigins?: string[];
};
/**
* Whether to send image responses to the client. Can be "allow" or "omit".
* Defaults to "allow".
*/
imageResponses?: 'allow' | 'omit';
}
```
</details>
### 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"
}
}
}
```
### Tool Modes
<details>
<summary><b>Docker</b></summary>
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:
**NOTE:** The Docker implementation only supports headless chromium at the moment.
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--vision"
]
"command": "docker",
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
}
}
}
```
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.
You can build the Docker image yourself.
### Programmatic usage with custom transports
```
docker build -t mcr.microsoft.com/playwright/mcp .
```
</details>
<details>
<summary><b>Programmatic usage</b></summary>
```js
import { createServer } from '@playwright/mcp';
import http from 'http';
import { createConnection } from '@playwright/mcp';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
http.createServer(async (req, res) => {
// ...
const server = createServer({
launchOptions: { headless: true }
});
transport = new SSEServerTransport("/messages", res);
server.connect(transport);
```
// Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.sever.connect(transport);
### Snapshot-based Interactions
// ...
});
```
</details>
### Tools
<!--- Tools generated by update-readme.js -->
<details>
<summary><b>Core automation</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_click**
- Title: Click
- Description: Perform click on a web 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
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
- `button` (string, optional): Button to click, defaults to left
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_drag**
- Title: Drag mouse
- Description: Perform drag and drop between two elements
- Parameters:
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_evaluate**
- Title: Evaluate JavaScript
- Description: Evaluate JavaScript expression on page or element
- Parameters:
- `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_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_handle_dialog**
- Title: Handle a dialog
- Description: Handle a dialog
- Parameters:
- `accept` (boolean): Whether to accept the dialog.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **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**
- **browser_drag**
- Description: Perform drag and drop between two elements
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate**
- Title: Navigate to a URL
- Description: Navigate to a URL
- Parameters:
- `startElement` (string): Human-readable source element description used to obtain permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- `endElement` (string): Human-readable target element description used to obtain permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
- `url` (string): The URL to navigate to
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_back**
- Title: Go back
- Description: Go back to the previous page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Title: Go forward
- Description: Go forward to the next page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_network_requests**
- Title: List network requests
- Description: Returns all network requests since loading the page
- Parameters: None
- Read-only: **true**
<!-- 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_resize**
- Title: Resize browser window
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_take_screenshot**
- Title: Take a screenshot
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `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.
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Title: Type text
- Description: Type text into editable element
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
@@ -198,128 +550,127 @@ server.connect(transport);
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
- Read-only: **false**
- **browser_select_option**
- Description: Select an option in a dropdown
<!-- 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:
- `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.
- `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**
- **browser_snapshot**
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
</details>
<details>
<summary><b>Tab management</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Title: Close a tab
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Title: List tabs
- Description: List browser tabs
- Parameters: None
- Read-only: **true**
- **browser_take_screenshot**
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
### Vision-based Interactions
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_move_mouse**
- Description: Move mouse to a given position
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
</details>
<details>
<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**
- **browser_screen_capture**
- Description: Take a screenshot of the current page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_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
- **browser_screen_drag**
- Description: Drag left mouse button
- **browser_mouse_drag_xy**
- Title: Drag mouse
- Description: Drag left mouse button to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
- Read-only: **false**
- **browser_screen_type**
- Description: Type text
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_move_xy**
- Title: Move mouse
- Description: Move mouse to a given position
- Parameters:
- `text` (string): Text to type
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `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**
- **browser_press_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`
</details>
### Tab Management
<details>
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
- **browser_tab_list**
- Description: List browser tabs
- Parameters: None
- **browser_tab_new**
- 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.
- **browser_tab_select**
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- **browser_tab_close**
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
### Navigation
- **browser_navigate**
- Description: Navigate to a URL
- Parameters:
- `url` (string): The URL to navigate to
- **browser_navigate_back**
- Description: Go back to the previous page
- Parameters: None
- **browser_navigate_forward**
- Description: Go forward to the next page
- Parameters: None
### Keyboard
- **browser_press_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`
### Files and Media
- **browser_file_upload**
- Description: Choose one or multiple files to upload
- Parameters:
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters: None
### Utilities
- **browser_wait**
- Description: Wait for a specified time in seconds
- Parameters:
- `time` (number): The time to wait in seconds (capped at 10 seconds)
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
- **browser_close**
- Description: Close the page
- Parameters: None
</details>
- **browser_install**
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
<!--- End of tools generated section -->

2
cli.js
View File

@@ -15,4 +15,4 @@
* limitations under the License.
*/
require('./lib/program');
import './lib/program.js';

119
config.d.ts vendored Normal file
View File

@@ -0,0 +1,119 @@
/**
* 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';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
export type Config = {
/**
* The browser to use.
*/
browser?: {
/**
* The type of browser to use.
*/
browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* Keep the browser profile in memory, do not save it to disk.
*/
isolated?: boolean;
/**
* Path to a user data directory for browser profile persistence.
* Temporary directory is created by default.
*/
userDataDir?: string;
/**
* Launch options passed to
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
*
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
*/
launchOptions?: playwright.LaunchOptions;
/**
* Context options for the browser context.
*
* This is useful for settings options like `viewport`.
*/
contextOptions?: playwright.BrowserContextOptions;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
*/
cdpEndpoint?: string;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
},
server?: {
/**
* The port to listen on for SSE or MCP transport.
*/
port?: number;
/**
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
*/
host?: string;
},
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'pdf': PDF generation and manipulation.
* - 'vision': Coordinate-based interactions.
*/
capabilities?: ToolCapability[];
/**
* Whether to save the Playwright trace of the session into the output directory.
*/
saveTrace?: boolean;
/**
* Whether to save the session log with tool calls and snapshots into the output directory.
*/
saveSession?: boolean;
/**
* The directory to save output files.
*/
outputDir?: string;
network?: {
/**
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
allowedOrigins?: string[];
/**
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
blockedOrigins?: string[];
};
/**
* 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';
};

View File

@@ -33,6 +33,8 @@ const plugins = {
};
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" },
@@ -178,12 +180,16 @@ export const baseRules = {
// 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 [

10
examples/generate-test.md Normal file
View File

@@ -0,0 +1,10 @@
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.

32
extension/connect.html Normal file
View File

@@ -0,0 +1,32 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html>
<head>
<title>Playwright MCP extension</title>
</head>
<body>
<div class="header">
<h3>Playwright MCP extension</h3>
</div>
<div id="status-container"></div>
<div class="button-row">
<button id="continue-btn">Continue</button>
<button id="reject-btn">Reject</button>
</div>
<script src="lib/connect.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

40
extension/manifest.json Normal file
View File

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

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

@@ -0,0 +1,109 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = {
type: 'connectToMCPRelay';
mcpRelayUrl: string;
};
class TabShareExtension {
private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null;
constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
}
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) {
case 'connectToMCPRelay':
const tabId = sender.tab?.id;
if (!tabId) {
sendResponse({ success: false, error: 'No tab id' });
return true;
}
this._connectTab(tabId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously
}
return false;
}
private async _connectTab(tabId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to bridge 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.setConnectedTabId(tabId);
const connectionClosed = (m: string) => {
debugLog(m);
if (this._activeConnection === connection) {
this._activeConnection = undefined;
void this._setConnectedTabId(null);
}
};
socket.onclose = () => connectionClosed('WebSocket closed');
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
this._activeConnection = connection;
await this._setConnectedTabId(tabId);
debugLog(`Tab ${tabId} connected successfully`);
} catch (error: any) {
debugLog(`Failed to connect tab ${tabId}:`, error.message);
await this._setConnectedTabId(null);
throw error;
}
}
private async _setConnectedTabId(tabId: number | null): Promise<void> {
const oldTabId = this._connectedTabId;
this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '', color: null });
if (tabId)
await this._updateBadge(tabId, { text: '●', color: '#4CAF50' });
}
private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
await chrome.action.setBadgeText({ tabId, text });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
}
private async _onTabRemoved(tabId: number): Promise<void> {
if (this._connectedTabId === tabId)
this._activeConnection!.setConnectedTabId(null);
}
private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
await this._setConnectedTabId(tabId);
}
}
new TabShareExtension();

70
extension/src/connect.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
document.addEventListener('DOMContentLoaded', async () => {
const statusContainer = document.getElementById('status-container') as HTMLElement;
const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement;
const rejectBtn = document.getElementById('reject-btn') as HTMLButtonElement;
const buttonRow = document.querySelector('.button-row') as HTMLElement;
function showStatus(type: 'connected' | 'error' | 'connecting', message: string) {
const div = document.createElement('div');
div.className = `status ${type}`;
div.textContent = message;
statusContainer.replaceChildren(div);
}
const params = new URLSearchParams(window.location.search);
const mcpRelayUrl = params.get('mcpRelayUrl');
if (!mcpRelayUrl) {
buttonRow.style.display = 'none';
showStatus('error', 'Missing mcpRelayUrl parameter in URL.');
return;
}
let clientInfo = 'unknown';
try {
const client = JSON.parse(params.get('client') || '{}');
clientInfo = `${client.name}/${client.version}`;
} catch (e) {
showStatus('error', 'Failed to parse client version.');
return;
}
showStatus('connecting', `MCP client "${clientInfo}" is trying to connect. Do you want to continue?`);
rejectBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
showStatus('error', 'Connection rejected. This tab can be closed.');
});
continueBtn.addEventListener('click', async () => {
buttonRow.style.display = 'none';
try {
const response = await chrome.runtime.sendMessage({
type: 'connectToMCPRelay',
mcpRelayUrl
});
if (response?.success)
showStatus('connected', `MCP client "${clientInfo}" connected.`);
else
showStatus('error', response?.error || `MCP client "${clientInfo}" failed to connect.`);
} catch (e) {
showStatus('error', `MCP client "${clientInfo}" failed to connect: ${e}`);
}
});
});

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 _rootSessionId = '';
private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
constructor(ws: WebSocket) {
this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this);
// 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);
}
setConnectedTabId(tabId: number | null): void {
if (!tabId) {
this._debuggee = { };
this._rootSessionId = '';
return;
}
this._debuggee = { tabId };
this._rootSessionId = `pw-tab-${tabId}`;
}
close(message?: string): void {
chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener);
this._ws.close(1000, message || 'Connection closed');
}
private async _detachDebugger(): Promise<void> {
await chrome.debugger.detach(this._debuggee);
}
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
if (source.tabId !== this._debuggee.tabId)
return;
debugLog('Forwarding CDP event:', method, params);
const sessionId = source.sessionId || this._rootSessionId;
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._sendMessage({
method: 'detachedFromTab',
params: {
tabId: this._debuggee.tabId,
reason,
},
});
}
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 (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'attachToTab') {
debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
return {
sessionId: this._rootSessionId,
targetInfo: result?.targetInfo,
};
}
if (message.method === 'detachFromTab') {
debugLog('Detaching debugger from tab:', this._debuggee);
return await this._detachDebugger();
}
if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params);
const debuggerSession: chrome.debugger.DebuggerSession = { ...this._debuggee };
// Pass session id, unless it's the root session.
if (sessionId && sessionId !== this._rootSessionId)
debuggerSession.sessionId = sessionId;
// 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 {
this._ws.send(JSON.stringify(message));
}
}

15
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "ESNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true,
},
"include": [
"src",
],
}

33
index.d.ts vendored
View File

@@ -15,33 +15,14 @@
* limitations under the License.
*/
import type { LaunchOptions } from 'playwright';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config.js';
import type { BrowserContext } from 'playwright';
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
type Options = {
/**
* Path to the user data directory.
*/
userDataDir?: string;
/**
* Launch options for the browser.
*/
launchOptions?: LaunchOptions;
/**
* Use screenshots instead of snapshots. Less accurate, reliable and overall
* slower, but contains visual representation of the page.
* @default false
*/
vision?: boolean;
/**
* Capabilities to enable.
*/
capabilities?: ToolCapability[];
export type Connection = {
server: Server;
close(): Promise<void>;
};
export function createServer(options?: Options): Server;
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export {};

View File

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

446
package-lock.json generated
View File

@@ -1,18 +1,21 @@
{
"name": "@playwright/mcp",
"version": "0.0.11",
"version": "0.0.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.11",
"version": "0.0.31",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -21,9 +24,12 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
@@ -228,17 +234,20 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
"integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.16.0.tgz",
"integrity": "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^4.1.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
@@ -286,13 +295,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mnitdsjXKPyKTjQQDJ78Or1xZSGcaoDzZVD/0BWFCvygn3nyNmGmiias/Mlfvzvgz9UWBbPeZYxU/bd2Lu+OrQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0-alpha-1743163434000"
"playwright": "1.55.0-alpha-1752701791000"
},
"bin": {
"playwright": "cli.js"
@@ -354,6 +363,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/chrome": {
"version": "0.0.315",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.315.tgz",
"integrity": "sha512-Oy1dYWkr6BCmgwBtOngLByCHstQ3whltZg7/7lubgIZEYvKobDneqplgc6LKERNRBwckFviV4UU5AZZNUFrJ4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -361,6 +391,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -375,6 +429,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
@@ -385,6 +446,16 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.27.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
@@ -644,7 +715,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -834,16 +904,16 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.5.2",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
@@ -853,44 +923,6 @@
"node": ">=18"
}
},
"node_modules/body-parser/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1091,7 +1123,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1157,12 +1188,12 @@
}
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -1225,16 +1256,6 @@
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -1770,46 +1791,45 @@
}
},
"node_modules/express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.0.1",
"body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "4.3.6",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "^2.0.0",
"fresh": "2.0.0",
"http-errors": "2.0.0",
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"methods": "~1.1.2",
"mime-types": "^3.0.0",
"on-finished": "2.4.1",
"once": "1.4.0",
"parseurl": "~1.3.3",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"router": "^2.0.0",
"safe-buffer": "5.2.1",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.1.0",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "^2.0.0",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
@@ -1831,7 +1851,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -1868,7 +1887,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
@@ -1931,29 +1949,6 @@
"node": ">= 0.8"
}
},
"node_modules/finalhandler/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -2313,12 +2308,12 @@
}
},
"node_modules/iconv-lite": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
@@ -2786,7 +2781,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-yaml": {
@@ -2813,7 +2807,6 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -2930,15 +2923,6 @@
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/metric-lcs": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
@@ -2960,6 +2944,21 @@
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2970,12 +2969,12 @@
}
},
"node_modules/mime-types": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.53.0"
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
@@ -3005,9 +3004,9 @@
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/natural-compare": {
@@ -3256,7 +3255,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3292,21 +3290,21 @@
}
},
"node_modules/pkce-challenge": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
"integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-PA3TvDz7uQ+Pde0uaii5/WpU5vntRJsYFsaSPoBzywIqzYFO1ugk1ZZ0q6z4/xHq0ha1UClvsv3P77B+u1fi+w==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0-alpha-1743163434000"
"playwright-core": "1.55.0-alpha-1752701791000"
},
"bin": {
"playwright": "cli.js"
@@ -3319,9 +3317,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
"version": "1.55.0-alpha-1752701791000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1752701791000.tgz",
"integrity": "sha512-mQhzhjJMiqnGNnYZv7M4yk1OcNTt1E72jrTLO7EqZuoeat4+qpcU0/mbK+RcTEass5a9YheoVFh6OIhruFMGVg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -3367,19 +3365,18 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -3433,18 +3430,6 @@
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3532,11 +3517,13 @@
}
},
"node_modules/router": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
@@ -3664,19 +3651,18 @@
}
},
"node_modules/send": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"destroy": "^1.2.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^0.5.2",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^2.1.35",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
@@ -3686,52 +3672,16 @@
"node": ">= 18"
}
},
"node_modules/send/node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.0.0"
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
@@ -3796,7 +3746,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -3809,7 +3758,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4066,9 +4014,9 @@
}
},
"node_modules/type-is": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -4210,21 +4158,11 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -4238,7 +4176,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -4355,16 +4292,25 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"license": "MIT",
"engines": {
"node": ">= 14"
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yocto-queue": {

View File

@@ -1,7 +1,8 @@
{
"name": "@playwright/mcp",
"version": "0.0.11",
"version": "0.0.31",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
@@ -16,10 +17,17 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "eslint .",
"build:extension": "tsc --project extension",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"watch:extension": "tsc --watch --project extension",
"test": "playwright test",
"clean": "rm -rf lib",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib extension/lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
"exports": {
@@ -30,18 +38,24 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.55.0-alpha-1752701791000",
"playwright-core": "1.55.0-alpha-1752701791000",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@playwright/test": "1.55.0-alpha-1752701791000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",

View File

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

View File

@@ -0,0 +1,229 @@
/**
* 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 * as playwright from 'playwright';
import { logUnhandledError, testDebug } from './log.js';
import type { FullConfig } from './config.js';
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
if (browserConfig.remoteEndpoint)
return new RemoteContextFactory(browserConfig);
if (browserConfig.cdpEndpoint)
return new CdpContextFactory(browserConfig);
if (browserConfig.isolated)
return new IsolatedContextFactory(browserConfig);
return new PersistentContextFactory(browserConfig);
}
export interface BrowserContextFactory {
createContext(clientInfo: { name: string, version: string }): 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(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this.name})`);
await browser.close().catch(logUnhandledError);
}
}
}
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;
}
}
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);
});
}

320
src/config.ts Normal file
View File

@@ -0,0 +1,320 @@
/**
* 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;
caps?: string[];
cdpEndpoint?: string;
config?: string;
device?: string;
executablePath?: string;
headless?: boolean;
host?: string;
ignoreHttpsErrors?: boolean;
isolated?: boolean;
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveTrace?: boolean;
saveSession?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
};
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 envOverrides = configFromEnv();
const cliOverrides = configFromCLIOptions(cliOptions);
let result = defaultConfig;
result = mergeConfig(result, configInFile);
result = mergeConfig(result, envOverrides);
result = mergeConfig(result, cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
export function configFromCLIOptions(cliOptions: CLIOptions): 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 === false)
launchOptions.chromiumSandbox = false;
if (cliOptions.proxyServer) {
launchOptions.proxy = {
server: cliOptions.proxyServer
};
if (cliOptions.proxyBypass)
launchOptions.proxy.bypass = cliOptions.proxyBypass;
}
if (cliOptions.device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with cdpEndpoint.');
// Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
if (cliOptions.storageState)
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: {
browserName,
isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir,
launchOptions,
contextOptions,
cdpEndpoint: cliOptions.cdpEndpoint,
},
server: {
port: cliOptions.port,
host: cliOptions.host,
},
capabilities: cliOptions.caps as ToolCapability[],
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveTrace: cliOptions.saveTrace,
saveSession: cliOptions.saveSession,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
};
return result;
}
function configFromEnv(): Config {
const options: CLIOptions = {};
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
options.saveSession = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_SESSION);
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
return configFromCLIOptions(options);
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
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;
}
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(';').map(v => v.trim());
}
export function commaSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(',').map(v => v.trim());
}
function envToNumber(value: string | undefined): number | undefined {
if (!value)
return undefined;
return +value;
}
function envToBoolean(value: string | undefined): boolean | undefined {
if (value === 'true' || value === '1')
return true;
if (value === 'false' || value === '0')
return false;
return undefined;
}
function envToString(value: string | undefined): string | undefined {
return value ? value.trim() : undefined;
}

96
src/connection.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context.js';
import { allTools } from './tools.js';
import { packageJSON } from './package.js';
import { FullConfig } from './config.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const tools = allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
const context = new Context(tools, config, browserContextFactory);
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: {
tools: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema),
annotations: {
title: tool.schema.title,
readOnlyHint: tool.schema.type === 'readOnly',
destructiveHint: tool.schema.type === 'destructive',
openWorldHint: true,
},
})) as McpTool[],
};
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const errorResult = (...messages: string[]) => ({
content: [{ type: 'text', text: messages.join('\n') }],
isError: true,
});
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool)
return errorResult(`Tool "${request.params.name}" not found`);
const modalStates = context.modalStates().map(state => state.type);
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
if (!tool.clearsModalState && modalStates.length)
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
try {
return await context.run(tool, request.params.arguments);
} catch (error) {
return errorResult(String(error));
}
});
return new Connection(server, context);
}
export class Connection {
readonly server: McpServer;
readonly context: Context;
constructor(server: McpServer, context: Context) {
this.server = server;
this.context = context;
this.server.oninitialized = () => {
this.context.clientVersion = this.server.getClientVersion();
};
}
async close() {
await this.server.close();
await this.context.close();
}
}

View File

@@ -14,87 +14,328 @@
* limitations under the License.
*/
import debug from 'debug';
import * as playwright from 'playwright';
import yaml from 'yaml';
import * as fs from 'fs';
import * as path from 'path';
import { waitForCompletion } from './tools/utils';
import { ToolResult } from './tools/tool';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
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>;
};
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
type RunOptions = {
captureSnapshot?: boolean;
waitForCompletion?: boolean;
status?: string;
noClearFileChooser?: boolean;
};
const testDebug = debug('pw:mcp:test');
export class Context {
readonly options: ContextOptions;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
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 }[] = [];
private _sessionFile: string | undefined;
private _sessionFileInitialized: Promise<void> | undefined;
clientVersion: { name: string; version: string; } | undefined;
constructor(options: ContextOptions) {
this.options = options;
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools;
this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
if (this.config.saveSession)
this._sessionFileInitialized = this._initializeSessionFile();
}
clientSupportsImages(): boolean {
if (this.config.imageResponses === 'omit')
return false;
return true;
}
modalStates(): ModalState[] {
return this._modalStates;
}
setModalState(modalState: ModalState, inTab: Tab) {
this._modalStates.push({ ...modalState, tab: inTab });
}
clearModalState(modalState: ModalState) {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
}
return result;
}
tabs(): Tab[] {
return this._tabs;
}
currentTab(): Tab {
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
throw new Error('No 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 { 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];
this._currentTab = this._tabs[index];
await this._currentTab.page.bringToFront();
}
async ensureTab(): Promise<Tab> {
const context = await this._ensureBrowserContext();
const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab)
await context.newPage();
await browserContext.newPage();
return this._currentTab!;
}
async listTabs(): Promise<string> {
async listTabsMarkdown(): Promise<string> {
if (!this._tabs.length)
return 'No tabs open';
const lines: string[] = ['Open tabs:'];
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.page.title();
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
lines.push(`- ${i}:${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.listTabs();
const tab = index === undefined ? this._currentTab : this._tabs[index];
await tab?.page.close();
return await this.listTabsMarkdown();
}
private async _initializeSessionFile() {
if (!this.config.saveSession)
return;
const timestamp = new Date().toISOString();
const fileName = `session${timestamp}.yml`;
this._sessionFile = await outputFile(this.config, fileName);
// Initialize empty session file
await fs.promises.writeFile(this._sessionFile, '# Session log started at ' + timestamp + '\n', 'utf8');
}
private async _logSessionEntry(toolName: string, params: Record<string, unknown>, snapshotFile?: string) {
if (!this.config.saveSession)
return;
// Ensure session file is initialized before proceeding
if (this._sessionFileInitialized)
await this._sessionFileInitialized;
// After initialization, session file should always be defined when saveSession is true
if (!this._sessionFile)
throw new Error('Session file not initialized despite saveSession being enabled');
const entry = [
`- ${toolName}:`,
' params:',
];
// Add parameters with proper YAML indentation
for (const [key, value] of Object.entries(params)) {
const yamlValue = typeof value === 'string' ? value : JSON.stringify(value);
entry.push(` ${key}: ${yamlValue}`);
}
// Add snapshot reference if provided
if (snapshotFile)
entry.push(` snapshot: ${path.basename(snapshotFile)}`);
entry.push(''); // Empty line for readability
await fs.promises.appendFile(this._sessionFile, entry.join('\n') + '\n', 'utf8');
}
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
if (resultOverride)
return resultOverride;
if (!this._currentTab) {
return {
content: [{
type: 'text',
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
}],
};
}
const tab = this.currentTabOrDie();
let snapshotFile: string | undefined;
// TODO: race against modal dialogs to resolve clicks.
const actionResult = await this._raceAgainstModalDialogs(async () => {
try {
if (waitForNetwork)
return await waitForCompletion(this, tab, async () => action?.()) ?? undefined;
else
return await action?.() ?? undefined;
} finally {
if (captureSnapshot && !this._javaScriptBlocked()) {
await tab.captureSnapshot();
// Save snapshot to file if session logging is enabled
if (this.config.saveSession && tab.hasSnapshot()) {
const timestamp = new Date().toISOString();
const snapshotFileName = `${timestamp}.snapshot.yaml`;
snapshotFile = await outputFile(this.config, snapshotFileName);
await fs.promises.writeFile(snapshotFile, tab.snapshotOrDie().text(), 'utf8');
}
}
}
});
// Log session entry if enabled
if (this.config.saveSession)
await this._logSessionEntry(tool.schema.name, params || {}, snapshotFile);
const result: string[] = [];
result.push(`### Ran Playwright code
\`\`\`js
${code.join('\n')}
\`\`\``);
if (this.modalStates().length) {
result.push('', ...this.modalStatesMarkdown());
return {
content: [{
type: 'text',
text: result.join('\n'),
}],
};
}
const messages = tab.takeRecentConsoleMessages();
if (messages.length) {
result.push('', `### New console messages`);
for (const message of messages)
result.push(`- ${trim(message.toString(), 100)}`);
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
}
if (captureSnapshot && tab.hasSnapshot()) {
if (this.tabs().length > 1)
result.push('', await this.listTabsMarkdown());
if (this.tabs().length > 1)
result.push('', '### Current tab');
else
result.push('', '### Page state');
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.title()}`
);
result.push(tab.snapshotOrDie().text());
}
const content = actionResult?.content ?? [];
return {
content: [
...content,
{
type: 'text',
text: result.join('\n'),
}
],
};
}
async waitForTimeout(time: number) {
if (!this._currentTab || this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
this._pendingAction = {
dialogShown: new ManualPromise(),
};
let result: ToolActionResult | undefined;
try {
await Promise.race([
action().then(r => result = r),
this._pendingAction.dialogShown,
]);
} finally {
this._pendingAction = undefined;
}
return result;
}
private _javaScriptBlocked(): boolean {
return this._modalStates.some(state => state.type === 'dialog');
}
dialogShown(tab: Tab, dialog: playwright.Dialog) {
this.setModalState({
type: 'dialog',
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
dialog,
}, tab);
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(tab: Tab, download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _onPageCreated(page: playwright.Page) {
@@ -105,6 +346,7 @@ export class Context {
}
private _onPageClosed(tab: Tab) {
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
@@ -112,250 +354,72 @@ export class Context {
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
const browser = this._browser;
if (this._browserContext && !this._tabs.length) {
void this._browserContext.close().then(() => browser?.close()).catch(() => {});
this._browser = undefined;
this._browserContext = undefined;
}
if (!this._tabs.length)
void this.close();
}
async close() {
if (!this._browserContext)
if (!this._browserContextPromise)
return;
await this._browserContext.close();
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 _ensureBrowserContext() {
if (!this._browserContext) {
const context = await this._createBrowserContext();
this._browser = context.browser;
this._browserContext = context.browserContext;
for (const page of this._browserContext.pages())
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(this.clientVersion!);
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
this._browserContext.on('page', page => this._onPageCreated(page));
}
return this._browserContext;
}
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.options.remoteEndpoint) {
const url = new URL(this.options.remoteEndpoint);
if (this.options.browserName)
url.searchParams.set('browser', this.options.browserName);
if (this.options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
const browserContext = browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await this._launchPersistentContext();
return { browserContext };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
} 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.`);
throw error;
}
}
}
class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _fileChooser: playwright.FileChooser | undefined;
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._console.push(event));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._console.length = 0;
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => this._fileChooser = chooser);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _onClose() {
this._fileChooser = undefined;
this._console.length = 0;
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
try {
if (!options?.noClearFileChooser)
this._fileChooser = undefined;
if (options?.waitForCompletion)
await waitForCompletion(this.page, () => callback(this));
else
await callback(this);
} finally {
if (options?.captureSnapshot)
this._snapshot = await PageSnapshot.create(this.page);
}
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
return {
content: [{
type: 'text',
text: tabList + snapshot,
}],
};
}
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
return await this.run(callback, {
waitForCompletion: true,
...options,
browserContext.on('page', page => this._onPageCreated(page));
if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
}
async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
return await this.run(tab => callback(tab.lastSnapshot()), {
captureSnapshot: true,
waitForCompletion: true,
...options,
});
}
lastSnapshot(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
async console(): Promise<playwright.ConsoleMessage[]> {
return this._console;
}
async submitFileChooser(paths: string[]) {
if (!this._fileChooser)
throw new Error('No file chooser visible');
await this._fileChooser.setFiles(paths);
this._fileChooser = undefined;
return result;
}
}
class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(options?: { status?: string, hasFileChooser?: boolean }): string {
const results: string[] = [];
if (options?.status) {
results.push(options.status);
results.push('');
}
if (options?.hasFileChooser) {
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
results.push('');
}
results.push(this._text);
return results.join('\n');
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
const lines = [];
lines.push(
`- Page URL: ${page.url()}`,
`- Page Title: ${await page.title()}`
);
lines.push(
`- Page Snapshot`,
'```yaml',
yamlDocument.toString().trim(),
'```',
''
);
this._text = lines.join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
}
function trim(text: string, maxLength: number) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + '...';
}

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

@@ -0,0 +1,397 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp/guid - Full CDP interface for Playwright MCP
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import { WebSocket, WebSocketServer } from 'ws';
import type websocket from 'ws';
import http from 'node:http';
import debug from 'debug';
import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import { httpAddressToString, startHttpServer } from '../transport.js';
import { BrowserContextFactory } from '../browserContextFactory.js';
import { Browser, chromium, type BrowserContext } from 'playwright';
const debugLogger = debug('pw:mcp:relay');
type CDPCommand = {
id: number;
sessionId?: string;
method: string;
params?: any;
};
type CDPResponse = {
id?: number;
sessionId?: string;
method?: string;
params?: any;
result?: any;
error?: { code?: number; message: string };
};
export class CDPRelayServer {
private _wsHost: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: {
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _extensionConnectionPromise: Promise<void>;
private _extensionConnectionResolve: (() => void) | null = null;
constructor(server: http.Server) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
cdpEndpoint() {
return `${this._wsHost}${this._cdpPath}`;
}
extensionEndpoint() {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
if (this._extensionConnection)
return;
await this._connectBrowser(clientInfo);
await this._extensionConnectionPromise;
}
private async _connectBrowser(clientInfo: { name: string, version: string }) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString();
const command = `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' '${href}'`;
try {
await promisify(exec)(command);
} catch (err) {
debugLogger('Failed to run command:', err);
}
}
stop(): void {
this._playwrightConnection?.close();
this._extensionConnection?.close();
}
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
const url = new URL(`http://localhost${request.url}`);
debugLogger(`New connection to ${url.pathname}`);
if (url.pathname === this._cdpPath) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === this._extensionPath) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
private _handlePlaywrightConnection(ws: WebSocket): void {
this._playwrightConnection = ws;
ws.on('message', async data => {
try {
const message = JSON.parse(data.toString());
await this._handlePlaywrightMessage(message);
} catch (error) {
debugLogger('Error parsing Playwright message:', error);
}
});
ws.on('close', () => {
if (this._playwrightConnection === ws) {
this._playwrightConnection = null;
this._closeExtensionConnection();
debugLogger('Playwright MCP disconnected');
}
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection?.close();
this._extensionConnection = null;
this._extensionConnectionPromise = new Promise(resolve => {
this._extensionConnectionResolve = resolve;
});
}
private _handleExtensionConnection(ws: WebSocket): void {
if (this._extensionConnection) {
ws.close(1000, 'Another extension connection already established');
return;
}
this._extensionConnection = new ExtensionConnection(ws);
this._extensionConnection.onclose = c => {
if (this._extensionConnection === c)
this._extensionConnection = null;
};
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
this._extensionConnectionResolve?.();
}
private _handleExtensionMessage(method: string, params: any) {
switch (method) {
case 'forwardCDPEvent':
this._sendToPlaywright({
sessionId: params.sessionId,
method: params.method,
params: params.params
});
break;
case 'detachedFromTab':
debugLogger('← Debugger detached from tab:', params);
this._connectedTabInfo = undefined;
break;
}
}
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
if (!this._extensionConnection) {
debugLogger('Extension not connected, sending error to Playwright');
this._sendToPlaywright({
id: message.id,
error: { message: 'Extension not connected' }
});
return;
}
if (await this._interceptCDPCommand(message))
return;
await this._forwardToExtension(message);
}
private async _interceptCDPCommand(message: CDPCommand): Promise<boolean> {
switch (message.method) {
case 'Browser.getVersion': {
this._sendToPlaywright({
id: message.id,
result: {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
}
});
return true;
}
case 'Browser.setDownloadBehavior': {
this._sendToPlaywright({
id: message.id
});
return true;
}
case 'Target.setAutoAttach': {
// Simulate auto-attach behavior with real target info
if (!message.sessionId) {
this._connectedTabInfo = await this._extensionConnection!.send('attachToTab');
debugLogger('Simulating auto-attach for target:', message);
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo!.sessionId,
targetInfo: {
...this._connectedTabInfo!.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
this._sendToPlaywright({
id: message.id
});
} else {
await this._forwardToExtension(message);
}
return true;
}
case 'Target.getTargetInfo': {
debugLogger('Target.getTargetInfo', message);
this._sendToPlaywright({
id: message.id,
result: this._connectedTabInfo?.targetInfo
});
return true;
}
}
return false;
}
private async _forwardToExtension(message: CDPCommand): Promise<void> {
try {
if (!this._extensionConnection)
throw new Error('Extension not connected');
const { id, sessionId, method, params } = message;
const result = await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
id: message.id,
sessionId: message.sessionId,
error: { message: (e as Error).message }
});
}
}
private _sendToPlaywright(message: CDPResponse): void {
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
this._playwrightConnection?.send(JSON.stringify(message));
}
}
class ExtensionContextFactory implements BrowserContextFactory {
private _relay: CDPRelayServer;
private _browserPromise: Promise<Browser> | undefined;
constructor(relay: CDPRelayServer) {
this._relay = relay;
}
async createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
// First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo);
const browser = await this._browserPromise;
return {
browserContext: browser.contexts()[0],
close: async () => {}
};
}
private async _obtainBrowser(clientInfo: { name: string, version: string }): Promise<Browser> {
await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
return await chromium.connectOverCDP(this._relay.cdpEndpoint());
}
}
export async function startCDPRelayServer(port: number) {
const httpServer = await startHttpServer({ port });
const cdpRelayServer = new CDPRelayServer(httpServer);
process.on('exit', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return new ExtensionContextFactory(cdpRelayServer);
}
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void }>();
private _lastId = 0;
onmessage?: (method: string, params: any) => void;
onclose?: (self: ExtensionConnection) => void;
constructor(ws: WebSocket) {
this._ws = ws;
this._ws.on('message', this._onMessage.bind(this));
this._ws.on('close', this._onClose.bind(this));
this._ws.on('error', this._onError.bind(this));
}
async send(method: string, params?: any, sessionId?: string): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN)
throw new Error('WebSocket closed');
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject });
});
}
close(message?: string) {
debugLogger('closing extension connection:', message);
this._ws.close(1000, message ?? 'Connection closed');
this.onclose?.(this);
}
private _onMessage(event: websocket.RawData) {
const eventData = event.toString();
let parsedJson;
try {
parsedJson = JSON.parse(eventData);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
this._ws.close();
return;
}
try {
this._handleParsedMessage(parsedJson);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
this._ws.close();
}
}
private _handleParsedMessage(object: any) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error)
callback.reject(new Error(object.error.message));
else
callback.resolve(object.result);
} else if (object.id) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method, object.params);
}
}
private _onClose(event: websocket.CloseEvent) {
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
this._dispose();
}
private _onError(event: websocket.ErrorEvent) {
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
this._dispose();
}
private _dispose() {
for (const callback of this._callbacks.values())
callback.reject(new Error('WebSocket closed'));
this._callbacks.clear();
}
}

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

@@ -0,0 +1,35 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolveCLIConfig } from '../config.js';
import { startHttpServer, startHttpTransport, startStdioTransport } from '../transport.js';
import { Server } from '../server.js';
import { startCDPRelayServer } from './cdpRelay.js';
export async function runWithExtension(options: any) {
const config = await resolveCLIConfig({ });
const contextFactory = await startCDPRelayServer(9225);
const server = new Server(config, contextFactory);
server.setupExitWatchdog();
if (options.port !== undefined) {
const httpServer = await startHttpServer({ port: options.port });
startHttpTransport(httpServer, server);
} else {
await startStdioTransport(server);
}
}

37
src/fileUtils.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'node:os';
import path from 'node:path';
import type { FullConfig } from './config.js';
export function cacheDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
return path.join(cacheDirectory, 'ms-playwright');
}
export async function userDataDir(browserConfig: FullConfig['browser']) {
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
}

232
src/httpServer.ts Normal file
View File

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

View File

@@ -14,71 +14,33 @@
* limitations under the License.
*/
import { createServerWithTools } from './server';
import common from './tools/common';
import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
import screen from './tools/screen';
import { console as consoleResource } from './resources/console';
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 { Tool, ToolCapability } from './tools/tool';
import type { Resource } from './resources/resource';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
const snapshotTools: Tool[] = [
...common(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
...pdf,
...snapshot,
...tabs(true),
];
const screenshotTools: Tool[] = [
...common(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...pdf,
...screen,
...tabs(false),
];
const resources: Resource[] = [
consoleResource,
];
type Options = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir?: string;
launchOptions?: LaunchOptions;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
const packageJSON = require('../package.json');
export function createServer(options?: Options): Server {
const allTools = options?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,
tools,
resources,
browserName: options?.browserName,
userDataDir: options?.userDataDir ?? '',
launchOptions: options?.launchOptions,
cdpEndpoint: options?.cdpEndpoint,
});
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()
};
}
}

53
src/javascript.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* 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

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

127
src/manualPromise.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* 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

@@ -14,22 +14,9 @@
* limitations under the License.
*/
import type { Resource } from './resource';
import fs from 'node:fs';
import url from 'node:url';
import path from 'node:path';
export const console: Resource = {
schema: {
uri: 'browser://console',
name: 'Page console',
mimeType: 'text/plain',
},
read: async (context, uri) => {
const messages = await context.currentTab().console();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return [{
uri,
mimeType: 'text/plain',
text: log
}];
},
};
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

55
src/pageSnapshot.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot {
private _page: playwright.Page;
private _text!: string;
constructor(page: playwright.Page) {
this._page = page;
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot(page);
await snapshot._build();
return snapshot;
}
text(): string {
return this._text;
}
private async _build() {
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [
`- Page Snapshot:`,
'```yaml',
snapshot,
'```',
].join('\n');
}
refLocator(params: { element: string, ref: string }): playwright.Locator {
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
}
}

View File

@@ -14,177 +14,76 @@
* limitations under the License.
*/
import http from 'http';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { program, Option } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { program } from 'commander';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { createServer } from './index';
import { ServerList } from './server';
import type { LaunchOptions } from 'playwright';
import assert from 'assert';
import { ToolCapability } from './tools/tool';
const packageJSON = require('../package.json');
import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
import { runWithExtension } from './extension/main.js';
program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--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('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--user-data-dir <path>', 'Path to the user data directory')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.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" or "omit", Defaults to "allow".')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--save-session', 'Whether to save the session log with tool calls and snapshots into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
if (options.extension) {
await runWithExtension(options);
return;
}
const launchOptions: LaunchOptions = {
headless: options.headless ?? !process.env.DISPLAY,
channel,
executablePath: options.executablePath,
};
if (options.vision) {
// eslint-disable-next-line no-console
console.error('The --vision option is deprecated, use --caps=vision instead');
options.caps = 'vision';
}
const config = await resolveCLIConfig(options);
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
const server = new Server(config);
server.setupExitWatchdog();
const serverList = new ServerList(() => createServer({
browserName,
userDataDir,
launchOptions,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
}));
setupExitWatchdog(serverList);
if (options.port) {
startSSEServer(+options.port, serverList);
if (config.server.port !== undefined) {
const httpServer = await startHttpServer(config.server);
startHttpTransport(httpServer, server);
} else {
const server = await serverList.create();
await server.connect(new StdioServerTransport());
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 setupExitWatchdog(serverList: ServerList) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
await serverList.closeAll();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
program.parse(process.argv);
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
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-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
async function startSSEServer(port: number, serverList: ServerList) {
const sessions = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
if (req.method === 'POST') {
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
const sessionId = searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
res.end('Missing sessionId');
return;
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
return;
}
await transport.handlePostMessage(req, res);
return;
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
const server = await serverList.create();
res.on('close', () => {
sessions.delete(transport.sessionId);
serverList.close(server).catch(e => console.error(e));
});
await server.connect(transport);
return;
} else {
res.statusCode = 405;
res.end('Method not allowed');
}
});
httpServer.listen(port, () => {
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}`;
}
console.log(`Listening on ${url}`);
console.log('Put this in your client config:');
console.log(JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
}
}, undefined, 2));
});
}
void program.parseAsync(process.argv);

View File

@@ -14,101 +14,46 @@
* limitations under the License.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { createConnection } from './connection.js';
import { contextFactory as defaultContextFactory } from './browserContextFactory.js';
import { Context } from './context';
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';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { ContextOptions } from './context';
export class Server {
readonly config: FullConfig;
private _connectionList: Connection[] = [];
private _browserConfig: FullConfig['browser'];
private _contextFactory: BrowserContextFactory;
type Options = ContextOptions & {
name: string;
version: string;
tools: Tool[];
resources: Resource[],
constructor(config: FullConfig, contextFactory?: BrowserContextFactory) {
this.config = config;
this._browserConfig = config.browser;
this._contextFactory = contextFactory ?? defaultContextFactory(this._browserConfig);
}
async createConnection(transport: Transport): Promise<Connection> {
const connection = createConnection(this.config, this._contextFactory);
this._connectionList.push(connection);
await connection.server.connect(transport);
return connection;
}
setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Promise.all(this._connectionList.map(connection => connection.close()));
process.exit(0);
};
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources } = options;
const context = new Context(options);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
resources: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: tools.map(tool => tool.schema) };
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: resources.map(resource => resource.schema) };
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
isError: true,
};
}
try {
const result = await tool.handle(context, request.params.arguments);
return result;
} catch (error) {
return {
content: [{ type: 'text', text: String(error) }],
isError: true,
};
}
});
server.setRequestHandler(ReadResourceRequestSchema, async request => {
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
if (!resource)
return { contents: [] };
const contents = await resource.read(context, request.params.uri);
return { contents };
});
const oldClose = server.close.bind(server);
server.close = async () => {
await oldClose();
await context.close();
};
return server;
}
export class ServerList {
private _servers: Server[] = [];
private _serverFactory: () => Server;
constructor(serverFactory: () => Server) {
this._serverFactory = serverFactory;
}
async create() {
const server = this._serverFactory();
this._servers.push(server);
return server;
}
async close(server: Server) {
const index = this._servers.indexOf(server);
if (index !== -1)
this._servers.splice(index, 1);
await server.close();
}
async closeAll() {
await Promise.all(this._servers.map(server => server.close()));
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}

164
src/tab.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* 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 { callOnPageNoTrace } from './tools/utils.js';
import { logUnhandledError } from './log.js';
import type { Context } from './context.js';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _consoleMessages: ConsoleMessage[] = [];
private _recentConsoleMessages: ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
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._recentConsoleMessages.length = 0;
this._requests.clear();
}
private _handleConsoleMessage(message: ConsoleMessage) {
this._consoleMessages.push(message);
this._recentConsoleMessages.push(message);
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
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(): ConsoleMessage[] {
return this._consoleMessages;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
takeRecentConsoleMessages(): ConsoleMessage[] {
const result = this._recentConsoleMessages.slice();
this._recentConsoleMessages.length = 0;
return result;
}
}
export type ConsoleMessage = {
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
text: string;
toString(): string;
};
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
return {
type: message.type(),
text: message.text(),
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
};
}
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
if (errorOrValue instanceof Error) {
return {
type: undefined,
text: errorOrValue.message,
toString: () => errorOrValue.stack || errorOrValue.message,
};
}
return {
type: undefined,
text: String(errorOrValue),
toString: () => String(errorOrValue),
};
}

51
src/tools.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* 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 evaluate from './tools/evaluate.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 wait from './tools/wait.js';
import mouse from './tools/mouse.js';
import type { Tool } from './tools/tool.js';
export const allTools: Tool<any>[] = [
...common,
...console,
...dialogs,
...evaluate,
...files,
...install,
...keyboard,
...navigate,
...network,
...mouse,
...pdf,
...screenshot,
...snapshot,
...tabs,
...wait,
];

View File

@@ -15,81 +15,64 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import type { Tool, ToolFactory } from './tool';
const waitSchema = z.object({
time: z.number().describe('The time to wait in seconds'),
});
const wait: Tool = {
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
inputSchema: zodToJsonSchema(waitSchema),
},
handle: async (context, params) => {
const validatedParams = waitSchema.parse(params);
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
return {
content: [{
type: 'text',
text: `Waited for ${validatedParams.time} seconds`,
}],
};
},
};
const closeSchema = z.object({});
const close: Tool = {
const close = defineTool({
capability: 'core',
schema: {
name: 'browser_close',
title: 'Close browser',
description: 'Close the page',
inputSchema: zodToJsonSchema(closeSchema),
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.close();
return {
content: [{
type: 'text',
text: `Page closed`,
}],
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
},
};
const resizeSchema = z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
});
const resize: ToolFactory = captureSnapshot => ({
const resize = defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
title: 'Resize browser window',
description: 'Resize the browser window',
inputSchema: zodToJsonSchema(resizeSchema),
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 validatedParams = resizeSchema.parse(params);
const tab = context.currentTab();
return await tab.run(
tab => tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height }),
{
status: `Resized browser window`,
captureSnapshot,
}
);
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: true,
waitForNetwork: true
};
},
});
export default (captureSnapshot: boolean) => [
export default [
close,
wait,
resize(captureSnapshot)
resize
];

47
src/tools/console.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* 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.toString()).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,
];

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

@@ -0,0 +1,62 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
const handleDialog = 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: true,
waitForNetwork: false,
};
},
clearsModalState: 'dialog',
});
export default [
handleDialog,
];

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

@@ -0,0 +1,71 @@
/**
* 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';
import type * as playwright from 'playwright';
const evaluateSchema = z.object({
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
});
const evaluate = defineTool({
capability: 'core',
schema: {
name: 'browser_evaluate',
title: 'Evaluate JavaScript',
description: 'Evaluate JavaScript expression on page or element',
inputSchema: evaluateSchema,
type: 'destructive',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const code: string[] = [];
let locator: playwright.Locator | undefined;
if (params.ref && params.element) {
const snapshot = tab.snapshotOrDie();
locator = snapshot.refLocator({ ref: params.ref, element: params.element });
code.push(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
} else {
code.push(`await page.evaluate(${javascript.quote(params.function)});`);
}
return {
code,
action: async () => {
const receiver = locator ?? tab.page as any;
const result = await receiver._evaluateFunction(params.function);
return {
content: [{ type: 'text', text: '- Result: ' + (JSON.stringify(result, null, 2) || 'undefined') }],
};
},
captureSnapshot: false,
waitForNetwork: false,
};
},
});
export default [
evaluate,
];

View File

@@ -15,34 +15,45 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import type { ToolFactory } from './tool';
const uploadFile = defineTool({
capability: 'core',
const uploadFileSchema = z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
});
const uploadFile: ToolFactory = captureSnapshot => ({
capability: 'files',
schema: {
name: 'browser_file_upload',
title: 'Upload files',
description: 'Upload one or multiple files',
inputSchema: zodToJsonSchema(uploadFileSchema),
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 validatedParams = uploadFileSchema.parse(params);
const tab = context.currentTab();
return await tab.runAndWait(async () => {
await tab.submitFileChooser(validatedParams.paths);
}, {
status: `Chose files ${validatedParams.paths.join(', ')}`,
captureSnapshot,
noClearFileChooser: true,
});
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: true,
waitForNetwork: true,
};
},
clearsModalState: 'fileChooser',
});
export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
export default [
uploadFile,
];

View File

@@ -18,22 +18,25 @@ import { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import type { Tool } from './tool';
import { fileURLToPath } from 'node:url';
const install: Tool = {
capability: 'install',
const install = defineTool({
capability: 'core-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: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
type: 'destructive',
},
handle: async context => {
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
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[] = [];
@@ -48,13 +51,12 @@ const install: Tool = {
});
});
return {
content: [{
type: 'text',
text: `Browser ${channel} installed`,
}],
code: [`// Browser ${channel} installed`],
captureSnapshot: false,
waitForNetwork: false,
};
},
};
});
export default [
install,

View File

@@ -15,32 +15,93 @@
*/
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import type { ToolFactory } from './tool';
import { defineTool } from './tool.js';
import { elementSchema } from './snapshot.js';
import { generateLocator } from './utils.js';
import * as javascript from '../javascript.js';
const pressKeySchema = z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
});
const pressKey: ToolFactory = captureSnapshot => ({
const pressKey = defineTool({
capability: 'core',
schema: {
name: 'browser_press_key',
title: 'Press a key',
description: 'Press a key on the keyboard',
inputSchema: zodToJsonSchema(pressKeySchema),
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 validatedParams = pressKeySchema.parse(params);
return await context.currentTab().runAndWait(async tab => {
await tab.page.keyboard.press(validatedParams.key);
}, {
status: `Pressed key ${validatedParams.key}`,
captureSnapshot,
});
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: true,
waitForNetwork: true
};
},
});
export default (captureSnapshot: boolean) => [
pressKey(captureSnapshot),
const typeSchema = elementSchema.extend({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
});
const type = 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,
};
},
});
export default [
pressKey,
type,
];

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

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

View File

@@ -15,73 +15,90 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import type { ToolFactory } from './tool';
const navigateSchema = z.object({
url: z.string().describe('The URL to navigate to'),
});
const navigate: ToolFactory = captureSnapshot => ({
const navigate = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate',
title: 'Navigate to a URL',
description: 'Navigate to a URL',
inputSchema: zodToJsonSchema(navigateSchema),
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
type: 'destructive',
},
handle: async (context, params) => {
const validatedParams = navigateSchema.parse(params);
const currentTab = await context.ensureTab();
return await currentTab.run(async tab => {
await tab.navigate(validatedParams.url);
}, {
status: `Navigated to ${validatedParams.url}`,
captureSnapshot,
});
const tab = await context.ensureTab();
await tab.navigate(params.url);
const code = [
`// Navigate to ${params.url}`,
`await page.goto('${params.url}');`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goBackSchema = z.object({});
const goBack: ToolFactory = snapshot => ({
capability: 'history',
const goBack = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate_back',
title: 'Go back',
description: 'Go back to the previous page',
inputSchema: zodToJsonSchema(goBackSchema),
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
return await context.currentTab().runAndWait(async tab => {
const tab = await context.ensureTab();
await tab.page.goBack();
}, {
status: 'Navigated back',
captureSnapshot: snapshot,
});
const code = [
`// Navigate back`,
`await page.goBack();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
const goForwardSchema = z.object({});
const goForward: ToolFactory = snapshot => ({
capability: 'history',
const goForward = defineTool({
capability: 'core',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
description: 'Go forward to the next page',
inputSchema: zodToJsonSchema(goForwardSchema),
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
return await context.currentTab().runAndWait(async tab => {
const tab = context.currentTabOrDie();
await tab.page.goForward();
}, {
status: 'Navigated forward',
captureSnapshot: snapshot,
});
const code = [
`// Navigate forward`,
`await page.goForward();`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false,
};
},
});
export default (captureSnapshot: boolean) => [
navigate(captureSnapshot),
goBack(captureSnapshot),
goForward(captureSnapshot),
export default [
navigate,
goBack,
goForward,
];

59
src/tools/network.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* 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

@@ -14,37 +14,44 @@
* limitations under the License.
*/
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import type { Tool } from './tool';
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 pdfSchema = z.object({});
const pdf: Tool = {
const pdf = defineTool({
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
title: 'Save as PDF',
description: 'Save page as PDF',
inputSchema: zodToJsonSchema(pdfSchema),
inputSchema: pdfSchema,
type: 'readOnly',
},
handle: async context => {
const tab = context.currentTab();
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
await tab.page.pdf({ path: fileName });
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 {
content: [{
type: 'text',
text: `Saved as ${fileName}`,
}],
code,
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
captureSnapshot: false,
waitForNetwork: false,
};
},
};
});
export default [
pdf,

View File

@@ -1,150 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { Tool } from './tool';
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_capture',
description: 'Take a screenshot of the current page',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const tab = await context.ensureTab();
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
};
},
};
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
});
const moveMouseSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const moveMouse: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
description: 'Move mouse to a given position',
inputSchema: zodToJsonSchema(moveMouseSchema),
},
handle: async (context, params) => {
const validatedParams = moveMouseSchema.parse(params);
const tab = context.currentTab();
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
return {
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
};
},
};
const clickSchema = elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
});
const click: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_click',
description: 'Click left mouse button',
inputSchema: zodToJsonSchema(clickSchema),
},
handle: async (context, params) => {
return await context.currentTab().runAndWait(async tab => {
const validatedParams = clickSchema.parse(params);
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
await tab.page.mouse.down();
await tab.page.mouse.up();
}, {
status: 'Clicked mouse',
});
},
};
const dragSchema = 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'),
});
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_drag',
description: 'Drag left mouse button',
inputSchema: zodToJsonSchema(dragSchema),
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await context.currentTab().runAndWait(async tab => {
await tab.page.mouse.move(validatedParams.startX, validatedParams.startY);
await tab.page.mouse.down();
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
await tab.page.mouse.up();
}, {
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
});
},
};
const typeSchema = 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)'),
});
const type: Tool = {
capability: 'core',
schema: {
name: 'browser_screen_type',
description: 'Type text',
inputSchema: zodToJsonSchema(typeSchema),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await context.currentTab().runAndWait(async tab => {
await tab.page.keyboard.type(validatedParams.text);
if (validatedParams.submit)
await tab.page.keyboard.press('Enter');
}, {
status: `Typed text "${validatedParams.text}"`,
});
},
};
export default [
screenshot,
moveMouse,
click,
drag,
type,
];

103
src/tools/screenshot.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* 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.'),
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
}).refine(data => {
return !(data.fullPage && (data.element || data.ref));
}, {
message: 'fullPage cannot be used with element screenshots.',
path: ['fullPage']
});
const screenshot = defineTool({
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,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
};
const isElementScreenshot = params.element && params.ref;
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
const code = [
`// Screenshot ${screenshotTarget} and save it as ${fileName}`,
];
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: false,
waitForNetwork: false,
};
}
});
export default [
screenshot,
];

View File

@@ -15,177 +15,174 @@
*/
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import type * as playwright from 'playwright';
import type { Tool } from './tool';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
const snapshot: Tool = {
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: zodToJsonSchema(z.object({})),
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = await context.ensureTab();
return await tab.run(async () => {}, { captureSnapshot: true });
},
};
await context.ensureTab();
const elementSchema = z.object({
return {
code: [`// <internal code to capture accessibility snapshot>`],
captureSnapshot: true,
waitForNetwork: false,
};
},
});
export const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
ref: z.string().describe('Exact target element reference from the page snapshot'),
});
const click: Tool = {
const clickSchema = elementSchema.extend({
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
});
const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
title: 'Click',
description: 'Perform click on a web page',
inputSchema: zodToJsonSchema(elementSchema),
inputSchema: clickSchema,
type: 'destructive',
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.click();
}, {
status: `Clicked "${validatedParams.element}"`,
});
},
};
const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params);
const button = params.button;
const buttonAttr = button ? `{ button: '${button}' }` : '';
const dragSchema = z.object({
const code: string[] = [];
if (params.doubleClick) {
code.push(`// Double click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
} else {
code.push(`// Click ${params.element}`);
code.push(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
}
return {
code,
action: () => params.doubleClick ? locator.dblclick({ button }) : locator.click({ button }),
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'),
});
const drag: Tool = {
capability: 'core',
schema: {
name: 'browser_drag',
description: 'Perform drag and drop between two elements',
inputSchema: zodToJsonSchema(dragSchema),
}),
type: 'destructive',
},
handle: async (context, params) => {
const validatedParams = dragSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const startLocator = snapshot.refLocator(validatedParams.startRef);
const endLocator = snapshot.refLocator(validatedParams.endRef);
await startLocator.dragTo(endLocator);
}, {
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
});
},
};
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 hover: Tool = {
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: zodToJsonSchema(elementSchema),
inputSchema: elementSchema,
type: 'readOnly',
},
handle: async (context, params) => {
const validatedParams = elementSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.hover();
}, {
status: `Hovered over "${validatedParams.element}"`,
});
},
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: Tool = {
capability: 'core',
schema: {
name: 'browser_type',
description: 'Type text into editable element',
inputSchema: zodToJsonSchema(typeSchema),
},
handle: async (context, params) => {
const validatedParams = typeSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
if (validatedParams.slowly)
await locator.pressSequentially(validatedParams.text);
else
await locator.fill(validatedParams.text);
if (validatedParams.submit)
await locator.press('Enter');
}, {
status: `Typed "${validatedParams.text}" into "${validatedParams.element}"`,
});
},
};
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: Tool = {
const selectOption = defineTool({
capability: 'core',
schema: {
name: 'browser_select_option',
title: 'Select option',
description: 'Select an option in a dropdown',
inputSchema: zodToJsonSchema(selectOptionSchema),
inputSchema: selectOptionSchema,
type: 'destructive',
},
handle: async (context, params) => {
const validatedParams = selectOptionSchema.parse(params);
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
const locator = snapshot.refLocator(validatedParams.ref);
await locator.selectOption(validatedParams.values);
}, {
status: `Selected option in "${validatedParams.element}"`,
});
},
};
const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params);
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.'),
});
const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`,
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
];
const screenshot: Tool = {
capability: 'core',
schema: {
name: 'browser_take_screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
inputSchema: zodToJsonSchema(screenshotSchema),
},
handle: async (context, params) => {
const validatedParams = screenshotSchema.parse(params);
const tab = context.currentTab();
const options: playwright.PageScreenshotOptions = validatedParams.raw ? { type: 'png', scale: 'css' } : { type: 'jpeg', quality: 50, scale: 'css' };
const screenshot = await tab.page.screenshot(options);
return {
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: validatedParams.raw ? 'image/png' : 'image/jpeg' }],
code,
action: () => locator.selectOption(params.values).then(() => {}),
captureSnapshot: true,
waitForNetwork: true,
};
},
};
});
export default [
snapshot,
click,
drag,
hover,
type,
selectOption,
screenshot,
];

View File

@@ -15,95 +15,120 @@
*/
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { defineTool } from './tool.js';
import type { ToolFactory, Tool } from './tool';
const listTabs = defineTool({
capability: 'core-tabs',
const listTabs: Tool = {
capability: 'tabs',
schema: {
name: 'browser_tab_list',
title: 'List tabs',
description: 'List browser tabs',
inputSchema: zodToJsonSchema(z.object({})),
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.listTabs(),
text: await context.listTabsMarkdown(),
}],
};
},
};
const selectTabSchema = z.object({
index: z.number().describe('The index of the tab to select'),
},
});
const selectTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
const selectTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_select',
title: 'Select a tab',
description: 'Select a tab by index',
inputSchema: zodToJsonSchema(selectTabSchema),
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
const validatedParams = selectTabSchema.parse(params);
await context.selectTab(validatedParams.index);
const currentTab = await context.ensureTab();
return await currentTab.run(async () => {}, { captureSnapshot });
await context.selectTab(params.index);
const code = [
`// <internal code to select tab ${params.index}>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
const newTabSchema = 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.'),
});
const newTab = defineTool({
capability: 'core-tabs',
const newTab: Tool = {
capability: 'tabs',
schema: {
name: 'browser_tab_new',
title: 'Open a new tab',
description: 'Open a new tab',
inputSchema: zodToJsonSchema(newTabSchema),
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) => {
const validatedParams = newTabSchema.parse(params);
await context.newTab();
if (validatedParams.url)
await context.currentTab().navigate(validatedParams.url);
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
},
};
const closeTabSchema = z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
handle: async (context, params) => {
await context.newTab();
if (params.url)
await context.currentTabOrDie().navigate(params.url);
const code = [
`// <internal code to open a new tab>`,
];
return {
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
const closeTab: ToolFactory = captureSnapshot => ({
capability: 'tabs',
const closeTab = defineTool({
capability: 'core-tabs',
schema: {
name: 'browser_tab_close',
title: 'Close a tab',
description: 'Close a tab',
inputSchema: zodToJsonSchema(closeTabSchema),
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) => {
const validatedParams = closeTabSchema.parse(params);
await context.closeTab(validatedParams.index);
const currentTab = context.currentTab();
if (currentTab)
return await currentTab.run(async () => {}, { captureSnapshot });
await context.closeTab(params.index);
const code = [
`// <internal code to close tab ${params.index}>`,
];
return {
content: [{
type: 'text',
text: await context.listTabs(),
}],
code,
captureSnapshot: true,
waitForNetwork: false
};
},
});
export default (captureSnapshot: boolean) => [
export default [
listTabs,
newTab,
selectTab(captureSnapshot),
closeTab(captureSnapshot),
selectTab,
closeTab,
];

View File

@@ -14,27 +14,53 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import type { Context } from '../context';
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 ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
export type ToolSchema = {
export type ToolSchema<Input extends InputType> = {
name: string;
title: string;
description: string;
inputSchema: JsonSchema7Type;
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 = {
content: (ImageContent | TextContent)[];
isError?: boolean;
code: string[];
action?: () => Promise<ToolActionResult>;
captureSnapshot: boolean;
waitForNetwork: boolean;
resultOverride?: ToolActionResult;
};
export type Tool = {
export type Tool<Input extends InputType = InputType> = {
capability: ToolCapability;
schema: ToolSchema;
handle: (context: Context, params?: Record<string, any>) => Promise<ToolResult>;
schema: ToolSchema<Input>;
clearsModalState?: ModalState['type'];
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
};
export type ToolFactory = (snapshot: boolean) => Tool;
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -14,9 +14,14 @@
* limitations under the License.
*/
import type * as playwright from 'playwright';
// @ts-ignore
import { asLocator } from 'playwright-core/lib/utils';
export async function waitForCompletion<R>(page: playwright.Page, callback: () => Promise<R>): Promise<R> {
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 = () => {};
@@ -35,9 +40,7 @@ export async function waitForCompletion<R>(page: playwright.Page, callback: () =
frameNavigated = true;
dispose();
clearTimeout(timeout);
void frame.waitForLoadState('load').then(() => {
waitCallback();
});
void tab.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
@@ -45,15 +48,15 @@ export async function waitForCompletion<R>(page: playwright.Page, callback: () =
waitCallback();
};
page.on('request', requestListener);
page.on('requestfinished', requestFinishedListener);
page.on('framenavigated', frameNavigateListener);
tab.page.on('request', requestListener);
tab.page.on('requestfinished', requestFinishedListener);
tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
page.off('request', requestListener);
page.off('requestfinished', requestFinishedListener);
page.off('framenavigated', frameNavigateListener);
tab.page.off('request', requestListener);
tab.page.off('requestfinished', requestFinishedListener);
tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
@@ -62,7 +65,7 @@ export async function waitForCompletion<R>(page: playwright.Page, callback: () =
if (!requests.size && !frameNavigated)
waitCallback();
await waitBarrier;
await page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
await context.waitForTimeout(1000);
return result;
} finally {
dispose();
@@ -70,5 +73,22 @@ export async function waitForCompletion<R>(page: playwright.Page, callback: () =
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
try {
const { resolvedSelector } = await (locator as any)._resolveSelector();
return asLocator('javascript', resolvedSelector);
} catch (e) {
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
}
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
}

70
src/tools/wait.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* 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 wait = defineTool({
capability: 'core',
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(30000, 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: true,
waitForNetwork: false,
};
},
});
export default [
wait,
];

158
src/transport.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 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 { logUnhandledError } from './log.js';
import type { AddressInfo } from 'node:net';
import type { Server } from './server.js';
import type { Connection } from './connection.js';
export async function startStdioTransport(server: Server) {
await server.createConnection(new StdioServerTransport());
}
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);
void connection.close().catch(logUnhandledError);
});
return;
}
res.statusCode = 405;
res.end('Method not allowed');
}
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, { transport: StreamableHTTPServerTransport, connection: Connection }>) {
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: async sessionId => {
testDebug(`create http session: ${transport.sessionId}`);
const connection = await server.createConnection(transport);
sessions.set(sessionId, { transport, connection });
}
});
transport.onclose = () => {
const result = transport.sessionId ? sessions.get(transport.sessionId) : undefined;
if (!result)
return;
sessions.delete(result.transport.sessionId!);
testDebug(`delete http session: ${transport.sessionId}`);
result.connection.close().catch(logUnhandledError);
};
await transport.handleRequest(req, res);
return;
}
res.statusCode = 400;
res.end('Invalid request');
}
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
});
return httpServer;
}
export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(mcpServer, req, res, url, sseSessions);
else
await handleStreamable(mcpServer, req, res, streamableSessions);
});
const url = httpAddressToString(httpServer.address());
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/mcp`
}
}
}, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}
export function httpAddressToString(address: string | AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
}

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/promises';
import { test, expect } from './fixtures';
test('browser_navigate', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
},
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e3',
},
})).toHaveTextContent(`Clicked "Submit button"
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=s2e3]
\`\`\`
`);
});
test('browser_select_option', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar'],
},
})).toHaveTextContent(`Selected option in "Select"
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
\`\`\`
`);
});
test('browser_select_option (multiple)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
},
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`Selected option in "Select"
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
\`\`\`
`);
});
test('browser_file_upload', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e3',
},
})).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
const filePath = test.info().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('There is a file chooser visible that requires browser_file_upload to be called');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e3',
},
});
expect(response).toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
}
{
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e4',
},
});
expect(response, 'not submitting browser_file_upload dismisses file chooser').not.toContainTextContent('There is a file chooser visible that requires browser_file_upload to be called');
}
});
test('browser_type', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
text: 'Hi!',
submit: true,
},
});
const resource = await client.readResource({
uri: 'browser://console',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: '[LOG] Key pressed: Enter , Text: Hi!',
}]);
});
test('browser_type (slowly)', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
text: 'Hi!',
submit: true,
slowly: true,
},
});
const resource = await client.readResource({
uri: 'browser://console',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: [
'[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 }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><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></html>',
},
});
const response = await client.callTool({
name: 'browser_resize',
arguments: {
width: 390,
height: 780,
},
});
expect(response).toContainTextContent('Resized browser window');
expect(response).toContainTextContent('Window size: 390x780');
});

View File

@@ -14,14 +14,17 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
@@ -30,7 +33,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_network_requests',
'browser_press_key',
'browser_resize',
'browser_snapshot',
@@ -39,56 +42,37 @@ test('test snapshot tool list', async ({ client }) => {
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
'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',
'browser_file_upload',
'browser_install',
'browser_navigate_back',
'browser_navigate_forward',
'browser_navigate',
'browser_pdf_save',
'browser_press_key',
'browser_resize',
'browser_screen_capture',
'browser_screen_click',
'browser_screen_drag',
'browser_screen_move_mouse',
'browser_screen_type',
'browser_tab_close',
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([
expect.objectContaining({
uri: 'browser://console',
mimeType: 'text/plain',
}),
]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],
test('test capabilities (pdf)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--caps=pdf'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).not.toContain('browser_file_upload');
expect(toolNames).not.toContain('browser_pdf_save');
expect(toolNames).not.toContain('browser_screen_capture');
expect(toolNames).not.toContain('browser_screen_click');
expect(toolNames).not.toContain('browser_screen_drag');
expect(toolNames).not.toContain('browser_screen_move_mouse');
expect(toolNames).not.toContain('browser_screen_type');
expect(toolNames).toContain('browser_pdf_save');
});
test('test capabilities (vision)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--caps=vision'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy');
expect(toolNames).toContain('browser_mouse_click_xy');
expect(toolNames).toContain('browser_mouse_drag_xy');
});
test('support for legacy --vision option', async ({ startClient }) => {
const { client } = await startClient({
args: ['--vision'],
});
const { tools } = await client.listTools();
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('browser_mouse_move_xy');
expect(toolNames).toContain('browser_mouse_click_xy');
expect(toolNames).toContain('browser_mouse_drag_xy');
});

View File

@@ -14,30 +14,26 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import url from 'node:url';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
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: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
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',
@@ -45,17 +41,53 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
element: 'Hello, world!',
ref: 'f0',
},
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveTextContent(`
- Page URL: data:text/html,hello world
- Page Title:
- Page Snapshot
### Ran Playwright code
\`\`\`js
// <internal code to capture accessibility snapshot>
\`\`\`
### Page state
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- text: hello world
- generic [active] [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 [active] [ref=e1]: Hello, world!`);
});
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
test('does not support --device', async () => {
const result = spawnSync('node', [
path.join(__filename, '../../cli.js'), '--device=Pixel 5', '--cdp-endpoint=http://localhost:1234',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with cdpEndpoint.');
});

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

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

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

@@ -0,0 +1,80 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, server, mcpMode }, 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, mcpMode }, 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`);
});
});
test.describe('sandbox configuration', () => {
test('should enable sandbox by default (no --no-sandbox flag)', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: undefined });
// When --no-sandbox is not passed, chromiumSandbox should not be set to false
// This allows the default (true) to be used
expect(config.browser?.launchOptions?.chromiumSandbox).toBeUndefined();
});
test('should disable sandbox when --no-sandbox flag is passed', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: false });
// When --no-sandbox is passed, chromiumSandbox should be explicitly set to false
expect(config.browser?.launchOptions?.chromiumSandbox).toBe(false);
});
});

View File

@@ -14,22 +14,83 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
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');
test('browser://console', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
url: server.PREFIX,
},
});
const resource = await client.readResource({
uri: 'browser://console',
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource.contents).toEqual([{
uri: 'browser://console',
mimeType: 'text/plain',
text: '[LOG] Hello, world!\n[ERROR] Error',
}]);
expect(resource).toHaveTextContent([
`[LOG] Hello, world! @ ${server.PREFIX}:4`,
`[ERROR] Error @ ${server.PREFIX}:5`,
].join('\n'));
});
test('browser_console_messages (page error)', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
throw new Error("Error in script");
</script>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const resource = await client.callTool({
name: 'browser_console_messages',
});
expect(resource).toHaveTextContent(/Error: Error in script/);
expect(resource).toHaveTextContent(new RegExp(server.PREFIX));
});
test('recent console messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<button onclick="console.log('Hello, world!');">Click me</button>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
const response = await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(response).toContainTextContent(`
### New console messages
- [LOG] Hello, world! @`);
});

265
tests/core.spec.ts Normal file
View File

@@ -0,0 +1,265 @@
/**
* 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_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 state
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot:
\`\`\`yaml
- generic [active] [ref=e1]: Hello, world!
\`\`\`
`
);
});
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 state
- 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 state
- 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,
},
});
const response = await client.callTool({
name: 'browser_console_messages',
});
expect(response).toHaveTextContent(/\[LOG\] Key pressed: H Text: /);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: i Text: H/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: ! Text: Hi/);
expect(response).toHaveTextContent(/\[LOG\] Key pressed: Enter Text: Hi!/);
});
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');
});
test('old locator error message', async ({ client, server }) => {
server.setContent('/', `
<button>Button 1</button>
<button>Button 2</button>
<script>
document.querySelector('button').addEventListener('click', () => {
document.querySelectorAll('button')[1].remove();
});
</script>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent(`
- button "Button 1" [ref=e2]
- button "Button 2" [ref=e3]
`.trim());
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button 1',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button 2',
ref: 'e3',
},
})).toContainTextContent('Ref not found');
});
test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
server.setContent('/', `
<div style="visibility: hidden;">
<div style="visibility: visible;">
<button>Button</button>
</div>
</div>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_snapshot'
})).toContainTextContent('- button "Button"');
});

43
tests/device.spec.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* 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, mcpMode }) => {
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`);
});

260
tests/dialogs.spec.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* 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('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).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
});
test('two alert dialogs', async ({ client, server }) => {
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).toContainTextContent(`
### Modal state
- ["alert" dialog with message "Alert 2"]: can be handled by the "browser_handle_dialog" tool`);
const result2 = await client.callTool({
name: 'browser_handle_dialog',
arguments: {
accept: true,
},
});
expect(result2).not.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 [active] [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 [active] [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 [active] [ref=e1]: Answer
\`\`\``);
});
test('alert dialog w/ race', async ({ client, server }) => {
server.setContent('/', `<button onclick="setTimeout(() => alert('Alert'), 100)">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).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).toContainTextContent(`### Ran Playwright code
\`\`\`js
// <internal code to handle "alert" dialog>
\`\`\`
### Page state
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot:
\`\`\`yaml
- button "Button"`);
});

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

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

141
tests/files.spec.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* 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 [active] [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');
}
{
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, mcpMode }, 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, mcpMode }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
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

@@ -14,48 +14,86 @@
* limitations under the License.
*/
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 { spawn } from 'child_process';
import { TestServer } from './testserver/index.ts';
type Fixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[] }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Stream } from 'stream';
export type TestOptions = {
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
};
export const test = baseTest.extend<Fixtures>({
type CDPServer = {
endpoint: string;
start: () => Promise<BrowserContext>;
};
type TestFixtures = {
client: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string;
cdpServer: CDPServer;
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
};
type WorkerFixtures = {
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => {
await use(await startClient());
const { client } = await startClient();
await use(client);
},
visionClient: async ({ startClient }, use) => {
await use(await startClient({ args: ['--vision'] }));
},
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
startClient: async ({ }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
let client: StdioClientTransport | undefined;
use(async options => {
const args = ['--headless', '--user-data-dir', userDataDir];
await use(async options => {
const args: string[] = [];
if (userDataDir)
args.push('--user-data-dir', userDataDir);
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
args.push('--headless');
if (mcpBrowser)
args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
args.push(`--config=${path.relative(configDir, configFile)}`);
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const { transport, stderr } = await createTransport(args, mcpMode);
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
process.stderr.write(data);
stderrBuffer += data.toString();
});
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
return client;
return { client, stderr: () => stderrBuffer };
});
await client?.close();
@@ -67,30 +105,97 @@ export const test = baseTest.extend<Fixtures>({
await browserServer.close();
},
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const executablePath = chromium.executablePath();
const browserProcess = spawn(executablePath, [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
let browserContext: BrowserContext | undefined;
const port = 3200 + test.info().parallelIndex;
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
channel: mcpBrowser,
headless: true,
args: [
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
`data:text/html,hello world`,
], {
stdio: 'pipe',
],
});
await new Promise<void>(resolve => {
browserProcess.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
return browserContext;
}
});
});
await use(`http://localhost:${port}`);
browserProcess.kill();
await browserContext?.close();
},
mcpHeadless: async ({ headless }, use) => {
await use(headless);
},
mcpBrowser: ['chrome', { option: true }],
mcpMode: [undefined, { option: true }],
_workerServers: [async ({ }, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port);
const httpsPort = port + 1;
const httpsServer = await TestServer.createHTTPS(httpsPort);
await use({ server, httpsServer });
await Promise.all([
server.stop(),
httpsServer.stop(),
]);
}, { scope: 'worker' }],
server: async ({ _workerServers }, use) => {
_workerServers.server.reset();
await use(_workerServers.server);
},
httpsServer: async ({ _workerServers }, use) => {
_workerServers.httpsServer.reset();
await use(_workerServers.httpsServer);
},
});
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
const transport = new StdioClientTransport({
command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
});
return {
transport,
stderr: transport.stderr,
};
}
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
stderr: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
return {
transport,
stderr: transport.stderr!,
};
}
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({
@@ -121,17 +226,14 @@ export const expect = baseExpect.extend({
};
},
toContainTextContent(response: Response, content: string | string[]) {
toContainTextContent(response: Response, content: 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++) {
const texts = (response.content as any).map(c => c.text).join('\n');
if (isNot)
expect(texts[i]).not.toContain(content[i]);
expect(texts).not.toContain(content);
else
expect(texts[i]).toContain(content[i]);
}
expect(texts).toContain(content);
} catch (e) {
return {
pass: isNot,
@@ -144,3 +246,7 @@ 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);
}

50
tests/headed.spec.ts Normal file
View File

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

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

@@ -0,0 +1,259 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test as baseTest, expect } from './fixtures.js';
import type { Config } from '../config.d.ts';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let cp: ChildProcess | undefined;
const userDataDir = testInfo.outputPath('user-data-dir');
await use(async (options?: { args?: string[], noPort?: boolean }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../cli.js'),
...(options?.noPort ? [] : ['--port=0']),
'--user-data-dir=' + userDataDir,
...(mcpHeadless ? ['--headless'] : []),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stderr = '';
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stderr: () => stderr };
});
cp?.kill('SIGTERM');
},
});
test('http transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport (config)', async ({ serverEndpoint }) => {
const config: Config = {
server: {
port: 0,
}
};
const configFile = test.info().outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('http transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
/**
* src/client/streamableHttp.ts
* Clients that no longer need a particular session
* (e.g., because the user is leaving the client application) SHOULD send an
* HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly
* terminate the session.
*/
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
}).toPass();
});
test('http transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport3 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client3 = new Client({ name: 'test', version: '1.0.0' });
await client3.connect(transport3);
await client3.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await transport3.terminateSession();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(3);
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
}).toPass();
});
test('http transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport1.terminateSession();
await client1.close();
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await transport2.terminateSession();
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete http session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
}).toPass();
});
test('http transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
const response = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response.isError).toBe(true);
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
await client1.close();
await client2.close();
});
test('http transport (default)', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
@@ -24,20 +24,21 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\`
`);
- generic [active] [ref=e1]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [active] [ref=f1e1]:
- button "World" [ref=f1e2]
- main [ref=f1e3]:
- iframe [ref=f1e4]:
- paragraph [ref=f2e2]: Nested
\`\`\``);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
ref: 'f1e2',
},
})).toContainTextContent('Clicked "World"');
})).toContainTextContent(`// Click World`);
});

24
tests/install.spec.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* 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_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`No open pages available.`);
});

View File

@@ -14,44 +14,144 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
test('test reopen browser', async ({ client }) => {
import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient();
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
name: 'browser_close',
})).toHaveTextContent('Page closed');
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: 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 }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
test('executable path', async ({ startClient, server }) => {
const { client } = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
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`);
});

28
tests/library.spec.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 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 'node:fs/promises';
import child_process from 'node:child_process';
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
const file = testInfo.outputPath('main.cjs');
await fs.writeFile(file, `
import('@playwright/mcp')
.then(playwrightMCP => playwrightMCP.createConnection())
.then(() => console.log('OK'));
`);
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
});

45
tests/network.spec.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* 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_network_requests', async ({ client, server }) => {
server.setContent('/', `
<button onclick="fetch('/json')">Click me</button>
`, 'text/html');
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me button',
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

@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
test('save as pdf unavailable', async ({ startClient }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => {
const { client } = await startClient();
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
@@ -30,26 +30,54 @@ test('save as pdf unavailable', async ({ startClient }) => {
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client }) => {
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output'), capabilities: ['pdf'] },
});
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: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
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, capabilities: ['pdf'] },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [active] [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

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

252
tests/screenshot.spec.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* 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 (fullPage: true)', 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',
arguments: { fullPage: true },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`Screenshot full page and save it as`),
type: 'text',
},
],
});
});
test('browser_take_screenshot (fullPage with element should error)', 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]`);
const result = await client.callTool({
name: 'browser_take_screenshot',
arguments: {
fullPage: true,
element: 'hello button',
ref: 'e1',
},
});
expect(result.isError).toBe(true);
expect(result.content?.[0]?.text).toContain('fullPage cannot be used with element screenshots');
});

78
tests/session.spec.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
test('check that session is saved', async ({ startClient, server, mcpMode }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-session', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
// Check that session file exists
const files = fs.readdirSync(outputDir);
const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml'));
expect(sessionFiles.length).toBe(1);
// Check session file content
const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8');
expect(sessionContent).toContain('- browser_navigate:');
expect(sessionContent).toContain('params:');
expect(sessionContent).toContain('url: ' + server.HELLO_WORLD);
expect(sessionContent).toContain('snapshot:');
});
test('check that session includes multiple tool calls', async ({ startClient, server, mcpMode }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-session', `--output-dir=${outputDir}`],
});
// Navigate to a page
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
// Take a snapshot
await client.callTool({
name: 'browser_snapshot',
arguments: {},
});
// Check that session file exists and contains both calls
const files = fs.readdirSync(outputDir);
const sessionFiles = files.filter(f => f.startsWith('session') && f.endsWith('.yml'));
expect(sessionFiles.length).toBe(1);
const sessionContent = fs.readFileSync(path.join(outputDir, sessionFiles[0]), 'utf8');
expect(sessionContent).toContain('- browser_navigate:');
expect(sessionContent).toContain('- browser_snapshot:');
// Check that snapshot files exist
const snapshotFiles = files.filter(f => f.includes('snapshot.yaml'));
expect(snapshotFiles.length).toBeGreaterThan(0);
});

View File

@@ -14,29 +14,223 @@
* limitations under the License.
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
import fs from 'node:fs';
import url from 'node:url';
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.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]);
}));
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
return { url: new URL(url), stderr: () => stderr };
});
cp?.kill('SIGTERM');
},
});
test('sse transport', async ({ serverEndpoint }) => {
const { url } = await serverEndpoint();
const transport = new SSEClientTransport(new URL('/sse', url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
});
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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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(new URL('/sse', 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();
});

View File

@@ -14,9 +14,7 @@
* limitations under the License.
*/
import { chromium } from 'playwright';
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -29,32 +27,48 @@ async function createTab(client: Client, title: string, body: string) {
});
}
test('create new tab', async ({ client }) => {
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
Open tabs:
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 0: (current) [] (about:blank)`);
});
Current tab:
test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
})).toHaveTextContent(`### Open tabs
- 0: [] (about:blank)
- 1: (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')).toContainTextContent(`
### Open tabs
- 0: [] (about:blank)
- 1: (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
- Page Snapshot:
\`\`\`yaml
- text: Body one
- generic [active] [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
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>)
expect(await createTab(client, 'Tab two', 'Body two')).toContainTextContent(`
### Open tabs
- 0: [] (about:blank)
- 1: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
Current tab:
### Current tab
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
- Page Title: Tab two
- Page Snapshot
- Page Snapshot:
\`\`\`yaml
- text: Body two
- generic [active] [ref=e1]: Body two
\`\`\``);
});
@@ -64,20 +78,25 @@ test('select tab', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_select',
arguments: {
index: 2,
index: 1,
},
})).toHaveTextContent(`
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>)
### Ran Playwright code
\`\`\`js
// <internal code to select tab 1>
\`\`\`
Current tab:
### Open tabs
- 0: [] (about:blank)
- 1: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
- 2: [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
- Page Snapshot:
\`\`\`yaml
- text: Body one
- generic [active] [ref=e1]: Body one
\`\`\``);
});
@@ -87,33 +106,35 @@ test('close tab', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_close',
arguments: {
index: 3,
index: 2,
},
})).toHaveTextContent(`
Open tabs:
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
### Ran Playwright code
\`\`\`js
// <internal code to close tab 2>
\`\`\`
Current tab:
### Open tabs
- 0: [] (about:blank)
- 1: (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
- Page Snapshot:
\`\`\`yaml
- text: Body one
- generic [active] [ref=e1]: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
const browser = await chromium.connectOverCDP(cdpEndpoint);
const [context] = browser.contexts();
const pages = context.pages();
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=${cdpEndpoint}`] });
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<title>Title</title><body>Body</body>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(pages.length).toBe(1);

29
tests/testserver/cert.pem Normal file
View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCjCCAvKgAwIBAgIULU/gkDm8IqC7PG8u3RID0AYyP6gwDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MB4XDTIzMDgxMDIyNTc1MFoX
DTMzMDgwNzIyNTc1MFowGjEYMBYGA1UEAwwPcGxheXdyaWdodC10ZXN0MIICIjAN
BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArbS99qjKcnHr5G0Zc2xhDaOZnjQv
Fbiqxf/nbXt/7WaqryzpVKu7AT1ainBvuPEo7If9DhVnfF//2pGl0gbU31OU4/mr
ymQmczGEyZvOBDsZhtCif54o5OoO0BjhODNT8OWec9RT87n6RkH58MHlOi8xsPxQ
9n5U1CN/h2DyQF3aRKunEFCgtwPKWSjG+J/TAI9i0aSENXPiR8wjTrjg79s8Ehuj
NN8Wk6rKLU3sepG3GIMID5vLsVa2t9xqn562sP95Ee+Xp2YX3z7oYK99QCJdzacw
alhMHob1GCEKjDyxsD2IFRi7Dysiutfyzy3pMo6NALxFrwKVhWX0L4zVFIsI6JlV
dK8dHmDk0MRSqgB9sWXvEfSTXADEe8rncFSFpFz4Z8RNLmn5YSzQJzokNn41DUCP
dZTlTkcGTqvn5NqoY4sOV8rkFbgmTcqyijV/sebPjxCbJNcNmaSWa9FJ5IjRTpzM
38wLmxn+eKGK68n2JB3P7JP6LtsBShQEpXAF3rFfyNsP1bjquvGZVSjV8w/UwPE4
kV5eq3j3D4913Zfxvzjp6PEmhStG0EQtIXvx/TRoYpaNWypIgZdbkZQp1HUIQL15
D2Web4nazP3so1FC3ZgbrJZ2ozoadjLMp49NcSFdh+WRyVKuo0DIqR0zaiAzzf2D
G1q7TLKimM3XBMUCAwEAAaNIMEYwCQYDVR0TBAIwADALBgNVHQ8EBAMCBeAwLAYD
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
SIb3DQEBCwUAA4ICAQAvC5M1JFc21WVSLPvE2iVbt4HmirO3EENdDqs+rTYG5VJG
iE5ZuI6h/LjS5ptTfKovXQKaMr3pwp1pLMd/9q+6ZR1Hs9Z2wF6OZan4sb0uT32Y
1KGlj86QMiiSLdrJ/1Z9JHskHYNCep1ZTsUhGk0qqiNv+G3K2y7ZpvrT/xlnYMth
KLTuSVUwM8BBEPrCRLoXuaEy0LnvMvMVepIfP8tnMIL6zqmj3hXMPe4r4OFV/C5o
XX25bC7GyuPWIRYn2OWP92J1CODZD1rGRoDtmvqrQpHdeX9RYcKH0ZLZoIf5L3Hf
pPUtVkw3QGtjvKeG3b9usxaV9Od2Z08vKKk1PRkXFe8gqaeyicK7YVIOMTSuspAf
JeJEHns6Hg61Exbo7GwdX76xlmQ/Z43E9BPHKgLyZ9WuJ0cysqN4aCyvS9yws9to
ki7iMZqJUsmE2o09n9VaEsX6uQANZtLjI9wf+IgJuueDTNrkzQkhU7pbaPMsSG40
AgGY/y4BR0H8sbhNnhqtZH7RcXV9VCJoPBAe+YiuXRiXyZHWxwBRyBE3e7g4MKHg
hrWtaWUAs7gbavHwjqgU63iVItDSk7t4fCiEyObjK09AaNf2DjjaSGf8YGza4bNy
BjYinYJ6/eX//gp+abqfocFbBP7D9zRDgMIbVmX/Ey6TghKiLkZOdbzcpO4Wgg==
-----END CERTIFICATE-----

172
tests/testserver/index.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
* Modifications copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import url from 'node:url';
import http from 'http';
import https from 'https';
import path from 'path';
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;
private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
private _csp = new Map<string, string>();
private _extraHeaders = new Map<string, object>();
private _requestSubscribers = new Map<string, Promise<any>>();
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
readonly HELLO_WORLD: string;
static async create(port: number): Promise<TestServer> {
const server = new TestServer(port);
await new Promise(x => server._server.once('listening', x));
return server;
}
static async createHTTPS(port: number): Promise<TestServer> {
const server = new TestServer(port, {
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
passphrase: 'aaaa',
});
await new Promise(x => server._server.once('listening', x));
return server;
}
constructor(port: number, sslOptions?: object) {
if (sslOptions)
this._server = https.createServer(sslOptions, this._onRequest.bind(this));
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.listen(port);
this.debugServer = debug('pw:testserver');
const cross_origin = '127.0.0.1';
const same_origin = 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
}
setCSP(path: string, csp: string) {
this._csp.set(path, csp);
}
setExtraHeaders(path: string, object: Record<string, string>) {
this._extraHeaders.set(path, object);
}
async stop() {
this.reset();
await new Promise(x => this._server.close(x));
}
route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
this._routes.set(path, handler);
}
setContent(path: string, content: string, mimeType: string) {
this.route(path, (req, res) => {
res.writeHead(200, { 'Content-Type': mimeType });
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
});
}
redirect(from: string, to: string) {
this.route(from, (req, res) => {
const headers = this._extraHeaders.get(req.url!) || {};
res.writeHead(302, { ...headers, location: to });
res.end();
});
}
waitForRequest(path: string): Promise<http.IncomingMessage> {
let promise = this._requestSubscribers.get(path);
if (promise)
return promise;
let fulfill, reject;
promise = new Promise((f, r) => {
fulfill = f;
reject = r;
});
promise[fulfillSymbol] = fulfill;
promise[rejectSymbol] = reject;
this._requestSubscribers.set(path, promise);
return promise;
}
reset() {
this._routes.clear();
this._csp.clear();
this._extraHeaders.clear();
this._server.closeAllConnections();
const error = new Error('Static Server has been reset');
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null, error);
this._requestSubscribers.clear();
this.setContent('/favicon.ico', '', 'image/x-icon');
this.setContent('/', ``, 'text/html');
this.setContent('/hello-world', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
}
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
request.on('error', error => {
if ((error as any).code === 'ECONNRESET')
response.end();
else
throw error;
});
(request as any).postBody = new Promise(resolve => {
const chunks: Buffer[] = [];
request.on('data', chunk => {
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks)));
});
const path = request.url || '/';
this.debugServer(`request ${request.method} ${path}`);
// Notify request subscriber.
if (this._requestSubscribers.has(path)) {
this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
this._requestSubscribers.delete(path);
}
const handler = this._routes.get(path);
if (handler) {
handler.call(null, request, response);
} else {
response.writeHead(404);
response.end();
}
}
}

52
tests/testserver/key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCttL32qMpycevk
bRlzbGENo5meNC8VuKrF/+dte3/tZqqvLOlUq7sBPVqKcG+48Sjsh/0OFWd8X//a
kaXSBtTfU5Tj+avKZCZzMYTJm84EOxmG0KJ/nijk6g7QGOE4M1Pw5Z5z1FPzufpG
QfnwweU6LzGw/FD2flTUI3+HYPJAXdpEq6cQUKC3A8pZKMb4n9MAj2LRpIQ1c+JH
zCNOuODv2zwSG6M03xaTqsotTex6kbcYgwgPm8uxVra33Gqfnraw/3kR75enZhff
Puhgr31AIl3NpzBqWEwehvUYIQqMPLGwPYgVGLsPKyK61/LPLekyjo0AvEWvApWF
ZfQvjNUUiwjomVV0rx0eYOTQxFKqAH2xZe8R9JNcAMR7yudwVIWkXPhnxE0uaflh
LNAnOiQ2fjUNQI91lOVORwZOq+fk2qhjiw5XyuQVuCZNyrKKNX+x5s+PEJsk1w2Z
pJZr0UnkiNFOnMzfzAubGf54oYrryfYkHc/sk/ou2wFKFASlcAXesV/I2w/VuOq6
8ZlVKNXzD9TA8TiRXl6rePcPj3Xdl/G/OOno8SaFK0bQRC0he/H9NGhilo1bKkiB
l1uRlCnUdQhAvXkPZZ5vidrM/eyjUULdmBuslnajOhp2Msynj01xIV2H5ZHJUq6j
QMipHTNqIDPN/YMbWrtMsqKYzdcExQIDAQABAoICAGqXttpdyZ1g+vg5WpzRrNzJ
v8KtExepMmI+Hq24U1BC6AqG7MfgeejQ1XaOeIBsvEgpSsgRqmdQIZjmN3Mibg59
I6ih1SFlQ5L8mBd/XHSML6Xi8VSOoVmXp29bVRk/pgr1XL6HVN0DCumCIvXyhc+m
lj+dFbGs5DEpd2CDxSRqcz4gd2wzjevAj7MWqsJ2kOyPEHzFD7wdWIXmZuQv3xhQ
2BPkkcon+5qx+07BupOcR1brUU8Cs4QnSgiZYXSB2GnU215+P/mhVJTR7ZcnGRz5
+cXxCmy3sj4pYs1juS1FMWSM3azUeDVeqvks+vrXmXpEr5H79mbmlwo8/hMPwNDO
07HRZwa8T01aT9EYVm0lIOYjMF/2f6j6cu2apJtjXICOksR2HefRBVXQirOxRHma
9XAYfNkZ/2164ZbgFmJv9khFnegPEuth9tLVdFIeGSmsG0aX9tH63zGT2NROyyLc
QXPqsDl2CxCYPRs2oiGkM9dnfP1wAOp96sq42GIuN7ykfqfRnwAIvvnLKvyCq1vR
pIno3CIX6vnzt+1/Hrmv13b0L6pJPitpXwKWHv9zJKBTpN8HEzP3Qmth2Ef60/7/
CBo1PVTd1A6zcU7816flg7SCY+Vk+OxVHV3dGBIIqN9SfrQ8BPcOl6FNV5Anbrnv
CpSw+LzH9n5xympDnk0BAoIBAQDjenvDfCnrNVeqx8+sYaYey4/WPVLXOQhREvRY
oOtX9eqlNSi20+Wl+iuXmyj8wdHrDET7rfjCbpDQ7u105yzLw4gy4qIRDKZ1nE45
YX+tm8mZgBqRnTp0DoGOArqmp3IKXJtUYmpbTz9tOfY7Usb1o1epb4winEB+Pl+8
mgXOEo8xvWBzKeRA7tE73V64Mwbvbo9Ff2EguhXweQP29yBkEjT4iViayuHUmyPt
hOVSMj2oFQuQGPdhAk7nUXojSGK/Zas/AGpH9CHH9De0h4m08vd3oM4vj0HwzgjU
Co9aRa9SAH7EiaocOTcjDRPxWdZPHhxmrVRIYlF0MNmOAkXJAoIBAQDDfEqu4sNi
pq74VXVatQqhzCILZo+o48bdgEjF7mF99mqPj8rwIDrEoEriDK861kenLc3vWKRY
5wh1iX3S896re9kUMoxx6p4heYTcsOJ9BbkcpT8bJPZx9gBJb4jJENeVf1exf6sG
RhFnulpzReRRaUjX2yAkyUPfc8YcUt+Nalrg+2W0fzeLCUpABCAcj2B1Vv7qRZHj
oEtlCV5Nz+iMhrwIa16g9c8wGt5DZb4PI+VIJ6EYkdsjhgqIF0T/wDq9/habGBPo
mHN+/DX3hCJWN2QgoVGJskHGt0zDMgiEgXfLZ2Grl02vQtq+mW2O2vGVeUd9Y5Ew
RUiY4bSRTrUdAoIBAHxL1wiP9c/By+9TUtScXssA681ioLtdPIAgXUd4VmAvzVEM
ZPzRd/BjbCJg89p4hZ1rjN4Ax6ZmB9dCVpnEH6QPaYJ0d53dTa+CAvQzpDJWp6eq
adobEW+M5ZmVQCwD3rpus6k+RWMzQDMMstDjgDeEU0gP3YCj5FGW/3TsrDNXzMqe
8e67ey9Hzyho43K+3xFBViPhYE8jnw1Q8quliRtlH3CWi8W5CgDD7LPCJBPvw+Tt
6u2H1tQ5EKgwyw4wZVSz1wiLz4cVjMfXWADa9pHbGQFS6pbuLlfIHObQBliLLysd
ficiGcNmOAx8/uKn9gQxLc+k8iLDJkLY1mdUMpECggEAJLl87k37ltTpmg2z9k58
qNjIrIugAYKJIaOwCD84YYmhi0bgQSxM3hOe/ciUQuFupKGeRpDIj0sX87zYvoDC
HEUwCvNUHzKMco15wFwasJIarJ7+tALFqbMlaqZhdCSN27AIsXfikVMogewoge9n
bUPyQ1sPNtn4vknptfh7tv18BTg1aytbK+ua31vnDHaDEIg/a5OWTMUYZOrVpJii
f4PwX0SMioCjY84oY1EB26ZKtLt9MDh2ir3rzJVSiRl776WEaa6kTtYVHI4VNWLF
cJ0HWnnz74JliQd2jFUh9IK+FqBdYPcTyREuNxBr3KKVMBeQrqW96OubL913JrU6
oQKCAQEA0yzORUouT0yleWs7RmzBlT9OLD/3cBYJMf/r1F8z8OQjB8fU1jKbO1Cs
q4l+o9FmI+eHkgc3xbEG0hahOFWm/hTTli9vzksxurgdawZELThRkK33uTU9pKla
Okqx3Ru/iMOW2+DQUx9UB+jK+hSAgq4gGqLeJVyaBerIdLQLlvqxrwSxjvvj+wJC
Y66mgRzdCi6VDF1vV0knCrQHK6tRwcPozu/k4zjJzvdbMJnKEy2S7Vh6vO8lEPJm
MQtaHPpmz+F4z14b9unNIiSbHO60Q4O+BwIBCzxApQQbFg63vBLYYwEMRd7hh92s
ZkZVSOEp+sYBf/tmptlKr49nO+dTjQ==
-----END PRIVATE KEY-----

19
tests/testserver/san.cnf Normal file
View File

@@ -0,0 +1,19 @@
# openssl req -new -x509 -days 3650 -key key.pem -out cert.pem -config san.cnf -extensions v3_req
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = playwright-test
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = ::1

35
tests/trace.spec.ts Normal file
View File

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

85
tests/wait.spec.ts Normal file
View File

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

38
tests/webdriver.spec.ts Normal file
View File

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

4
tsconfig.all.json Normal file
View File

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

View File

@@ -1,12 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "nodenext",
"strict": true,
"module": "CommonJS",
"outDir": "./lib"
"module": "NodeNext",
"rootDir": "src",
"outDir": "./lib",
"resolveJsonModule": true
},
"include": [
"src",

6
utils/generate-links.js Normal file
View File

@@ -0,0 +1,6 @@
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["@playwright/mcp@latest"] });
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
console.log(urlForGithub);

152
utils/update-readme.js Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zodToJsonSchema from 'zod-to-json-schema'
import { execSync } from 'node:child_process';
import { allTools } from '../lib/tools.js';
const capabilities = {
'core': 'Core automation',
'core-tabs': 'Tab management',
'core-install': 'Browser installation',
'vision': 'Coordinate-based (opt-in via --caps=vision)',
'pdf': 'PDF generation (opt-in via --caps=pdf)',
};
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
/**
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
* @returns {string[]}
*/
function formatToolForReadme(tool) {
const lines = /** @type {string[]} */ ([]);
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
lines.push(``);
lines.push(`- **${tool.name}**`);
lines.push(` - Title: ${tool.title}`);
lines.push(` - Description: ${tool.description}`);
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
const requiredParams = inputSchema.required || [];
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
lines.push(` - Parameters:`);
Object.entries(inputSchema.properties).forEach(([name, param]) => {
const optional = !requiredParams.includes(name);
const meta = /** @type {string[]} */ ([]);
if (param.type)
meta.push(param.type);
if (optional)
meta.push('optional');
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
});
} else {
lines.push(` - Parameters: None`);
}
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
lines.push('');
return lines;
}
/**
* @param {string} content
* @param {string} startMarker
* @param {string} endMarker
* @param {string[]} generatedLines
* @returns {Promise<string>}
*/
async function updateSection(content, startMarker, endMarker, generatedLines) {
const startMarkerIndex = content.indexOf(startMarker);
const endMarkerIndex = content.indexOf(endMarker);
if (startMarkerIndex === -1 || endMarkerIndex === -1)
throw new Error('Markers for generated section not found in README');
return [
content.slice(0, startMarkerIndex + startMarker.length),
'',
generatedLines.join('\n'),
'',
content.slice(endMarkerIndex),
].join('\n');
}
/**
* @param {string} content
* @returns {Promise<string>}
*/
async function updateTools(content) {
console.log('Loading tool information from compiled modules...');
const generatedLines = /** @type {string[]} */ ([]);
for (const [capability, tools] of Object.entries(toolsByCapability)) {
console.log('Updating tools for capability:', capability);
generatedLines.push(`<details>\n<summary><b>${capability}</b></summary>`);
generatedLines.push('');
for (const tool of tools)
generatedLines.push(...formatToolForReadme(tool.schema));
generatedLines.push(`</details>`);
generatedLines.push('');
}
const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of tools generated section -->`;
return updateSection(content, startMarker, endMarker, generatedLines);
}
/**
* @param {string} content
* @returns {Promise<string>}
*/
async function updateOptions(content) {
console.log('Listing options...');
const output = execSync('node cli.js --help');
const lines = output.toString().split('\n');
const firstLine = lines.findIndex(line => line.includes('--version'));
lines.splice(0, firstLine + 1);
const lastLine = lines.findIndex(line => line.includes('--help'));
lines.splice(lastLine);
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of options generated section -->`;
return updateSection(content, startMarker, endMarker, [
'```',
'> npx @playwright/mcp@latest --help',
...lines,
'```',
]);
}
async function updateReadme() {
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const withTools = await updateTools(readmeContent);
const withOptions = await updateOptions(withTools);
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
console.log('README updated successfully');
}
updateReadme().catch(err => {
console.error('Error updating README:', err);
process.exit(1);
});