43 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
37973f0876 Fix misleading browser_close message by adding resultOverride
Co-authored-by: Skn0tt <14912729+Skn0tt@users.noreply.github.com>
2025-06-20 09:23:29 +00:00
copilot-swe-agent[bot]
a5ffc9671b Initial plan for issue 2025-06-20 09:13:21 +00: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
57 changed files with 4088 additions and 1096 deletions

View File

@@ -66,4 +66,4 @@ COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib 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) # Run in headless and only with chromium (other browsers need more dependencies not included in this image)
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"] ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]

622
README.md
View File

@@ -4,25 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Key Features ### Key Features
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input. - **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
- **LLM-friendly**: No vision models needed, operates purely on structured data. - **LLM-friendly**. No vision models needed, operates purely on structured data.
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches. - **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
### Use Cases ### Requirements
- Node.js 18 or newer
- Web navigation and form-filling - VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
- Data extraction from structured content
- Automated testing driven by LLMs
- General-purpose browser interaction for agents
<!-- <!--
// Generate using: // Generate using:
node utils/generate-links.js node utils/generate-links.js
--> -->
[<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) ### Getting started
### Example config First, install the Playwright MCP server with your client. A typical configuration looks like this:
```js ```js
{ {
@@ -37,20 +34,12 @@ node utils/generate-links.js
} }
``` ```
### Table of Contents [<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](#installation-in-vs-code)
- [Command line](#command-line)
- [User profile](#user-profile)
- [Configuration file](#configuration-file)
- [Running on Linux](#running-on-linux)
- [Docker](#docker)
- [Programmatic usage](#programmatic-usage)
- [Tool modes](#tool-modes)
### Installation in VS Code <details><summary><b>Install in VS Code</b></summary>
You can install the Playwright MCP server using the VS Code CLI: You can also install the Playwright MCP server using the VS Code CLI:
```bash ```bash
# For VS Code # For VS Code
@@ -58,45 +47,214 @@ code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@la
``` ```
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code. After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
</details>
### Command line <details>
<summary><b>Install in Cursor</b></summary>
The Playwright MCP server supports the following command-line options: #### Click the button to install:
- `--browser <browser>`: Browser or chrome channel to use. Possible values: [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
- `chrome`, `firefox`, `webkit`, `msedge`
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev` #### Or install manually:
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome` 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`.
- `--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 ```js
- `--executable-path <path>`: Path to the browser executable {
- `--headless`: Run browser in headless mode (headed by default) "mcpServers": {
- `--device`: Emulate mobile device "playwright": {
- `--user-data-dir <path>`: Path to the user data directory "command": "npx",
- `--port <port>`: Port to listen on for SSE transport "args": [
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. "@playwright/mcp@latest"
- `--allowed-origins <origins>`: Semicolon-separated list of origins to allow the browser to request. Default is to allow all. Origins matching both `--allowed-origins` and `--blocked-origins` will be blocked. ]
- `--blocked-origins <origins>`: Semicolon-separated list of origins to block the browser to request. Origins matching both `--allowed-origins` and `--blocked-origins` will be blocked. }
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default) }
- `--output-dir`: Directory for output files }
- `--config <path>`: Path to the configuration file ```
</details>
<details>
<summary><b>Install in Windsurf</b></summary>
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Desktop</b></summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Code</b></summary>
Use the Claude Code CLI to add the Playwright MCP server:
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
</details>
<details>
<summary><b>Install in Qodo Gen</b></summary>
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
Click <code>Save</code>.
</details>
### Configuration
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 -->
```
> npx @playwright/mcp@latest --help
--allowed-origins <origins> semicolon-separated list of origins to allow the
browser to request. Default is to allow all.
--blocked-origins <origins> semicolon-separated list of origins to block the
browser from requesting. Blocklist is evaluated
before allowlist. If used without the allowlist,
requests not matching the blocklist are still
allowed.
--block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge.
--browser-agent <endpoint> Use browser agent (experimental).
--caps <caps> comma-separated list of capabilities to enable,
possible values: tabs, pdf, history, wait, files,
install. Default is all.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--executable-path <path> path to the browser executable.
--headless run browser in headless mode, headed by default
--host <host> host to bind server to. Default is localhost. Use
0.0.0.0 to bind to all interfaces.
--ignore-https-errors ignore https errors
--isolated keep the browser profile in memory, do not save
it to disk.
--image-responses <mode> whether to send image responses to the client.
Can be "allow", "omit", or "auto". Defaults to
"auto", which sends images if the client can
display them.
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--storage-state <path> path to the storage state file for isolated
sessions.
--user-agent <ua string> specify user agent string
--user-data-dir <path> path to the user data directory. If not
specified, a temporary directory will be created.
--viewport-size <size> specify browser viewport size in pixels, for
example "1280, 720"
--vision Run server that uses screenshots (Aria snapshots
are used by default)
```
<!--- End of options generated section -->
### User profile ### User profile
Playwright MCP will launch the browser with the new profile, located at You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
``` **Persistent profile**
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS 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.
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux 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
``` ```
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. **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
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--isolated",
"--storage-state={path/to/storage.json}"
]
}
}
}
```
### Configuration file ### Configuration file
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format: 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 ```typescript
{ {
@@ -105,6 +263,9 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
// Browser type to use (chromium, firefox, or webkit) // Browser type to use (chromium, firefox, or webkit)
browserName?: 'chromium' | 'firefox' | '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 // Path to user data directory for browser profile persistence
userDataDir?: string; userDataDir?: string;
@@ -170,14 +331,9 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
noImageResponses?: boolean; noImageResponses?: boolean;
} }
``` ```
</details>
You can specify the configuration file using the `--config` command line option: ### Standalone MCP server
```bash
npx @playwright/mcp@latest --config path/to/config.json
```
### Running on Linux
When running headed browser on system w/o display or from worker processes of the IDEs, 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 SSE transport.
@@ -198,7 +354,8 @@ And then in MCP client config, set the `url` to the SSE endpoint:
} }
``` ```
### Docker <details>
<summary><b>Docker</b></summary>
**NOTE:** The Docker implementation only supports headless chromium at the moment. **NOTE:** The Docker implementation only supports headless chromium at the moment.
@@ -218,8 +375,10 @@ You can build the Docker image yourself.
``` ```
docker build -t mcr.microsoft.com/playwright/mcp . docker build -t mcr.microsoft.com/playwright/mcp .
``` ```
</details>
### Programmatic usage <details>
<summary><b>Programmatic usage</b></summary>
```js ```js
import http from 'http'; import http from 'http';
@@ -233,13 +392,14 @@ http.createServer(async (req, res) => {
// Creates a headless Playwright MCP server with SSE transport // Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } }); const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res); const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport); await connection.sever.connect(transport);
// ... // ...
}); });
``` ```
</details>
### Tool modes ### Tools
The tools are available in two modes: The tools are available in two modes:
@@ -265,10 +425,10 @@ To use Vision Mode, add the `--vision` flag when starting the server:
Vision Mode works best with the computer use models that are able to interact with elements using 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. X Y coordinate space, based on the provided screenshot.
<!--- Tools generated by update-readme.js -->
<!--- Generated by update-readme.js --> <details>
<summary><b>Interactions</b></summary>
### Snapshot-based Interactions
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -336,6 +496,80 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- 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**
</details>
<details>
<summary><b>Navigation</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate**
- Title: Navigate to a URL
- Description: Navigate to a URL
- Parameters:
- `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**
</details>
<details>
<summary><b>Resources</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_take_screenshot** - **browser_take_screenshot**
- Title: Take a 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. - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
@@ -346,7 +580,122 @@ X Y coordinate space, based on the provided screenshot.
- `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. - `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- Read-only: **true** - Read-only: **true**
### Vision-based Interactions <!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters:
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_network_requests**
- Title: List network requests
- Description: Returns all network requests since loading the page
- 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**
</details>
<details>
<summary><b>Utilities</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_install**
- Title: Install the browser specified in the config
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Title: Resize browser window
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
- Read-only: **true**
</details>
<details>
<summary><b>Tabs</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Title: List tabs
- Description: List browser tabs
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Title: Close a tab
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
- Read-only: **false**
</details>
<details>
<summary><b>Testing</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_generate_playwright_test**
- Title: Generate a Playwright test
- Description: Generate a Playwright test for given scenario
- Parameters:
- `name` (string): The name of the test
- `description` (string): The description of the test
- `steps` (array): The steps of the test
- Read-only: **true**
</details>
<details>
<summary><b>Vision mode</b></summary>
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -401,72 +750,6 @@ X Y coordinate space, based on the provided screenshot.
- `submit` (boolean, optional): Whether to submit entered text (press Enter after) - `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- Read-only: **false** - Read-only: **false**
### Tab Management
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Title: List tabs
- Description: List browser tabs
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Title: Open a new tab
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Title: Select a tab
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
- Read-only: **true**
<!-- 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**
### Navigation
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate**
- Title: Navigate to a URL
- Description: Navigate to a URL
- Parameters:
- `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**
### Keyboard
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key** - **browser_press_key**
@@ -476,46 +759,6 @@ X Y coordinate space, based on the provided screenshot.
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a` - `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false** - Read-only: **false**
### Console
<!-- 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**
### Files and Media
<!-- 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_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters:
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
### Utilities
<!-- 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 --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for** - **browser_wait_for**
@@ -529,20 +772,11 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize** - **browser_file_upload**
- Title: Resize browser window - Title: Upload files
- Description: Resize the browser window - Description: Upload one or multiple files
- Parameters: - Parameters:
- `width` (number): Width of the browser window - `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
- `height` (number): Height of the browser window
- Read-only: **true**
<!-- 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** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
@@ -555,25 +789,7 @@ X Y coordinate space, based on the provided screenshot.
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog. - `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
- Read-only: **false** - Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js --> </details>
- **browser_network_requests**
- Title: List network requests
- Description: Returns all network requests since loading the page
- Parameters: None
- Read-only: **true**
### Testing <!--- End of tools generated section -->
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_generate_playwright_test**
- Title: Generate a Playwright test
- Description: Generate a Playwright test for given scenario
- Parameters:
- `name` (string): The name of the test
- `description` (string): The description of the test
- `steps` (array): The steps of the test
- Read-only: **true**
<!--- End of generated section -->

19
config.d.ts vendored
View File

@@ -23,11 +23,21 @@ export type Config = {
* The browser to use. * The browser to use.
*/ */
browser?: { browser?: {
/**
* Use browser agent (experimental).
*/
browserAgent?: string;
/** /**
* The type of browser to use. * The type of browser to use.
*/ */
browserName?: 'chromium' | 'firefox' | 'webkit'; 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. * Path to a user data directory for browser profile persistence.
* Temporary directory is created by default. * Temporary directory is created by default.
@@ -89,6 +99,11 @@ export type Config = {
*/ */
vision?: boolean; vision?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/
saveTrace?: boolean;
/** /**
* The directory to save output files. * The directory to save output files.
*/ */
@@ -107,7 +122,7 @@ export type Config = {
}; };
/** /**
* Do not send image responses to the client. * 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.
*/ */
noImageResponses?: boolean; imageResponses?: 'allow' | 'omit' | 'auto';
}; };

View File

@@ -1,4 +1,4 @@
Generate test for scenario: Use Playwright tools to generate test for scenario:
## GitHub PR Checks Navigation Checklist ## GitHub PR Checks Navigation Checklist

344
extension/background.js Normal file
View File

@@ -0,0 +1,344 @@
/**
* 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.
*/
/**
* Simple Chrome Extension that pumps CDP messages between chrome.debugger and WebSocket
*/
// @ts-check
function debugLog(...args) {
const enabled = false;
if (enabled) {
console.log('[Extension]', ...args);
}
}
class TabShareExtension {
constructor() {
this.activeConnections = new Map(); // tabId -> connection info
// Remove page action click handler since we now use popup
chrome.tabs.onRemoved.addListener(this.onTabRemoved.bind(this));
// Handle messages from popup
chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
}
/**
* Handle messages from popup
* @param {any} message
* @param {chrome.runtime.MessageSender} sender
* @param {Function} sendResponse
*/
onMessage(message, sender, sendResponse) {
switch (message.type) {
case 'getStatus':
this.getStatus(message.tabId, sendResponse);
return true; // Will respond asynchronously
case 'connect':
this.connectTab(message.tabId, message.bridgeUrl).then(
() => sendResponse({ success: true }),
(error) => sendResponse({ success: false, error: error.message })
);
return true; // Will respond asynchronously
case 'disconnect':
this.disconnectTab(message.tabId).then(
() => sendResponse({ success: true }),
(error) => sendResponse({ success: false, error: error.message })
);
return true; // Will respond asynchronously
}
return false;
}
/**
* Get connection status for popup
* @param {number} requestedTabId
* @param {Function} sendResponse
*/
getStatus(requestedTabId, sendResponse) {
const isConnected = this.activeConnections.size > 0;
let activeTabId = null;
let activeTabInfo = null;
if (isConnected) {
const [tabId, connection] = this.activeConnections.entries().next().value;
activeTabId = tabId;
// Get tab info
chrome.tabs.get(tabId, (tab) => {
if (chrome.runtime.lastError) {
sendResponse({
isConnected: false,
error: 'Active tab not found'
});
} else {
sendResponse({
isConnected: true,
activeTabId,
activeTabInfo: {
title: tab.title,
url: tab.url
}
});
}
});
} else {
sendResponse({
isConnected: false,
activeTabId: null,
activeTabInfo: null
});
}
}
/**
* Connect a tab to the bridge server
* @param {number} tabId
* @param {string} bridgeUrl
*/
async connectTab(tabId, bridgeUrl) {
try {
debugLog(`Connecting tab ${tabId} to bridge at ${bridgeUrl}`);
// Attach chrome debugger
const debuggee = { tabId };
await chrome.debugger.attach(debuggee, '1.3');
if (chrome.runtime.lastError)
throw new Error(chrome.runtime.lastError.message);
const targetInfo = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'));
debugLog('Target info:', targetInfo);
// Connect to bridge server
const socket = new WebSocket(bridgeUrl);
const connection = {
debuggee,
socket,
tabId,
sessionId: `pw-tab-${tabId}`
};
await new Promise((resolve, reject) => {
socket.onopen = () => {
debugLog(`WebSocket connected for tab ${tabId}`);
// Send initial connection info to bridge
socket.send(JSON.stringify({
type: 'connection_info',
sessionId: connection.sessionId,
targetInfo: targetInfo?.targetInfo
}));
resolve(undefined);
};
socket.onerror = reject;
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
// Set up message handling
this.setupMessageHandling(connection);
// Store connection
this.activeConnections.set(tabId, connection);
// Update UI
chrome.action.setBadgeText({ tabId, text: '●' });
chrome.action.setBadgeBackgroundColor({ tabId, color: '#4CAF50' });
chrome.action.setTitle({ tabId, title: 'Disconnect from Playwright MCP' });
debugLog(`Tab ${tabId} connected successfully`);
} catch (error) {
debugLog(`Failed to connect tab ${tabId}:`, error.message);
await this.cleanupConnection(tabId);
// Show error to user
chrome.action.setBadgeText({ tabId, text: '!' });
chrome.action.setBadgeBackgroundColor({ tabId, color: '#F44336' });
chrome.action.setTitle({ tabId, title: `Connection failed: ${error.message}` });
throw error; // Re-throw for popup to handle
}
}
/**
* Set up bidirectional message handling between debugger and WebSocket
* @param {Object} connection
*/
setupMessageHandling(connection) {
const { debuggee, socket, tabId, sessionId: rootSessionId } = connection;
// WebSocket -> chrome.debugger
socket.onmessage = async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
debugLog('Error parsing message:', error);
socket.send(JSON.stringify({
error: {
code: -32700,
message: `Error parsing message: ${error.message}`
}
}));
return;
}
try {
debugLog('Received from bridge:', message);
const debuggerSession = { ...debuggee };
const sessionId = message.sessionId;
// Pass session id, unless it's the root session.
if (sessionId && sessionId !== rootSessionId)
debuggerSession.sessionId = sessionId;
// Forward CDP command to chrome.debugger
const result = await chrome.debugger.sendCommand(
debuggerSession,
message.method,
message.params || {}
);
// Send response back to bridge
const response = {
id: message.id,
sessionId,
result
};
if (chrome.runtime.lastError) {
response.error = {
code: -32000,
message: chrome.runtime.lastError.message,
};
}
socket.send(JSON.stringify(response));
} catch (error) {
debugLog('Error processing WebSocket message:', error);
const response = {
id: message.id,
sessionId: message.sessionId,
error: {
code: -32000,
message: error.message,
},
};
socket.send(JSON.stringify(response));
}
};
// chrome.debugger events -> WebSocket
const eventListener = (source, method, params) => {
if (source.tabId === tabId && socket.readyState === WebSocket.OPEN) {
// If the sessionId is not provided, use the root sessionId.
const event = {
sessionId: source.sessionId || rootSessionId,
method,
params,
};
debugLog('Forwarding CDP event:', event);
socket.send(JSON.stringify(event));
}
};
const detachListener = (source, reason) => {
if (source.tabId === tabId) {
debugLog(`Debugger detached from tab ${tabId}, reason: ${reason}`);
this.disconnectTab(tabId);
}
};
// Store listeners for cleanup
connection.eventListener = eventListener;
connection.detachListener = detachListener;
chrome.debugger.onEvent.addListener(eventListener);
chrome.debugger.onDetach.addListener(detachListener);
// Handle WebSocket close
socket.onclose = () => {
debugLog(`WebSocket closed for tab ${tabId}`);
this.disconnectTab(tabId);
};
socket.onerror = (error) => {
debugLog(`WebSocket error for tab ${tabId}:`, error);
this.disconnectTab(tabId);
};
}
/**
* Disconnect a tab from the bridge
* @param {number} tabId
*/
async disconnectTab(tabId) {
await this.cleanupConnection(tabId);
// Update UI
chrome.action.setBadgeText({ tabId, text: '' });
chrome.action.setTitle({ tabId, title: 'Share tab with Playwright MCP' });
debugLog(`Tab ${tabId} disconnected`);
}
/**
* Clean up connection resources
* @param {number} tabId
*/
async cleanupConnection(tabId) {
const connection = this.activeConnections.get(tabId);
if (!connection) return;
// Remove listeners
if (connection.eventListener) {
chrome.debugger.onEvent.removeListener(connection.eventListener);
}
if (connection.detachListener) {
chrome.debugger.onDetach.removeListener(connection.detachListener);
}
// Close WebSocket
if (connection.socket && connection.socket.readyState === WebSocket.OPEN) {
connection.socket.close();
}
// Detach debugger
try {
await chrome.debugger.detach(connection.debuggee);
} catch (error) {
// Ignore detach errors - might already be detached
}
this.activeConnections.delete(tabId);
}
/**
* Handle tab removal
* @param {number} tabId
*/
async onTabRemoved(tabId) {
if (this.activeConnections.has(tabId)) {
await this.cleanupConnection(tabId);
}
}
}
new TabShareExtension();

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 through CDP bridge",
"permissions": [
"debugger",
"activeTab",
"tabs",
"storage"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "Share tab with Playwright MCP",
"default_popup": "popup.html",
"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"
}
}

173
extension/popup.html Normal file
View File

@@ -0,0 +1,173 @@
<!--
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>
<meta charset="utf-8">
<style>
body {
width: 320px;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
margin: 0;
}
.header {
margin-bottom: 16px;
text-align: center;
}
.header h3 {
margin: 0 0 8px 0;
color: #333;
}
.section {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #555;
}
input[type="url"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
input[type="url"]:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.button {
background: #4CAF50;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
width: 100%;
margin-top: 8px;
}
.button:hover {
background: #45a049;
}
.button:disabled {
background: #cccccc;
cursor: not-allowed;
}
.button.disconnect {
background: #f44336;
}
.button.disconnect:hover {
background: #da190b;
}
.status {
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
text-align: center;
}
.status.connected {
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status.error {
background: #ffebee;
color: #c62828;
border: 1px solid #f44336;
}
.status.warning {
background: #fff3e0;
color: #ef6c00;
border: 1px solid #ff9800;
}
.tab-info {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
margin-bottom: 16px;
}
.tab-title {
font-weight: 500;
margin-bottom: 4px;
color: #333;
}
.tab-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.focus-button {
background: #2196F3;
margin-top: 8px;
}
.focus-button:hover {
background: #1976D2;
}
.small-text {
font-size: 12px;
color: #666;
margin-top: 8px;
}
</style>
</head>
<body>
<div class="header">
<h3>Playwright MCP Bridge</h3>
</div>
<div id="status-container"></div>
<div class="section">
<label for="bridge-url">Bridge Server URL:</label>
<input type="url" id="bridge-url" disabled placeholder="ws://localhost:9223/extension" />
<div class="small-text">Enter the WebSocket URL of your MCP bridge server</div>
</div>
<div id="action-container">
<button id="connect-btn" class="button">Share This Tab</button>
</div>
<script src="popup.js"></script>
</body>
</html>

228
extension/popup.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 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
/**
* Popup script for Playwright MCP Bridge extension
*/
class PopupController {
constructor() {
this.currentTab = null;
this.bridgeUrlInput = /** @type {HTMLInputElement} */ (document.getElementById('bridge-url'));
this.connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
this.statusContainer = /** @type {HTMLElement} */ (document.getElementById('status-container'));
this.actionContainer = /** @type {HTMLElement} */ (document.getElementById('action-container'));
this.init();
}
async init() {
// Get current tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
this.currentTab = tab;
// Load saved bridge URL
const result = await chrome.storage.sync.get(['bridgeUrl']);
const savedUrl = result.bridgeUrl || 'ws://localhost:9223/extension';
this.bridgeUrlInput.value = savedUrl;
this.bridgeUrlInput.disabled = false;
// Set up event listeners
this.bridgeUrlInput.addEventListener('input', this.onUrlChange.bind(this));
this.connectBtn.addEventListener('click', this.onConnectClick.bind(this));
// Update UI based on current state
await this.updateUI();
}
async updateUI() {
if (!this.currentTab?.id) return;
// Get connection status from background script
const response = await chrome.runtime.sendMessage({
type: 'getStatus',
tabId: this.currentTab.id
});
const { isConnected, activeTabId, activeTabInfo, error } = response;
if (!this.statusContainer || !this.actionContainer) return;
this.statusContainer.innerHTML = '';
this.actionContainer.innerHTML = '';
if (error) {
this.showStatus('error', `Error: ${error}`);
this.showConnectButton();
} else if (isConnected && activeTabId === this.currentTab.id) {
// Current tab is connected
this.showStatus('connected', 'This tab is currently shared with MCP server');
this.showDisconnectButton();
} else if (isConnected && activeTabId !== this.currentTab.id) {
// Another tab is connected
this.showStatus('warning', 'Another tab is already sharing the CDP session');
this.showActiveTabInfo(activeTabInfo);
this.showFocusButton(activeTabId);
} else {
// No connection
this.showConnectButton();
}
}
showStatus(type, message) {
const statusDiv = document.createElement('div');
statusDiv.className = `status ${type}`;
statusDiv.textContent = message;
this.statusContainer.appendChild(statusDiv);
}
showConnectButton() {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="connect-btn" class="button">Share This Tab</button>
`;
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
if (connectBtn) {
connectBtn.addEventListener('click', this.onConnectClick.bind(this));
// Disable if URL is invalid
const isValidUrl = this.bridgeUrlInput ? this.isValidWebSocketUrl(this.bridgeUrlInput.value) : false;
connectBtn.disabled = !isValidUrl;
}
}
showDisconnectButton() {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="disconnect-btn" class="button disconnect">Stop Sharing</button>
`;
const disconnectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('disconnect-btn'));
if (disconnectBtn) {
disconnectBtn.addEventListener('click', this.onDisconnectClick.bind(this));
}
}
showActiveTabInfo(tabInfo) {
if (!tabInfo) return;
const tabDiv = document.createElement('div');
tabDiv.className = 'tab-info';
tabDiv.innerHTML = `
<div class="tab-title">${tabInfo.title || 'Unknown Tab'}</div>
<div class="tab-url">${tabInfo.url || ''}</div>
`;
this.statusContainer.appendChild(tabDiv);
}
showFocusButton(activeTabId) {
if (!this.actionContainer) return;
this.actionContainer.innerHTML = `
<button id="focus-btn" class="button focus-button">Switch to Shared Tab</button>
`;
const focusBtn = /** @type {HTMLButtonElement} */ (document.getElementById('focus-btn'));
if (focusBtn) {
focusBtn.addEventListener('click', () => this.onFocusClick(activeTabId));
}
}
onUrlChange() {
if (!this.bridgeUrlInput) return;
const isValid = this.isValidWebSocketUrl(this.bridgeUrlInput.value);
const connectBtn = /** @type {HTMLButtonElement} */ (document.getElementById('connect-btn'));
if (connectBtn) {
connectBtn.disabled = !isValid;
}
// Save URL to storage
if (isValid) {
chrome.storage.sync.set({ bridgeUrl: this.bridgeUrlInput.value });
}
}
async onConnectClick() {
if (!this.bridgeUrlInput || !this.currentTab?.id) return;
const url = this.bridgeUrlInput.value.trim();
if (!this.isValidWebSocketUrl(url)) {
this.showStatus('error', 'Please enter a valid WebSocket URL');
return;
}
// Save URL to storage
await chrome.storage.sync.set({ bridgeUrl: url });
// Send connect message to background script
const response = await chrome.runtime.sendMessage({
type: 'connect',
tabId: this.currentTab.id,
bridgeUrl: url
});
if (response.success) {
await this.updateUI();
} else {
this.showStatus('error', response.error || 'Failed to connect');
}
}
async onDisconnectClick() {
if (!this.currentTab?.id) return;
const response = await chrome.runtime.sendMessage({
type: 'disconnect',
tabId: this.currentTab.id
});
if (response.success) {
await this.updateUI();
} else {
this.showStatus('error', response.error || 'Failed to disconnect');
}
}
async onFocusClick(activeTabId) {
try {
await chrome.tabs.update(activeTabId, { active: true });
window.close(); // Close popup after switching
} catch (error) {
this.showStatus('error', 'Failed to switch to tab');
}
}
isValidWebSocketUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
} catch {
return false;
}
}
}
// Initialize popup when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new PopupController();
});

7
index.d.ts vendored
View File

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

View File

@@ -15,5 +15,5 @@
* limitations under the License. * limitations under the License.
*/ */
import { createConnection } from './lib/index'; import { createConnection } from './lib/index.js';
export { createConnection }; export { createConnection };

411
package-lock.json generated
View File

@@ -1,17 +1,20 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.24", "version": "0.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.24", "version": "0.0.29",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746832516000", "debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.53.0",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"bin": { "bin": {
@@ -20,9 +23,12 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746832516000", "@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.26.1",
@@ -286,13 +292,12 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==", "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.53.0-alpha-1746832516000" "playwright": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -354,6 +359,27 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -361,6 +387,30 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -375,6 +425,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.13.10", "version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
@@ -385,6 +442,16 @@
"undici-types": "~6.20.0" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.27.0", "version": "8.27.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
@@ -834,16 +901,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"debug": "^4.4.0", "debug": "^4.4.0",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"iconv-lite": "^0.5.2", "iconv-lite": "^0.6.3",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.0", "qs": "^6.14.0",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
@@ -853,44 +920,6 @@
"node": ">=18" "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": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1156,12 +1185,12 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@@ -1224,16 +1253,6 @@
"node": ">= 0.8" "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": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -1769,46 +1788,45 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "5.0.1", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.0.1", "body-parser": "^2.2.0",
"content-disposition": "^1.0.0", "content-disposition": "^1.0.0",
"content-type": "~1.0.4", "content-type": "^1.0.5",
"cookie": "0.7.1", "cookie": "^0.7.1",
"cookie-signature": "^1.2.1", "cookie-signature": "^1.2.1",
"debug": "4.3.6", "debug": "^4.4.0",
"depd": "2.0.0", "encodeurl": "^2.0.0",
"encodeurl": "~2.0.0", "escape-html": "^1.0.3",
"escape-html": "~1.0.3", "etag": "^1.8.1",
"etag": "~1.8.1", "finalhandler": "^2.1.0",
"finalhandler": "^2.0.0", "fresh": "^2.0.0",
"fresh": "2.0.0", "http-errors": "^2.0.0",
"http-errors": "2.0.0",
"merge-descriptors": "^2.0.0", "merge-descriptors": "^2.0.0",
"methods": "~1.1.2",
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
"on-finished": "2.4.1", "on-finished": "^2.4.1",
"once": "1.4.0", "once": "^1.4.0",
"parseurl": "~1.3.3", "parseurl": "^1.3.3",
"proxy-addr": "~2.0.7", "proxy-addr": "^2.0.7",
"qs": "6.13.0", "qs": "^6.14.0",
"range-parser": "~1.2.1", "range-parser": "^1.2.1",
"router": "^2.0.0", "router": "^2.2.0",
"safe-buffer": "5.2.1",
"send": "^1.1.0", "send": "^1.1.0",
"serve-static": "^2.1.0", "serve-static": "^2.2.0",
"setprototypeof": "1.2.0", "statuses": "^2.0.1",
"statuses": "2.0.1", "type-is": "^2.0.1",
"type-is": "^2.0.0", "vary": "^1.1.2"
"utils-merge": "1.0.1",
"vary": "~1.1.2"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
@@ -1930,29 +1948,6 @@
"node": ">= 0.8" "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": { "node_modules/find-root": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -2037,7 +2032,6 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -2312,12 +2306,12 @@
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.5.2", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2928,15 +2922,6 @@
"node": ">= 8" "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": { "node_modules/metric-lcs": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
@@ -2958,6 +2943,21 @@
"node": ">=8.6" "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": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2968,12 +2968,12 @@
} }
}, },
"node_modules/mime-types": { "node_modules/mime-types": {
"version": "3.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "^1.53.0" "mime-db": "^1.54.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@@ -3003,9 +3003,9 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@@ -3298,12 +3298,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==", "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.53.0-alpha-1746832516000" "playwright-core": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3316,10 +3315,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==", "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@@ -3371,12 +3369,12 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "side-channel": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -3430,18 +3428,6 @@
"node": ">= 0.8" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3529,11 +3515,13 @@
} }
}, },
"node_modules/router": { "node_modules/router": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0", "is-promise": "^4.0.0",
"parseurl": "^1.3.3", "parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0" "path-to-regexp": "^8.0.0"
@@ -3661,19 +3649,18 @@
} }
}, },
"node_modules/send": { "node_modules/send": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.5", "debug": "^4.3.5",
"destroy": "^1.2.0",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"etag": "^1.8.1", "etag": "^1.8.1",
"fresh": "^0.5.2", "fresh": "^2.0.0",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"mime-types": "^2.1.35", "mime-types": "^3.0.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"range-parser": "^1.2.1", "range-parser": "^1.2.1",
@@ -3683,52 +3670,16 @@
"node": ">= 18" "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": { "node_modules/serve-static": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"parseurl": "^1.3.3", "parseurl": "^1.3.3",
"send": "^1.0.0" "send": "^1.2.0"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -4061,9 +4012,9 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -4211,15 +4162,6 @@
"punycode": "^2.1.0" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -4349,6 +4291,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"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": ">=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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.24", "version": "0.0.29",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -24,6 +24,8 @@
"ctest": "playwright test --project=chrome", "ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox", "ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit", "wtest": "playwright test --project=webkit",
"etest": "playwright test --project=chromium-extension",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib", "clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish" "npm-publish": "npm run clean && npm run build && npm run test && npm publish"
}, },
@@ -37,15 +39,21 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746832516000", "debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.53.0",
"ws": "^8.18.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746832516000", "@playwright/test": "1.53.0",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/chrome": "^0.0.315",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.26.1",

View File

@@ -29,8 +29,16 @@ export default defineConfig<TestOptions>({
{ name: 'chrome' }, { name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } }, { name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } }, { name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [], ...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: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } },
{ name: 'chromium-extension', use: { mcpBrowser: 'chromium', mcpMode: 'extension' } },
], ],
}); });

View File

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

197
src/browserServer.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-console */
import net from 'net';
import { program } from 'commander';
import playwright from 'playwright';
import { HttpServer } from './httpServer.js';
import { packageJSON } from './package.js';
import type http from 'http';
export type LaunchBrowserRequest = {
browserType: string;
userDataDir: string;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
};
export type BrowserInfo = {
browserType: string;
userDataDir: string;
cdpPort: number;
launchOptions: playwright.LaunchOptions;
contextOptions: playwright.BrowserContextOptions;
error?: string;
};
type BrowserEntry = {
browser?: playwright.Browser;
info: BrowserInfo;
};
class BrowserServer {
private _server = new HttpServer();
private _entries: BrowserEntry[] = [];
constructor() {
this._setupExitHandler();
}
async start(port: number) {
await this._server.start({ port });
this._server.routePath('/json/list', (req, res) => {
this._handleJsonList(res);
});
this._server.routePath('/json/launch', async (req, res) => {
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
});
this._setEntries([]);
}
private _handleJsonList(res: http.ServerResponse) {
const list = this._entries.map(browser => browser.info);
res.end(JSON.stringify(list));
}
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
const request = await readBody<LaunchBrowserRequest>(req);
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
if (!info || info.error)
info = await this._newBrowser(request);
res.end(JSON.stringify(info));
}
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
const cdpPort = await findFreePort();
(request.launchOptions as any).cdpPort = cdpPort;
const info: BrowserInfo = {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
};
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
...request.launchOptions,
...request.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).then(context => {
return { browser: context.browser()!, error: undefined };
}).catch(error => {
return { browser: undefined, error: error.message };
});
this._setEntries([...this._entries, {
browser,
info: {
browserType: request.browserType,
userDataDir: request.userDataDir,
cdpPort,
launchOptions: request.launchOptions,
contextOptions: request.contextOptions,
error,
},
}]);
browser?.on('disconnected', () => {
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
});
return info;
}
private _updateReport() {
// Clear the current line and move cursor to top of screen
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
if (this._entries.length === 0) {
process.stdout.write('No browsers currently running\n');
return;
}
process.stdout.write('Running browsers:\n');
for (const entry of this._entries) {
const status = entry.browser ? 'running' : 'error';
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
if (entry.info.error)
process.stdout.write(` Error: ${entry.info.error}\n`);
}
}
private _setEntries(entries: BrowserEntry[]) {
this._entries = entries;
this._updateReport();
}
private _setupExitHandler() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
for (const entry of this._entries)
await entry.browser?.close().catch(() => {});
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}
program
.name('browser-agent')
.option('-p, --port <port>', 'Port to listen on', '9224')
.action(async options => {
await main(options);
});
void program.parseAsync(process.argv);
async function main(options: { port: string }) {
const server = new BrowserServer();
await server.start(+options.port);
}
function readBody<T>(req: http.IncomingMessage): Promise<T> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
});
}
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}

317
src/cdpRelay.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* 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.
*/
/**
* Bridge Server - Standalone WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp - Full CDP interface for Playwright MCP
* - /extension - Extension connection for chrome.debugger forwarding
*/
/* eslint-disable no-console */
import { WebSocket, WebSocketServer } from 'ws';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import debug from 'debug';
import { httpAddressToString } from './transport.js';
const debugLogger = debug('pw:mcp:relay');
const CDP_PATH = '/cdp';
const EXTENSION_PATH = '/extension';
export class CDPRelayServer extends EventEmitter {
private _wss: WebSocketServer;
private _playwrightSocket: WebSocket | null = null;
private _extensionSocket: WebSocket | null = null;
private _connectionInfo: {
targetInfo: any;
sessionId: string;
} | undefined;
constructor(server: http.Server) {
super();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
stop(): void {
this._playwrightSocket?.close();
this._extensionSocket?.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 === CDP_PATH) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === EXTENSION_PATH) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
/**
* Handle Playwright MCP connection - provides full CDP interface
*/
private _handlePlaywrightConnection(ws: WebSocket): void {
if (this._playwrightSocket?.readyState === WebSocket.OPEN) {
debugLogger('Closing previous Playwright connection');
this._playwrightSocket.close(1000, 'New connection established');
}
this._playwrightSocket = ws;
debugLogger('Playwright MCP connected');
ws.on('message', data => {
try {
const message = JSON.parse(data.toString());
this._handlePlaywrightMessage(message);
} catch (error) {
debugLogger('Error parsing Playwright message:', error);
}
});
ws.on('close', () => {
if (this._playwrightSocket === ws)
this._playwrightSocket = null;
debugLogger('Playwright MCP disconnected');
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
}
/**
* Handle Extension connection - forwards to chrome.debugger
*/
private _handleExtensionConnection(ws: WebSocket): void {
if (this._extensionSocket?.readyState === WebSocket.OPEN) {
debugLogger('Closing previous extension connection');
this._extensionSocket.close(1000, 'New connection established');
}
this._extensionSocket = ws;
debugLogger('Extension connected');
ws.on('message', data => {
try {
const message = JSON.parse(data.toString());
this._handleExtensionMessage(message);
} catch (error) {
debugLogger('Error parsing extension message:', error);
}
});
ws.on('close', () => {
if (this._extensionSocket === ws)
this._extensionSocket = null;
debugLogger('Extension disconnected');
});
ws.on('error', error => {
debugLogger('Extension WebSocket error:', error);
});
}
/**
* Handle messages from Playwright MCP
*/
private _handlePlaywrightMessage(message: any): void {
debugLogger('← Playwright:', message.method || `response(${message.id})`);
// Handle Browser domain methods locally
if (message.method?.startsWith('Browser.')) {
this._handleBrowserDomainMethod(message);
return;
}
// Handle Target domain methods
if (message.method?.startsWith('Target.')) {
this._handleTargetDomainMethod(message);
return;
}
// Forward other commands to extension
if (message.method)
this._forwardToExtension(message);
}
/**
* Handle messages from Extension
*/
private _handleExtensionMessage(message: any): void {
// Handle connection info from extension
if (message.type === 'connection_info') {
debugLogger('← Extension connected to tab:', message);
this._connectionInfo = {
targetInfo: message.targetInfo,
// Page sessionId that should be used by this connection.
sessionId: message.sessionId
};
return;
}
// CDP event from extension
debugLogger(`← Extension message: ${message.method ?? (message.id && `response(id=${message.id})`) ?? 'unknown'}`);
this._sendToPlaywright(message);
}
/**
* Handle Browser domain methods locally
*/
private _handleBrowserDomainMethod(message: any): void {
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',
}
});
break;
case 'Browser.setDownloadBehavior':
this._sendToPlaywright({
id: message.id,
result: {}
});
break;
default:
// Forward unknown Browser methods to extension
this._forwardToExtension(message);
}
}
/**
* Handle Target domain methods
*/
private _handleTargetDomainMethod(message: any): void {
switch (message.method) {
case 'Target.setAutoAttach':
// Simulate auto-attach behavior with real target info
if (this._connectionInfo && !message.sessionId) {
debugLogger('Simulating auto-attach for target:', JSON.stringify(message));
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectionInfo.sessionId,
targetInfo: {
...this._connectionInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
this._sendToPlaywright({
id: message.id,
result: {}
});
} else {
this._forwardToExtension(message);
}
break;
case 'Target.getTargets':
const targetInfos = [];
if (this._connectionInfo) {
targetInfos.push({
...this._connectionInfo.targetInfo,
attached: true,
});
}
this._sendToPlaywright({
id: message.id,
result: { targetInfos }
});
break;
default:
this._forwardToExtension(message);
}
}
/**
* Forward message to extension
*/
private _forwardToExtension(message: any): void {
if (this._extensionSocket?.readyState === WebSocket.OPEN) {
debugLogger('→ Extension:', message.method || `command(${message.id})`);
this._extensionSocket.send(JSON.stringify(message));
} else {
debugLogger('Extension not connected, cannot forward message');
if (message.id) {
this._sendToPlaywright({
id: message.id,
error: { message: 'Extension not connected' }
});
}
}
}
/**
* Forward message to Playwright
*/
private _sendToPlaywright(message: any): void {
if (this._playwrightSocket?.readyState === WebSocket.OPEN) {
debugLogger('→ Playwright:', JSON.stringify(message));
this._playwrightSocket.send(JSON.stringify(message));
}
}
}
export async function startCDPRelayServer(httpServer: http.Server) {
const wsAddress = httpAddressToString(httpServer.address()).replace(/^http/, 'ws');
const cdpRelayServer = new CDPRelayServer(httpServer);
process.on('exit', () => cdpRelayServer.stop());
// eslint-disable-next-line no-console
console.error(`CDP relay server started on ${wsAddress}${EXTENSION_PATH} - Connect to it using the browser extension.`);
const cdpEndpoint = `${wsAddress}${CDP_PATH}`;
return cdpEndpoint;
}
// CLI usage
if (import.meta.url === `file://${process.argv[1]}`) {
const port = parseInt(process.argv[2], 10) || 9223;
const httpServer = http.createServer();
await new Promise<void>(resolve => httpServer.listen(port, resolve));
const server = new CDPRelayServer(httpServer);
console.error(`CDP Bridge Server listening on ws://localhost:${port}`);
console.error(`- Playwright MCP: ws://localhost:${port}${CDP_PATH}`);
console.error(`- Extension: ws://localhost:${port}${EXTENSION_PATH}`);
process.on('SIGINT', () => {
debugLogger('\nShutting down bridge server...');
server.stop();
process.exit(0);
});
}

View File

@@ -15,39 +15,59 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import net from 'net';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { devices } from 'playwright'; import { devices } from 'playwright';
import type { Config, ToolCapability } from '../config.js'; import type { Config as PublicConfig, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright'; import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js'; import { sanitizeForFilePath } from './tools/utils.js';
export type CLIOptions = { type Config = PublicConfig & {
browser?: string; /**
caps?: string; * TODO: Move to PublicConfig once we are ready to release this feature.
cdpEndpoint?: string; * Run server that is able to connect to the 'Playwright MCP' Chrome extension.
executablePath?: string; */
headless?: boolean; extension?: boolean;
device?: string;
userDataDir?: string;
port?: number;
host?: string;
vision?: boolean;
config?: string;
allowedOrigins?: string[];
blockedOrigins?: string[];
outputDir?: string;
noImageResponses?: boolean;
}; };
const defaultConfig: Config = { export type CLIOptions = {
allowedOrigins?: string[];
blockedOrigins?: string[];
blockServiceWorkers?: boolean;
browser?: string;
browserAgent?: string;
caps?: string;
cdpEndpoint?: string;
config?: string;
device?: string;
executablePath?: string;
headless?: boolean;
host?: string;
ignoreHttpsErrors?: boolean;
isolated?: boolean;
imageResponses?: 'allow' | 'omit' | 'auto';
sandbox: boolean;
outputDir?: string;
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
vision?: boolean;
extension?: boolean;
};
const defaultConfig: FullConfig = {
browser: { browser: {
browserName: 'chromium', browserName: 'chromium',
launchOptions: { launchOptions: {
channel: 'chrome', channel: 'chrome',
headless: os.platform() === 'linux' && !process.env.DISPLAY, headless: os.platform() === 'linux' && !process.env.DISPLAY,
chromiumSandbox: true,
}, },
contextOptions: { contextOptions: {
viewport: null, viewport: null,
@@ -57,16 +77,46 @@ const defaultConfig: Config = {
allowedOrigins: undefined, allowedOrigins: undefined,
blockedOrigins: undefined, blockedOrigins: undefined,
}, },
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
}; };
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> { type BrowserUserConfig = NonNullable<Config['browser']>;
const config = await loadConfig(cliOptions.config);
export type FullConfig = Config & {
browser: Omit<BrowserUserConfig, 'browserName'> & {
browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
server: NonNullable<Config['server']>,
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
return mergeConfig(defaultConfig, config);
}
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions); const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides)); const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
export function validateConfig(config: Config) {
if (config.extension) {
if (config.browser?.browserName !== 'chromium')
throw new Error('Extension mode is only supported for Chromium browsers.');
}
} }
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> { export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit'; let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined; let channel: string | undefined;
switch (cliOptions.browser) { switch (cliOptions.browser) {
case 'chrome': case 'chrome':
@@ -87,25 +137,62 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
case 'webkit': case 'webkit':
browserName = 'webkit'; browserName = 'webkit';
break; break;
default:
browserName = 'chromium';
channel = 'chrome';
} }
// Launch options
const launchOptions: LaunchOptions = { const launchOptions: LaunchOptions = {
channel, channel,
executablePath: cliOptions.executablePath, executablePath: cliOptions.executablePath,
headless: cliOptions.headless, headless: cliOptions.headless,
}; };
if (browserName === 'chromium') // --no-sandbox was passed, disable the sandbox
(launchOptions as any).cdpPort = await findFreePort(); if (!cliOptions.sandbox)
launchOptions.chromiumSandbox = false;
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined; if (cliOptions.proxyServer) {
launchOptions.proxy = {
server: cliOptions.proxyServer
};
if (cliOptions.proxyBypass)
launchOptions.proxy.bypass = cliOptions.proxyBypass;
}
return { if (cliOptions.device && cliOptions.cdpEndpoint)
throw new Error('Device emulation is not supported with cdpEndpoint.');
if (cliOptions.device && cliOptions.extension)
throw new Error('Device emulation is not supported with extension mode.');
// 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: { browser: {
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
browserName, browserName,
isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir, userDataDir: cliOptions.userDataDir,
launchOptions, launchOptions,
contextOptions, contextOptions,
@@ -117,23 +204,17 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
}, },
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability), capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision, vision: !!cliOptions.vision,
extension: !!cliOptions.extension,
network: { network: {
allowedOrigins: cliOptions.allowedOrigins, allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins, blockedOrigins: cliOptions.blockedOrigins,
}, },
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir, outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
}; };
}
async function findFreePort() { return result;
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);
});
} }
async function loadConfig(configFile: string | undefined): Promise<Config> { async function loadConfig(configFile: string | undefined): Promise<Config> {
@@ -147,11 +228,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
} }
} }
export async function outputFile(config: Config, name: string): Promise<string> { export async function outputFile(config: FullConfig, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir(); await fs.promises.mkdir(config.outputDir, { recursive: true });
await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name); const fileName = sanitizeForFilePath(name);
return path.join(result, fileName); return path.join(config.outputDir, fileName);
} }
function pickDefined<T extends object>(obj: T | undefined): Partial<T> { function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
@@ -160,10 +240,12 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
) as Partial<T>; ) as Partial<T>;
} }
function mergeConfig(base: Config, overrides: Config): Config { function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: Config['browser'] = { const browser: FullConfig['browser'] = {
...pickDefined(base.browser), ...pickDefined(base.browser),
...pickDefined(overrides.browser), ...pickDefined(overrides.browser),
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: { launchOptions: {
...pickDefined(base.browser?.launchOptions), ...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions),
@@ -186,5 +268,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.network), ...pickDefined(base.network),
...pickDefined(overrides.network), ...pickDefined(overrides.network),
}, },
}; server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig;
} }

View File

@@ -14,22 +14,24 @@
* limitations under the License. * limitations under the License.
*/ */
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context, packageJSON } from './context.js'; import { Context } from './context.js';
import { snapshotTools, screenshotTools } from './tools.js'; import { snapshotTools, visionTools } from './tools.js';
import { packageJSON } from './package.js';
import type { Config } from '../config.js'; import { FullConfig, validateConfig } from './config.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export async function createConnection(config: Config): Promise<Connection> { import type { BrowserContextFactory } from './browserContextFactory.js';
const allTools = config.vision ? screenshotTools : snapshotTools;
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
const allTools = config.vision ? visionTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability)); const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
validateConfig(config);
const context = new Context(tools, config); const context = new Context(tools, config, browserContextFactory);
const server = new Server({ name: 'Playwright', version: packageJSON.version }, { const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
capabilities: { capabilities: {
tools: {}, tools: {},
} }
@@ -74,26 +76,19 @@ export async function createConnection(config: Config): Promise<Connection> {
} }
}); });
const connection = new Connection(server, context); return new Connection(server, context);
return connection;
} }
export class Connection { export class Connection {
readonly server: Server; readonly server: McpServer;
readonly context: Context; readonly context: Context;
constructor(server: Server, context: Context) { constructor(server: McpServer, context: Context) {
this.server = server; this.server = server;
this.context = context; this.context = context;
} this.server.oninitialized = () => {
this.context.clientVersion = this.server.getClientVersion();
async connect(transport: Transport) { };
await this.server.connect(transport);
await new Promise<void>(resolve => {
this.server.oninitialized = () => resolve();
});
if (this.server.getClientVersion()?.name.includes('cursor'))
this.context.config.noImageResponses = true;
} }
async close() { async close() {

View File

@@ -14,41 +14,50 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'node:fs'; import debug from 'debug';
import url from 'node:url';
import os from 'node:os';
import path from 'node:path';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js'; import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js'; import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js'; import type { FullConfig } from './config.js';
import { outputFile } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js';
type PendingAction = { type PendingAction = {
dialogShown: ManualPromise<void>; dialogShown: ManualPromise<void>;
}; };
const testDebug = debug('pw:mcp:test');
export class Context { export class Context {
readonly tools: Tool[]; readonly tools: Tool[];
readonly config: Config; readonly config: FullConfig;
private _browser: playwright.Browser | undefined; private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _browserContext: playwright.BrowserContext | undefined; private _browserContextFactory: BrowserContextFactory;
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
private _tabs: Tab[] = []; private _tabs: Tab[] = [];
private _currentTab: Tab | undefined; private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = []; private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined; private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
clientVersion: { name: string; version: string; } | undefined;
constructor(tools: Tool[], config: Config) { constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools; this.tools = tools;
this.config = config; this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
}
clientSupportsImages(): boolean {
if (this.config.imageResponses === 'allow')
return true;
if (this.config.imageResponses === 'omit')
return false;
return !this.clientVersion?.name.includes('cursor');
} }
modalStates(): ModalState[] { modalStates(): ModalState[] {
@@ -80,12 +89,12 @@ export class Context {
currentTabOrDie(): Tab { currentTabOrDie(): Tab {
if (!this._currentTab) 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; return this._currentTab;
} }
async newTab(): Promise<Tab> { async newTab(): Promise<Tab> {
const browserContext = await this._ensureBrowserContext(); const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage(); const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!; this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab; return this._currentTab;
@@ -97,9 +106,9 @@ export class Context {
} }
async ensureTab(): Promise<Tab> { async ensureTab(): Promise<Tab> {
const context = await this._ensureBrowserContext(); const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab) if (!this._currentTab)
await context.newPage(); await browserContext.newPage();
return this._currentTab!; return this._currentTab!;
} }
@@ -109,7 +118,7 @@ export class Context {
const lines: string[] = ['### Open tabs']; const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) { for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i]; const tab = this._tabs[i];
const title = await tab.page.title(); const title = await tab.title();
const url = tab.page.url(); const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : ''; const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`); lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
@@ -146,7 +155,7 @@ export class Context {
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined; let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try { try {
if (waitForNetwork) if (waitForNetwork)
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined; actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else else
actionResult = await racingAction?.() ?? undefined; actionResult = await racingAction?.() ?? undefined;
} finally { } finally {
@@ -190,7 +199,7 @@ ${code.join('\n')}
result.push( result.push(
`- Page URL: ${tab.page.url()}`, `- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.page.title()}` `- Page Title: ${await tab.title()}`
); );
if (captureSnapshot && tab.hasSnapshot()) if (captureSnapshot && tab.hasSnapshot())
@@ -210,10 +219,14 @@ ${code.join('\n')}
} }
async waitForTimeout(time: number) { async waitForTimeout(time: number) {
if (this._currentTab && !this._javaScriptBlocked()) if (!this._currentTab || this._javaScriptBlocked()) {
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
else
await new Promise(f => setTimeout(f, time)); 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> { private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
@@ -273,22 +286,24 @@ ${code.join('\n')}
if (this._currentTab === tab) if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (this._browserContext && !this._tabs.length) if (!this._tabs.length)
void this.close(); void this.close();
} }
async close() { async close() {
if (!this._browserContext) if (!this._browserContextPromise)
return; return;
const browserContext = this._browserContext;
const browser = this._browser;
this._createBrowserContextPromise = undefined;
this._browserContext = undefined;
this._browser = undefined;
await browserContext?.close().then(async () => { testDebug('close context');
await browser?.close();
}).catch(() => {}); const promise = this._browserContextPromise;
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, close }) => {
if (this.config.saveTrace)
await browserContext.tracing.stop();
await close();
});
} }
private async _setupRequestInterception(context: playwright.BrowserContext) { private async _setupRequestInterception(context: playwright.BrowserContext) {
@@ -305,83 +320,32 @@ ${code.join('\n')}
} }
} }
private async _ensureBrowserContext() { private _ensureBrowserContext() {
if (!this._browserContext) { if (!this._browserContextPromise) {
const context = await this._createBrowserContext(); this._browserContextPromise = this._setupBrowserContext();
this._browser = context.browser; this._browserContextPromise.catch(() => {
this._browserContext = context.browserContext; this._browserContextPromise = undefined;
await this._setupRequestInterception(this._browserContext);
for (const page of this._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._createBrowserContextPromise) {
this._createBrowserContextPromise = this._innerCreateBrowserContext();
void this._createBrowserContextPromise.catch(() => {
this._createBrowserContextPromise = undefined;
}); });
} }
return this._createBrowserContextPromise; return this._browserContextPromise;
} }
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> { private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this.config.browser?.remoteEndpoint) { // TODO: move to the browser context factory to make it based on isolation mode.
const url = new URL(this.config.browser?.remoteEndpoint); const result = await this._browserContextFactory.createContext();
if (this.config.browser.browserName) const { browserContext } = result;
url.searchParams.set('browser', this.config.browser.browserName); await this._setupRequestInterception(browserContext);
if (this.config.browser.launchOptions) for (const page of browserContext.pages())
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions)); this._onPageCreated(page);
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url)); browserContext.on('page', page => this._onPageCreated(page));
const browserContext = await browser.newContext(); if (this.config.saveTrace) {
return { browser, browserContext }; await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
} }
return result;
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await launchPersistentContext(this.config.browser);
return { browserContext };
} }
} }
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName];
return await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
} 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;
}
}
async function createUserDataDir(browserConfig: Config['browser']) {
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-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

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,10 +14,33 @@
* limitations under the License. * limitations under the License.
*/ */
import { Connection, createConnection as createConnectionImpl } from './connection.js'; import { createConnection as createConnectionImpl } from './connection.js';
import type { Connection } from '../index.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
export async function createConnection(config: Config = {}): Promise<Connection> { export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
return createConnectionImpl(config); 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()
};
}
} }

22
src/package.ts Normal file
View File

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

View File

@@ -15,6 +15,11 @@
*/ */
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot { export class PageSnapshot {
private _page: playwright.Page; private _page: playwright.Page;
@@ -35,16 +40,16 @@ export class PageSnapshot {
} }
private async _build() { private async _build() {
const yamlDocument = await (this._page as any)._snapshotForAI(); const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [ this._text = [
`- Page Snapshot`, `- Page Snapshot`,
'```yaml', '```yaml',
yamlDocument.toString({ indentSeq: false }).trim(), snapshot,
'```', '```',
].join('\n'); ].join('\n');
} }
refLocator(ref: string): playwright.Locator { refLocator(params: { element: string, ref: string }): playwright.Locator {
return this._page.locator(`aria-ref=${ref}`); return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
} }
} }

View File

@@ -14,58 +14,75 @@
* limitations under the License. * limitations under the License.
*/ */
import { program } from 'commander'; import { Option, program } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { startHttpTransport, startStdioTransport } from './transport.js'; import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
import { resolveConfig } from './config.js'; import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import type { Connection } from './connection.js'; import { packageJSON } from './package.js';
import { packageJSON } from './context.js'; import { startCDPRelayServer } from './cdpRelay.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
.name(packageJSON.name) .name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.') .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.') .option('--config <path>', 'path to the configuration file.')
.option('--headless', 'Run browser in headless mode, headed by default') .option('--device <device>', 'device to emulate, for example: "iPhone 15"')
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"') .option('--executable-path <path>', 'path to the browser executable.')
.option('--user-data-dir <path>', 'Path to the user data directory') .option('--headless', 'run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.') .option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.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('--allowed-origins <origins>', 'Semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) .option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
.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('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.option('--no-image-responses', 'Do not send image responses to the client.') .addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp())
.option('--output-dir <path>', 'Path to the directory for output files.')
.option('--config <path>', 'Path to the configuration file.')
.action(async options => { .action(async options => {
const config = await resolveConfig(options); const config = await resolveCLIConfig(options);
const connectionList: Connection[] = []; const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
setupExitWatchdog(connectionList); if (config.extension) {
if (!httpServer)
throw new Error('--port parameter is required for extension mode');
// Point CDP endpoint to the relay server.
config.browser.cdpEndpoint = await startCDPRelayServer(httpServer);
}
if (options.port) const server = new Server(config);
startHttpTransport(config, +options.port, options.host, connectionList); server.setupExitWatchdog();
if (httpServer)
await startHttpTransport(httpServer, server);
else else
await startStdioTransport(config, connectionList); 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(connectionList: Connection[]) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
for (const connection of connectionList)
await connection.close();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
function semicolonSeparatedList(value: string): string[] { function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim()); return value.split(';').map(v => v.trim());
} }
program.parse(process.argv); void program.parseAsync(process.argv);

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

View File

@@ -19,6 +19,7 @@ import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js'; import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js'; import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab { export class Tab {
readonly context: Context; readonly context: Context;
@@ -61,10 +62,18 @@ export class Tab {
this._onPageClose(this); this._onPageClose(this);
} }
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) { async navigate(url: string) {
this._clearCollectedArtifacts(); this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {}); const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try { try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' }); await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) { } catch (_e: unknown) {
@@ -74,18 +83,17 @@ export class Tab {
|| e.message.includes('Download is starting'); // firefox + webkit || e.message.includes('Download is starting'); // firefox + webkit
if (!mightBeDownload) if (!mightBeDownload)
throw e; throw e;
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([ const download = await Promise.race([
downloadEvent, downloadEvent,
new Promise(resolve => setTimeout(resolve, 500)), new Promise(resolve => setTimeout(resolve, 1000)),
]); ]);
if (!download) if (!download)
throw e; throw e;
} }
// Cap load event to 5 seconds, the page is operational at this point. // Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); await this.waitForLoadState('load', { timeout: 5000 });
} }
hasSnapshot(): boolean { hasSnapshot(): boolean {

View File

@@ -25,8 +25,10 @@ import network from './tools/network.js';
import pdf from './tools/pdf.js'; import pdf from './tools/pdf.js';
import snapshot from './tools/snapshot.js'; import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js'; import tabs from './tools/tabs.js';
import screen from './tools/screen.js'; import screenshot from './tools/screenshot.js';
import testing from './tools/testing.js'; import testing from './tools/testing.js';
import vision from './tools/vision.js';
import wait from './tools/wait.js';
import type { Tool } from './tools/tool.js'; import type { Tool } from './tools/tool.js';
@@ -40,12 +42,14 @@ export const snapshotTools: Tool<any>[] = [
...navigate(true), ...navigate(true),
...network, ...network,
...pdf, ...pdf,
...screenshot,
...snapshot, ...snapshot,
...tabs(true), ...tabs(true),
...testing, ...testing,
...wait(true),
]; ];
export const screenshotTools: Tool<any>[] = [ export const visionTools: Tool<any>[] = [
...common(false), ...common(false),
...console, ...console,
...dialogs(false), ...dialogs(false),
@@ -55,7 +59,8 @@ export const screenshotTools: Tool<any>[] = [
...navigate(false), ...navigate(false),
...network, ...network,
...pdf, ...pdf,
...screen,
...tabs(false), ...tabs(false),
...testing, ...testing,
...vision,
...wait(false),
]; ];

View File

@@ -17,54 +17,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool.js'; import { defineTool, type ToolFactory } from './tool.js';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait_for',
title: 'Wait for',
description: 'Wait for text to appear or disappear or a specified time to pass',
inputSchema: z.object({
time: z.number().optional().describe('The time to wait in seconds'),
text: z.string().optional().describe('The text to wait for'),
textGone: z.string().optional().describe('The text to wait for to disappear'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
const code: string[] = [];
if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
}
const tab = context.currentTabOrDie();
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
await locator.waitFor({ state: 'visible' });
}
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
const close = defineTool({ const close = defineTool({
capability: 'core', capability: 'core',
@@ -79,9 +31,15 @@ const close = defineTool({
handle: async context => { handle: async context => {
await context.close(); await context.close();
return { return {
code: [`// Internal to close the page`], code: [`await page.close()`],
captureSnapshot: false, captureSnapshot: false,
waitForNetwork: false, waitForNetwork: false,
resultOverride: {
content: [{
type: 'text',
text: 'Browser closed successfully.',
}],
},
}; };
}, },
}); });
@@ -122,6 +80,5 @@ const resize: ToolFactory = captureSnapshot => defineTool({
export default (captureSnapshot: boolean) => [ export default (captureSnapshot: boolean) => [
close, close,
wait(captureSnapshot),
resize(captureSnapshot) resize(captureSnapshot)
]; ];

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

@@ -0,0 +1,90 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
});
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
title: 'Take a screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
inputSchema: screenshotSchema,
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
const includeBase64 = context.clientSupportsImages();
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: false,
};
}
});
export default [
screenshot,
];

View File

@@ -18,9 +18,7 @@ import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
import * as javascript from '../javascript.js'; import * as javascript from '../javascript.js';
import { outputFile } from '../config.js'; import { generateLocator } from './utils.js';
import type * as playwright from 'playwright';
const snapshot = defineTool({ const snapshot = defineTool({
capability: 'core', capability: 'core',
@@ -60,7 +58,7 @@ const click = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const tab = context.currentTabOrDie(); const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params.ref); const locator = tab.snapshotOrDie().refLocator(params);
const code = [ const code = [
`// Click ${params.element}`, `// Click ${params.element}`,
@@ -93,8 +91,8 @@ const drag = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator(params.startRef); const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
const endLocator = snapshot.refLocator(params.endRef); const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
const code = [ const code = [
`// Drag ${params.startElement} to ${params.endElement}`, `// Drag ${params.startElement} to ${params.endElement}`,
@@ -122,7 +120,7 @@ const hover = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code = [ const code = [
`// Hover over ${params.element}`, `// Hover over ${params.element}`,
@@ -156,7 +154,7 @@ const type = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code: string[] = []; const code: string[] = [];
const steps: (() => Promise<void>)[] = []; const steps: (() => Promise<void>)[] = [];
@@ -202,7 +200,7 @@ const selectOption = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code = [ const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`, `// Select options [${params.values.join(', ')}] in ${params.element}`,
@@ -218,72 +216,6 @@ const selectOption = defineTool({
}, },
}); });
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
}).refine(data => {
return !!data.element === !!data.ref;
}, {
message: 'Both element and ref must be provided or neither.',
path: ['ref', 'element']
});
const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_take_screenshot',
title: 'Take a screenshot',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
inputSchema: screenshotSchema,
type: 'readOnly',
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = params.element && params.ref;
const code = [
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
];
const locator = params.ref ? snapshot.refLocator(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.config.noImageResponses;
const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: includeBase64 ? [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}] : []
};
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: false,
};
}
});
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
export default [ export default [
snapshot, snapshot,
click, click,
@@ -291,5 +223,4 @@ export default [
hover, hover,
type, type,
selectOption, selectOption,
screenshot,
]; ];

View File

@@ -16,8 +16,9 @@
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> { export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>(); const requests = new Set<playwright.Request>();
let frameNavigated = false; let frameNavigated = false;
let waitCallback: () => void = () => {}; let waitCallback: () => void = () => {};
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
frameNavigated = true; frameNavigated = true;
dispose(); dispose();
clearTimeout(timeout); clearTimeout(timeout);
void frame.waitForLoadState('load').then(() => { void tab.waitForLoadState('load').then(waitCallback);
waitCallback();
});
}; };
const onTimeout = () => { const onTimeout = () => {
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
waitCallback(); waitCallback();
}; };
page.on('request', requestListener); tab.page.on('request', requestListener);
page.on('requestfinished', requestFinishedListener); tab.page.on('requestfinished', requestFinishedListener);
page.on('framenavigated', frameNavigateListener); tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000); const timeout = setTimeout(onTimeout, 10000);
const dispose = () => { const dispose = () => {
page.off('request', requestListener); tab.page.off('request', requestListener);
page.off('requestfinished', requestFinishedListener); tab.page.off('requestfinished', requestFinishedListener);
page.off('framenavigated', frameNavigateListener); tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout); clearTimeout(timeout);
}; };
@@ -77,3 +76,17 @@ export function sanitizeForFilePath(s: string) {
return sanitize(s); return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1)); return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
} }
export async function generateLocator(locator: playwright.Locator): Promise<string> {
try {
return await (locator as any)._generateLocatorString();
} catch (e) {
if (e instanceof Error && /locator._generateLocatorString: Timeout .* exceeded/.test(e.message))
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
throw e;
}
}
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, type ToolFactory } from './tool.js';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait_for',
title: 'Wait for',
description: 'Wait for text to appear or disappear or a specified time to pass',
inputSchema: z.object({
time: z.number().optional().describe('The time to wait in seconds'),
text: z.string().optional().describe('The text to wait for'),
textGone: z.string().optional().describe('The text to wait for to disappear'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
const code: string[] = [];
if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
}
const tab = context.currentTabOrDie();
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
await locator.waitFor({ state: 'visible' });
}
return {
code,
captureSnapshot,
waitForNetwork: false,
};
},
});
export default (captureSnapshot: boolean) => [
wait(captureSnapshot),
];

View File

@@ -18,22 +18,21 @@ import http from 'node:http';
import assert from 'node:assert'; import assert from 'node:assert';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createConnection } from './connection.js'; import type { AddressInfo } from 'node:net';
import type { Server } from './server.js';
import type { Config } from '../config.js'; export async function startStdioTransport(server: Server) {
import type { Connection } from './connection.js'; await server.createConnection(new StdioServerTransport());
export async function startStdioTransport(config: Config, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
} }
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) { 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') { if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId'); const sessionId = url.searchParams.get('sessionId');
if (!sessionId) { if (!sessionId) {
@@ -51,15 +50,13 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res); const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport); sessions.set(transport.sessionId, transport);
const connection = await createConnection(config); testDebug(`create SSE session: ${transport.sessionId}`);
await connection.connect(transport); const connection = await server.createConnection(transport);
connectionList.push(connection);
res.on('close', () => { res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
connection.close().catch(e => { // eslint-disable-next-line no-console
// eslint-disable-next-line no-console void connection.close().catch(e => console.error(e));
console.error(e);
});
}); });
return; return;
} }
@@ -68,7 +65,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed'); res.end('Method not allowed');
} }
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) { async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) { if (sessionId) {
const transport = sessions.get(sessionId); const transport = sessions.get(sessionId);
@@ -91,12 +88,8 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
if (transport.sessionId) if (transport.sessionId)
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
}; };
const connection = await createConnection(config); await server.createConnection(transport);
connectionList.push(connection); await transport.handleRequest(req, res);
await Promise.all([
connection.connect(transport),
transport.handleRequest(req, res),
]);
return; return;
} }
@@ -104,42 +97,53 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
res.end('Invalid request'); res.end('Invalid request');
} }
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) { 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 sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => { httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`); const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp')) if (url.pathname.startsWith('/mcp'))
await handleStreamable(config, req, res, streamableSessions, connectionList); await handleStreamable(mcpServer, req, res, streamableSessions);
else else
await handleSSE(config, req, res, url, sseSessions, connectionList); await handleSSE(mcpServer, req, res, url, sseSessions);
}); });
httpServer.listen(port, hostname, () => { const url = httpAddressToString(httpServer.address());
const address = httpServer.address(); const message = [
assert(address, 'Could not bind server socket'); `Listening on ${url}`,
let url: string; 'Put this in your client config:',
if (typeof address === 'string') { JSON.stringify({
url = address; 'mcpServers': {
} else { 'playwright': {
const resolvedPort = address.port; 'url': `${url}/sse`
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
url = `http://${resolvedHost}:${resolvedPort}`;
}
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
} }
}, undefined, 2), }
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', }, undefined, 2),
].join('\n'); 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(message); 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

@@ -0,0 +1,77 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import path from 'path';
import url from 'node:url';
import { spawn } from 'child_process';
import { test as baseTest, expect } from './fixtures.js';
import type { ChildProcess } from 'child_process';
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
agentEndpoint: async ({}, use) => {
let cp: ChildProcess | undefined;
await use(async (options?: { args?: string[] }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../lib/browserServer.js'),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stdout = '';
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
return { url: new URL(url), stdout: () => stdout };
});
cp?.kill('SIGTERM');
},
});
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
const { url: agentUrl } = await agentEndpoint();
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
expect(await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent('Hello, world!');
await client1.close();
await client2.close();
});

View File

@@ -77,7 +77,7 @@ test('test vision tool list', async ({ visionClient }) => {
}); });
test('test capabilities', async ({ startClient }) => { test('test capabilities', async ({ startClient }) => {
const client = await startClient({ const { client } = await startClient({
args: ['--caps="core"'], args: ['--caps="core"'],
}); });
const { tools } = await client.listTools(); const { tools } = await client.listTools();

View File

@@ -14,18 +14,28 @@
* limitations under the License. * limitations under the License.
*/ */
import url from 'node:url';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient, server }) => { test.skip(({ mcpMode }) => mcpMode === 'extension', 'Connecting to CDP server is not supported in combination with --extension');
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
}); });
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => { test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] }); 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({ expect(await client.callTool({
name: 'browser_click', name: 'browser_click',
@@ -33,7 +43,7 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
element: 'Hello, world!', element: 'Hello, world!',
ref: 'f0', 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({ expect(await client.callTool({
name: 'browser_snapshot', name: 'browser_snapshot',
@@ -43,18 +53,17 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
// <internal code to capture accessibility snapshot> // <internal code to capture accessibility snapshot>
\`\`\` \`\`\`
- Page URL: data:text/html,hello world - Page URL: ${server.HELLO_WORLD}
- Page Title: - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [ref=e1]: hello world - generic [ref=e1]: Hello, world!
\`\`\` \`\`\`
`); `);
}); });
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => { test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const port = 3200 + test.info().parallelIndex; const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
@@ -65,9 +74,21 @@ test('should throw connection error and allow re-connecting', async ({ cdpEndpoi
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port); await cdpServer.start();
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [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.');
});

View File

@@ -19,7 +19,8 @@ import fs from 'node:fs';
import { Config } from '../config.js'; import { Config } from '../config.js';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, localOutputPath, server }) => { test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
test.skip(mcpMode === 'extension', 'Connecting to CDP server does not use user data dir');
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
<body>Hello, world!</body> <body>Hello, world!</body>
@@ -27,13 +28,13 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
const config: Config = { const config: Config = {
browser: { browser: {
userDataDir: localOutputPath('user-data-dir'), userDataDir: testInfo.outputPath('user-data-dir'),
}, },
}; };
const configPath = localOutputPath('config.json'); const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const client = await startClient({ args: ['--config', configPath] }); const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -42,3 +43,23 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
const files = await fs.promises.readdir(config.browser!.userDataDir!); const files = await fs.promises.readdir(config.browser!.userDataDir!);
expect(files.length).toBeGreaterThan(0); 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) => {
test.skip(mcpMode === 'extension', 'Extension mode only supports Chromium');
const config: Config = {
browser: {
browserName: 'firefox',
},
};
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
})).toContainTextContent(`Firefox`);
});
});

View File

@@ -237,3 +237,41 @@ await page.setViewportSize({ width: 390, height: 780 });
\`\`\``); \`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780'); 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');
});

View File

@@ -16,8 +16,9 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => { test('--device should work', async ({ startClient, server, mcpMode }) => {
const client = await startClient({ test.skip(mcpMode === 'extension', 'Viewport is not supported when connecting via CDP. There we re-use the browser viewport.');
const { client } = await startClient({
args: ['--device', 'iPhone 15'], args: ['--device', 'iPhone 15'],
}); });

43
tests/extension.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 url from 'url';
import path from 'path';
import { spawnSync } from 'child_process';
import { test, expect } from './fixtures.js';
import { createConnection } from '@playwright/mcp';
test.skip(({ mcpMode }) => mcpMode !== 'extension');
test('does not allow --cdp-endpoint', async ({ startClient }) => {
await expect(createConnection({
browser: { browserName: 'firefox' },
...({ extension: true })
})).rejects.toThrow(/Extension mode is only supported for Chromium browsers/);
});
// 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', '--extension',
]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain('Device emulation is not supported with extension mode.');
});

View File

@@ -16,9 +16,8 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path';
test('browser_file_upload', async ({ client, localOutputPath, server }) => { test('browser_file_upload', async ({ client, server }, testInfo) => {
server.setContent('/', ` server.setContent('/', `
<input type="file" /> <input type="file" />
<button>Button</button> <button>Button</button>
@@ -54,7 +53,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
})).toContainTextContent(`### Modal state })).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`); - [File chooser]: can be handled by the "browser_file_upload" tool`);
const filePath = localOutputPath('test.txt'); const filePath = testInfo.outputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!'); await fs.writeFile(filePath, 'Hello, world!');
{ {
@@ -101,10 +100,10 @@ The tool "browser_file_upload" can only be used when there is related modal stat
} }
}); });
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => { test('clicking on download link emits download', async ({ startClient, server, mcpMode }, testInfo) => {
const outputDir = localOutputPath('output'); test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const client = await startClient({ const { client } = await startClient({
config: { outputDir }, config: { outputDir: testInfo.outputPath('output') },
}); });
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html'); server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
@@ -123,10 +122,15 @@ test('clicking on download link emits download', async ({ startClient, localOutp
}); });
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(` await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads ### Downloads
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`); - Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
}); });
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => { test('navigating to download link emits download', async ({ startClient, server, mcpBrowser, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Downloads are on the Browser CDP domain and not supported with --extension');
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436'); test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
server.route('/download', (req, res) => { server.route('/download', (req, res) => {
res.writeHead(200, { res.writeHead(200, {

View File

@@ -17,31 +17,42 @@
import fs from 'fs'; import fs from 'fs';
import url from 'url'; import url from 'url';
import path from 'path'; import path from 'path';
import net from 'net';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { fork } from 'child_process';
import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { TestServer } from './testserver/index.ts'; import { TestServer } from './testserver/index.ts';
import { ManualPromise } from '../src/manualPromise.js';
import type { Config } from '../config'; 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 = { export type TestOptions = {
mcpBrowser: string | undefined; mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined; mcpMode: 'docker' | 'extension' | undefined;
};
type CDPServer = {
endpoint: string;
start: () => Promise<BrowserContext>;
}; };
type TestFixtures = { type TestFixtures = {
client: Client; client: Client;
visionClient: Client; visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>; startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string; wsEndpoint: string;
cdpEndpoint: (port?: number) => Promise<string>; cdpServer: CDPServer;
server: TestServer; server: TestServer;
httpsServer: TestServer; httpsServer: TestServer;
mcpHeadless: boolean; mcpHeadless: boolean;
localOutputPath: (filePath: string) => string; startMcpExtension: (relayServerURL: string) => Promise<void>;
}; };
type WorkerFixtures = { type WorkerFixtures = {
@@ -51,20 +62,26 @@ type WorkerFixtures = {
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => { client: async ({ startClient }, use) => {
await use(await startClient()); const { client } = await startClient();
await use(client);
}, },
visionClient: async ({ startClient }, use) => { visionClient: async ({ startClient }, use) => {
await use(await startClient({ args: ['--vision'] })); const { client } = await startClient({ args: ['--vision'] });
await use(client);
}, },
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { startClient: async ({ mcpHeadless, mcpBrowser, mcpMode, startMcpExtension }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir'); const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!); const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined; let client: Client | undefined;
await use(async options => { await use(async options => {
const args = ['--user-data-dir', path.relative(configDir, userDataDir)]; const args: string[] = [];
if (userDataDir)
args.push('--user-data-dir', userDataDir);
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless) if (mcpHeadless)
args.push('--headless'); args.push('--headless');
if (mcpBrowser) if (mcpBrowser)
@@ -78,10 +95,18 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
} }
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode); const { transport, stderr, relayServerURL } = await createTransport(args, mcpMode);
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
process.stderr.write(data);
stderrBuffer += data.toString();
});
await client.connect(transport); await client.connect(transport);
if (mcpMode === 'extension')
await startMcpExtension(relayServerURL!);
await client.ping(); await client.ping();
return client; return { client, stderr: () => stderrBuffer };
}); });
await client?.close(); await client?.close();
@@ -93,39 +118,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await browserServer.close(); await browserServer.close();
}, },
cdpEndpoint: async ({ }, use, testInfo) => { cdpServer: async ({ mcpBrowser }, use, testInfo) => {
let browserProcess: ChildProcessWithoutNullStreams | undefined; test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
await use(async port => { let browserContext: BrowserContext | undefined;
if (!port) const port = 3200 + test.info().parallelIndex;
port = 3200 + test.info().parallelIndex; await use({
if (browserProcess) endpoint: `http://localhost:${port}`,
return `http://localhost:${port}`; start: async () => {
browserProcess = spawn(chromium.executablePath(), [ browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`, channel: mcpBrowser,
`--remote-debugging-port=${port}`, headless: true,
`--no-first-run`, args: [
`--no-sandbox`, `--remote-debugging-port=${port}`,
`--headless`, ],
'--use-mock-keychain',
`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;
return `http://localhost:${port}`; }
});
await new Promise<void>(resolve => {
if (!browserProcess)
return resolve();
browserProcess.on('exit', () => resolve());
browserProcess.kill();
}); });
await browserContext?.close();
}, },
mcpHeadless: async ({ headless }, use) => { mcpHeadless: async ({ headless }, use) => {
@@ -136,14 +147,39 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpMode: [undefined, { option: true }], mcpMode: [undefined, { option: true }],
localOutputPath: async ({ mcpMode }, use, testInfo) => { startMcpExtension: async ({ mcpMode, mcpHeadless }, use) => {
await use(filePath => { let context: BrowserContext | undefined;
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode'); await use(async (relayServerURL: string) => {
return testInfo.outputPath(filePath); if (mcpMode !== 'extension')
throw new Error('Must be running in MCP extension mode to use this fixture.');
const cdpPort = await findFreePort();
const pathToExtension = path.join(url.fileURLToPath(import.meta.url), '../../extension');
context = await chromium.launchPersistentContext('', {
headless: mcpHeadless,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
'--enable-features=AllowContentInitiatedDataUrlNavigations',
],
channel: 'chromium',
...{ assistantMode: true, cdpPort },
});
const popupPage = await context.newPage();
const page = context.pages()[0];
await page.bringToFront();
// Do not auto dismiss dialogs.
page.on('dialog', () => { });
await expect.poll(() => context?.serviceWorkers()).toHaveLength(1);
// Connect to the relay server.
await popupPage.goto(new URL('/popup.html', context.serviceWorkers()[0].url()).toString());
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).clear();
await popupPage.getByRole('textbox', { name: 'Bridge Server URL:' }).fill(relayServerURL);
await popupPage.getByRole('button', { name: 'Share This Tab' }).click();
}); });
await context?.close();
}, },
_workerServers: [async ({}, use, workerInfo) => { _workerServers: [async ({ }, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4; const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port); const server = await TestServer.create(port);
@@ -169,21 +205,77 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
}, },
}); });
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) { async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
transport: Transport,
stderr: Stream | null,
relayServerURL?: string,
}> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. // 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 __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') { if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`]; const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
return new StdioClientTransport({ const transport = new StdioClientTransport({
command: 'docker', command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args], args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
}); });
return {
transport,
stderr: transport.stderr,
};
} }
return new StdioClientTransport({ if (mcpMode === 'extension') {
const relay = fork(path.join(__filename, '../../cli.js'), [...args, '--extension', '--port=0'], {
stdio: 'pipe'
});
const cdpRelayServerReady = new ManualPromise<string>();
const sseEndpointPromise = new ManualPromise<string>();
let stderrBuffer = '';
relay.stderr!.on('data', data => {
stderrBuffer += data.toString();
const match = stderrBuffer.match(/Listening on (http:\/\/.*)/);
if (match)
sseEndpointPromise.resolve(match[1].toString());
const extensionMatch = stderrBuffer.match(/CDP relay server started on (ws:\/\/.*\/extension)/);
if (extensionMatch)
cdpRelayServerReady.resolve(extensionMatch[1].toString());
});
relay.on('exit', () => {
sseEndpointPromise.reject(new Error(`Process exited`));
cdpRelayServerReady.reject(new Error(`Process exited`));
});
const relayServerURL = await cdpRelayServerReady;
const sseEndpoint = await sseEndpointPromise;
const transport = new SSEClientTransport(new URL(sseEndpoint));
// We cannot just add transport.onclose here as Client.connect() overrides it.
const origClose = transport.close;
transport.close = async () => {
await origClose.call(transport);
relay.kill();
};
return {
transport,
stderr: relay.stderr!,
relayServerURL,
};
}
const transport = new StdioClientTransport({
command: 'node', command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args], args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'), 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']>>; type Response = Awaited<ReturnType<Client['callTool']>>;
@@ -239,3 +331,18 @@ export const expect = baseExpect.extend({
}; };
}, },
}); });
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
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);
}

View File

@@ -14,9 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
import { test, expect } from './fixtures.js'; import fs from 'fs';
test('test reopen browser', async ({ client, server }) => { import { test, expect, formatOutput } from './fixtures.js';
test.skip(({ mcpMode }) => mcpMode === 'extension', 'launch scenarios are not supported with --extension - the browser is already launched');
test('test reopen browser', async ({ startClient, server, mcpMode }) => {
const { client, stderr } = await startClient();
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -24,19 +29,131 @@ test('test reopen browser', async ({ client, server }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_close', name: 'browser_close',
})).toContainTextContent('No open pages available'); })).toContainTextContent('Browser closed successfully.');
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
if (process.platform === 'win32')
return;
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
'create context',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
]);
}); });
test('executable path', async ({ startClient, server }) => { test('executable path', async ({ startClient, server }) => {
const client = await startClient({ args: [`--executable-path=bogus`] }); const { client } = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
}); });
expect(response).toContainTextContent(`executable doesn't exist`); 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');
});

View File

@@ -19,7 +19,7 @@ import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => { test('save as pdf unavailable', async ({ startClient, server }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] }); const { client } = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -30,7 +30,11 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/); })).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
}); });
test('save as pdf', async ({ client, mcpBrowser, server }) => { test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.'); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({ expect(await client.callTool({
@@ -44,10 +48,10 @@ test('save as pdf', async ({ client, mcpBrowser, server }) => {
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/); expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
}); });
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => { 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.'); test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({
config: { outputDir }, config: { outputDir },
}); });
@@ -73,6 +77,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); const pdfFiles = files.filter(f => f.endsWith('.pdf'));
expect(files[0]).toMatch(/^output.pdf$/); expect(pdfFiles).toHaveLength(1);
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
}); });

View File

@@ -37,7 +37,7 @@ test('default to allow all', async ({ server, client }) => {
}); });
test('blocked works', async ({ startClient }) => { test('blocked works', async ({ startClient }) => {
const client = await startClient({ const { client } = await startClient({
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev'] args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
}); });
const result = await fetchPage(client, 'https://example.com/'); const result = await fetchPage(client, 'https://example.com/');
@@ -46,7 +46,7 @@ test('blocked works', async ({ startClient }) => {
test('allowed works', async ({ server, startClient }) => { test('allowed works', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html'); server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({ const { client } = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`] args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
}); });
const result = await fetchPage(client, server.PREFIX + 'ppp'); const result = await fetchPage(client, server.PREFIX + 'ppp');
@@ -54,7 +54,7 @@ test('allowed works', async ({ server, startClient }) => {
}); });
test('blocked takes precedence', async ({ startClient }) => { test('blocked takes precedence', async ({ startClient }) => {
const client = await startClient({ const { client } = await startClient({
args: [ args: [
'--blocked-origins', 'example.com', '--blocked-origins', 'example.com',
'--allowed-origins', 'example.com', '--allowed-origins', 'example.com',
@@ -65,7 +65,7 @@ test('blocked takes precedence', async ({ startClient }) => {
}); });
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => { test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
const client = await startClient({ const { client } = await startClient({
args: ['--allowed-origins', 'playwright.dev'], args: ['--allowed-origins', 'playwright.dev'],
}); });
const result = await fetchPage(client, 'https://example.com/'); const result = await fetchPage(client, 'https://example.com/');
@@ -74,7 +74,7 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => { test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html'); server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({ const { client } = await startClient({
args: ['--blocked-origins', 'example.com'], args: ['--blocked-origins', 'example.com'],
}); });
const result = await fetchPage(client, server.PREFIX + 'ppp'); const result = await fetchPage(client, server.PREFIX + 'ppp');

View File

@@ -18,7 +18,10 @@ import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ client, server }) => { test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -41,7 +44,10 @@ test('browser_take_screenshot (viewport)', async ({ client, server }) => {
}); });
}); });
test('browser_take_screenshot (element)', async ({ client, server }) => { test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({
config: { outputDir: testInfo.outputPath('output') },
});
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -68,10 +74,10 @@ test('browser_take_screenshot (element)', async ({ client, server }) => {
}); });
}); });
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => { test('--output-dir should work', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const outputDir = testInfo.outputPath('output');
const client = await startClient({ const { client } = await startClient({
args: ['--output-dir', outputDir], config: { outputDir },
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
@@ -83,14 +89,16 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
}); });
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1); 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]) { for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => { test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const ext = raw ? 'png' : 'jpeg'; const ext = raw ? 'png' : 'jpeg';
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({
config: { outputDir }, config: { outputDir },
}); });
expect(await client.callTool({ expect(await client.callTool({
@@ -117,7 +125,7 @@ for (const raw of [undefined, true]) {
], ],
}); });
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); expect(files).toHaveLength(1);
@@ -128,9 +136,9 @@ for (const raw of [undefined, true]) {
} }
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => { test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const outputDir = testInfo.outputPath('output');
const client = await startClient({ const { client } = await startClient({
config: { outputDir }, config: { outputDir },
}); });
expect(await client.callTool({ expect(await client.callTool({
@@ -157,17 +165,19 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
], ],
}); });
const files = [...fs.readdirSync(outputDir)]; const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(fs.existsSync(outputDir)).toBeTruthy(); expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1); expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.jpeg$/); expect(files[0]).toMatch(/^output\.jpeg$/);
}); });
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => { test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
const client = await startClient({ const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { config: {
noImageResponses: true, outputDir,
imageResponses: 'omit',
}, },
}); });
@@ -192,8 +202,13 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server
}); });
}); });
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => { test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
const client = await startClient({ clientName: 'cursor:vscode' }); const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
clientName: 'cursor:vscode',
config: { outputDir },
});
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',

View File

@@ -14,90 +14,235 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'node:fs';
import url from 'node:url'; import url from 'node:url';
import http from 'node:http';
import { spawn } from 'node:child_process'; import { ChildProcess, spawn } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createConnection } from '@playwright/mcp';
import { test as baseTest, expect } from './fixtures.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. // 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 __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: string }>({ baseTest.skip(({ mcpMode }) => mcpMode === 'extension', 'Extension tests run via SSE anyways');
serverEndpoint: async ({}, use) => {
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' }); const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
try { serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
let stdout = ''; let cp: ChildProcess | undefined;
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => { const userDataDir = testInfo.outputPath('user-data-dir');
stdout += data.toString(); await use(async (options?: { args?: string[], noPort?: boolean }) => {
const match = stdout.match(/Listening on (http:\/\/.*)/); 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) if (match)
resolve(match[1]); resolve(match[1]);
})); }));
await use(url); return { url: new URL(url), stderr: () => stderr };
} finally { });
cp.kill(); cp?.kill('SIGTERM');
}
}, },
}); });
test('sse transport', async ({ serverEndpoint }) => { test('sse transport', async ({ serverEndpoint }) => {
const transport = new SSEClientTransport(new URL(serverEndpoint)); const { url } = await serverEndpoint();
const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
}); });
test('sse transport (config)', async ({ serverEndpoint }) => {
const config: Config = {
server: {
port: 0,
}
};
const configFile = test.info().outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
const transport = new SSEClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
}).toPass();
});
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport3 = new SSEClientTransport(url);
const client3 = new Client({ name: 'test', version: '1.0.0' });
await client3.connect(transport3);
await client3.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await client3.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
}).toPass();
});
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint();
const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client1.close();
const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await client2.close();
await expect(async () => {
const lines = stderr().split('\n');
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
}).toPass();
});
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport1 = new SSEClientTransport(url);
const client1 = new Client({ name: 'test', version: '1.0.0' });
await client1.connect(transport1);
await client1.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const transport2 = new SSEClientTransport(url);
const client2 = new Client({ name: 'test', version: '1.0.0' });
await client2.connect(transport2);
const response = await client2.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(response.isError).toBe(true);
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
await client1.close();
await client2.close();
});
test('streamable http transport', async ({ serverEndpoint }) => { test('streamable http transport', async ({ serverEndpoint }) => {
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint)); const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
const client = new Client({ name: 'test', version: '1.0.0' }); const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined(); expect(transport.sessionId, 'has session support').toBeDefined();
}); });
test('sse transport via public API', async ({ server }) => {
const sessions = new Map<string, SSEServerTransport>();
const mcpServer = http.createServer(async (req, res) => {
if (req.method === 'GET') {
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
await connection.connect(transport);
} else if (req.method === 'POST') {
const url = new URL(`http://localhost${req.url}`);
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');
}
void transport.handlePostMessage(req, res);
}
});
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
const transport = new SSEClientTransport(new URL(serverUrl));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
mcpServer.close();
});

View File

@@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import { chromium } from 'playwright';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -29,6 +27,8 @@ async function createTab(client: Client, title: string, body: string) {
}); });
} }
test.skip(({ mcpMode }) => mcpMode === 'extension', 'Multi-tab scenarios are not supported with --extension');
test('list initial tabs', async ({ client }) => { test('list initial tabs', async ({ client }) => {
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_tab_list', name: 'browser_tab_list',
@@ -139,17 +139,14 @@ test('close tab', async ({ client }) => {
\`\`\``); \`\`\``);
}); });
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => { test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html'); const browserContext = await cdpServer.start();
const pages = browserContext.pages();
const browser = await chromium.connectOverCDP(await cdpEndpoint()); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [context] = browser.contexts();
const pages = context.pages();
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.HELLO_WORLD },
}); });
expect(pages.length).toBe(1); expect(pages.length).toBe(1);

37
tests/trace.spec.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 fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server, mcpMode }, testInfo) => {
test.fixme(mcpMode === 'extension', 'Tracing is not supported via CDP');
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
});

View File

@@ -32,65 +32,67 @@ import networkTools from '../lib/tools/network.js';
import pdfTools from '../lib/tools/pdf.js'; import pdfTools from '../lib/tools/pdf.js';
import snapshotTools from '../lib/tools/snapshot.js'; import snapshotTools from '../lib/tools/snapshot.js';
import tabsTools from '../lib/tools/tabs.js'; import tabsTools from '../lib/tools/tabs.js';
import screenTools from '../lib/tools/screen.js'; import screenshotTools from '../lib/tools/screenshot.js';
import testTools from '../lib/tools/testing.js'; import testTools from '../lib/tools/testing.js';
import visionTools from '../lib/tools/vision.js';
import waitTools from '../lib/tools/wait.js';
import { execSync } from 'node:child_process';
// Category definitions for tools
const categories = { const categories = {
'Snapshot-based Interactions': [ 'Interactions': [
...snapshotTools, ...snapshotTools,
], ...keyboardTools(true),
'Vision-based Interactions': [ ...waitTools(true),
...screenTools ...filesTools(true),
], ...dialogsTools(true),
'Tab Management': [
...tabsTools(true),
], ],
'Navigation': [ 'Navigation': [
...navigateTools(true), ...navigateTools(true),
], ],
'Keyboard': [ 'Resources': [
...keyboardTools(true) ...screenshotTools,
], ...pdfTools,
'Console': [ ...networkTools,
...consoleTools ...consoleTools,
],
'Files and Media': [
...filesTools(true),
...pdfTools
], ],
'Utilities': [ 'Utilities': [
...commonTools(true),
...installTools, ...installTools,
...dialogsTools(true), ...commonTools(true),
...networkTools, ],
'Tabs': [
...tabsTools(true),
], ],
'Testing': [ 'Testing': [
...testTools, ...testTools,
], ],
'Vision mode': [
...visionTools,
...keyboardTools(),
...waitTools(false),
...filesTools(false),
...dialogsTools(false),
],
}; };
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename. // 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 __filename = url.fileURLToPath(import.meta.url);
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
const kEndMarker = `<!--- End of generated section -->`;
/** /**
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool * @param {import('../src/tools/tool.js').ToolSchema<any>} tool
* @returns {string} * @returns {string[]}
*/ */
function formatToolForReadme(tool) { function formatToolForReadme(tool) {
const lines = /** @type {string[]} */ ([]); const lines = /** @type {string[]} */ ([]);
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`); lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
lines.push(`- **${tool.name}**\n`); lines.push(``);
lines.push(` - Title: ${tool.title}\n`); lines.push(`- **${tool.name}**`);
lines.push(` - Description: ${tool.description}\n`); lines.push(` - Title: ${tool.title}`);
lines.push(` - Description: ${tool.description}`);
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {})); const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
const requiredParams = inputSchema.required || []; const requiredParams = inputSchema.required || [];
if (inputSchema.properties && Object.keys(inputSchema.properties).length) { if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
lines.push(` - Parameters:\n`); lines.push(` - Parameters:`);
Object.entries(inputSchema.properties).forEach(([name, param]) => { Object.entries(inputSchema.properties).forEach(([name, param]) => {
const optional = !requiredParams.includes(name); const optional = !requiredParams.includes(name);
const meta = /** @type {string[]} */ ([]); const meta = /** @type {string[]} */ ([]);
@@ -98,52 +100,94 @@ function formatToolForReadme(tool) {
meta.push(param.type); meta.push(param.type);
if (optional) if (optional)
meta.push('optional'); meta.push('optional');
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`); lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
}); });
} else { } else {
lines.push(` - Parameters: None\n`); lines.push(` - Parameters: None`);
} }
lines.push(` - Read-only: **${tool.type === 'readOnly'}**\n`); lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
lines.push('\n'); lines.push('');
return lines.join(''); return lines;
} }
async function updateReadme() { /**
* @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...'); console.log('Loading tool information from compiled modules...');
// Count the tools processed
const totalTools = Object.values(categories).flat().length; const totalTools = Object.values(categories).flat().length;
console.log(`Found ${totalTools} tools`); console.log(`Found ${totalTools} tools`);
const generatedLines = /** @type {string[]} */ ([]); const generatedLines = /** @type {string[]} */ ([]);
for (const [category, categoryTools] of Object.entries(categories)) { for (const [category, categoryTools] of Object.entries(categories)) {
generatedLines.push(`### ${category}\n\n`); generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
generatedLines.push('');
for (const tool of categoryTools) for (const tool of categoryTools)
generatedLines.push(formatToolForReadme(tool.schema)); 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 readmePath = path.join(path.dirname(__filename), '..', 'README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8'); const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const startMarker = readmeContent.indexOf(kStartMarker); const withTools = await updateTools(readmeContent);
const endMarker = readmeContent.indexOf(kEndMarker); const withOptions = await updateOptions(withTools);
if (startMarker === -1 || endMarker === -1) await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
throw new Error('Markers for generated section not found in README');
const newReadmeContent = [
readmeContent.slice(0, startMarker),
kStartMarker + '\n\n',
generatedLines.join(''),
kEndMarker,
readmeContent.slice(endMarker + kEndMarker.length),
].join('');
// Write updated README
await fs.promises.writeFile(readmePath, newReadmeContent, 'utf-8');
console.log('README updated successfully'); console.log('README updated successfully');
} }
// Run the update
updateReadme().catch(err => { updateReadme().catch(err => {
console.error('Error updating README:', err); console.error('Error updating README:', err);
process.exit(1); process.exit(1);