77 Commits

Author SHA1 Message Date
Pavel Feldman
746c9fc124 chore: mark v0.0.25 (#414) 2025-05-13 16:24:04 -07:00
Pavel Feldman
ee33097abe chore: normalize --no- options (#413) 2025-05-13 16:17:45 -07:00
Pavel Feldman
ab20175826 chore: generate readme options (#411) 2025-05-13 15:52:30 -07:00
Pavel Feldman
c506027aec chore: run w/ sandbox by default (#412) 2025-05-13 15:30:02 -07:00
Pavel Feldman
7be0c8872e feat(args): allow configuring proxy, UA, viewport, https errors (#410) 2025-05-13 14:40:03 -07:00
Pavel Feldman
ce72367208 feat(storage): allow passing storage state for isolated contexts (#409)
Fixes https://github.com/microsoft/playwright-mcp/issues/403
Ref https://github.com/microsoft/playwright-mcp/issues/367
2025-05-13 13:14:04 -07:00
Pavel Feldman
949f956378 feat(ephemeral): allow for non-persistent context operation (#405)
Ref: https://github.com/microsoft/playwright-mcp/issues/367
Ref: https://github.com/microsoft/playwright-mcp/issues/393
2025-05-12 18:18:53 -07:00
Pavel Feldman
a1eee8351e chore: collapse readme (#404) 2025-05-12 16:42:47 -07:00
Pavel Feldman
fea3f26e85 chore: mark v0.0.24 (#401) 2025-05-12 09:40:59 -07:00
Pavel Feldman
dd5b41f1d8 chore: account for undefined arguments (#400) 2025-05-12 09:35:33 -07:00
Pavel Feldman
05dc5d915b chore: mark v0.0.23 (#399) 2025-05-12 09:13:48 -07:00
Taiga Mikami
65a229c79f Fix import in README from createServer to createConnection (#396)
Probably, `createServer` is not from `@playwright/mcp`.
2025-05-12 08:46:21 -07:00
Max Schmitt
84664d4b09 test: unflake 'should throw connection error and allow re-connecting' (#398)
Fixes
https://github.com/microsoft/playwright-mcp/actions/runs/14940263450/job/41976152764#step:8:315
2025-05-12 09:45:09 +02:00
Pavel Feldman
445170a76b chore: roll playwright 5/9 (#394) 2025-05-09 18:01:17 -07:00
Pavel Feldman
c28b480b51 feat(wait): allow waiting for given text (#390)
Fixes https://github.com/microsoft/playwright-mcp/issues/389
2025-05-09 15:35:28 -07:00
Max Schmitt
65716b60dd fix: createConnection() via public API (#384)
Fixes https://github.com/microsoft/playwright-mcp/issues/382
2025-05-09 21:50:38 +02:00
Max Schmitt
75f74a54bc docs: reference to new Docker image (#380) 2025-05-09 21:01:10 +02:00
Max Schmitt
ef41c626ef chore: unset skipLibCheck in tsconfig.json (#386)
Follow-up for
https://github.com/microsoft/playwright-mcp/pull/385#discussion_r2081541865.

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

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

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

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

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

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

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

Instead, we should show the error message from the tool implementation.
2025-04-29 18:48:52 +02:00
zwmmm
ad4147da54 docs: Fix the default path to User data directory (#290)
Fix the default path to User data directory
2025-04-29 08:53:30 -07:00
Pavel Feldman
69703cc882 chore: follow up to exposing playwright config options (#289) 2025-04-29 08:53:03 -07:00
Max Schmitt
4147e21a3a chore: fix update-readme TS linting (#296) 2025-04-29 16:12:17 +02:00
Pavel Feldman
80c9b93b72 chore: allow configuring raw Playwright options (#287)
Fixes: https://github.com/microsoft/playwright-mcp/issues/272
2025-04-28 20:17:16 -07:00
Pavel Feldman
12e72a96c4 chore: allow configuring screenshot tool (#286)
Fixes: https://github.com/microsoft/playwright-mcp/issues/277
2025-04-28 17:21:23 -07:00
Pavel Feldman
697a69a8c2 chore: allow specifying output dir (#285)
Ref: https://github.com/microsoft/playwright-mcp/issues/279
2025-04-28 16:35:33 -07:00
Pavel Feldman
6e76d5e550 chore: split context.ts into files (#284) 2025-04-28 16:14:16 -07:00
Pavel Feldman
26779ceb20 chore: allow passing config file (#281) 2025-04-28 15:04:59 -07:00
Pavel Feldman
23704ace1f chore: update docs on lint (#283) 2025-04-28 14:56:00 -07:00
Pavel Feldman
b02370df2f chore: roll playwright to latest (#269) 2025-04-28 13:44:24 -07:00
Simon Knott
bf7dbabca4 feat: support streamable http transport (#243)
Adds support for the new StreamableHttp transport. I'm not aware of any
clients that implement it, but somebody's gotta make the start! Once
some clients support it, we can also advertise it in the README.
2025-04-28 11:11:31 +02:00
Zheng Xi Zhou
7256ee3701 docs(readme): Fix syntax error and improve formatting (#263)
The commit fixes a syntax error in the `npx` command by removing
an extra backtick. It also improves the formatting by adding line
breaks before code blocks to enhance readability.
2025-04-24 10:30:35 +02:00
Zheng Xi Zhou
0ed0bcd914 feat(server): add host option to SSE server configuration (#261) 2025-04-23 23:04:00 -07:00
Zheng Xi Zhou
4d95761f66 chore(gitignore): Add .idea and .DS_Store to .gitignore (#262) 2025-04-23 22:05:06 -07:00
Max Schmitt
b9dc323734 chore: enable @typescript-eslint/no-floating-promises rule (#260) 2025-04-23 16:03:30 +02:00
70 changed files with 3348 additions and 1409 deletions

View File

@@ -21,7 +21,6 @@ jobs:
- run: npm run build
- name: Run ESLint
run: npm run lint
- run: npm run update-readme
- name: Ensure no changes
run: git diff --exit-code
@@ -31,31 +30,56 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
# MS Edge is not preinstalled on macOS runners.
if: ${{ matrix.os == 'macos-latest' }}
run: npx playwright install msedge
- name: Build
run: npm run build
- name: Run tests
run: npm test
test_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
run: npx playwright install msedge
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
tags: playwright-mcp-dev:latest
cache-from: type=gha
cache-to: type=gha,mode=max
load: true
- name: Run tests
run: npm test -- --forbid-only
shell: bash
run: |
# Used for the Docker tests to share the test-results folder with the container.
umask 0000
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
id-token: write # Needed for npm provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -21,4 +21,35 @@ jobs:
- run: npm run ctest
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-docker:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Needed for OIDC login to Azure
environment: allow-publishing-docker-to-acr
steps:
- uses: actions/checkout@v4
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx # Needed for multi-platform builds
uses: docker/setup-buildx-action@v3
- name: Azure Login via OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
- name: Login to ACR
run: az acr login --name playwright
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
platforms: linux/amd64,linux/arm64
push: true
tags: |
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
playwright.azurecr.io/public/playwright/mcp:latest

4
.gitignore vendored
View File

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

View File

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

View File

@@ -1,22 +1,69 @@
FROM node:22-bookworm-slim
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# ------------------------------
# Base
# ------------------------------
# Base stage: Contains only the minimal dependencies required for runtime
# (node_modules and Playwright system dependencies)
FROM node:22-bookworm-slim AS base
ARG PLAYWRIGHT_BROWSERS_PATH
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json at this stage to leverage the build cache
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
npm ci --omit=dev && \
# Install system dependencies for playwright
npx -y playwright-core install-deps chromium
# Install dependencies
RUN npm ci
# ------------------------------
# Builder
# ------------------------------
FROM base AS builder
# Install chromium and its dependencies, but only for headless mode
RUN npx -y playwright install --with-deps --only-shell chromium
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
npm ci
# Copy the rest of the app
COPY . .
COPY *.json *.js *.ts .
COPY src src/
# Build the app
RUN npm run build
# ------------------------------
# Browser
# ------------------------------
# Cache optimization:
# - Browser is downloaded only when node_modules or Playwright system dependencies change
# - Cache is reused when only source code changes
FROM base AS browser
RUN npx -y playwright-core install --no-shell chromium
# ------------------------------
# Runtime
# ------------------------------
FROM base
ARG PLAYWRIGHT_BROWSERS_PATH
ARG USERNAME=node
ENV NODE_ENV=production
# Set the correct ownership for the runtime user on production `node_modules`
RUN chown -R ${USERNAME}:${USERNAME} node_modules
USER ${USERNAME}
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"]
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]

683
README.md
View File

@@ -4,20 +4,22 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
### Key Features
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
- **LLM-friendly**: No vision models needed, operates purely on structured data.
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
- **LLM-friendly**. No vision models needed, operates purely on structured data.
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
### Use Cases
### Requirements
- Node.js 18 or newer
- VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
- Web navigation and form-filling
- Data extraction from structured content
- Automated testing driven by LLMs
- General-purpose browser interaction for agents
<!--
// Generate using:
node utils/generate-links.js
-->
### Example config
### Getting started
#### NPX
First, install the Playwright MCP server with your client. A typical configuration looks like this:
```js
{
@@ -32,67 +34,157 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
}
```
#### Installation in VS Code
[<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)
Install the Playwright MCP server in VS Code using one of these buttons:
<!--
// Generate using?:
const config = JSON.stringify({ name: 'playwright', command: 'npx', args: ["-y", "@playwright/mcp@latest"] });
const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
// Github markdown does not allow linking to `vscode:` directly, so you can use our redirect:
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
-->
<details><summary><b>Install in VS Code</b></summary>
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
Alternatively, 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
# For VS Code
code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
```
```bash
# For VS Code Insiders
code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'
```
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
</details>
### CLI Options
<details>
<summary><b>Install in Cursor</b></summary>
The Playwright MCP server supports the following command-line options:
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`.
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
- `chrome`, `firefox`, `webkit`, `msedge`
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
- `--port <port>`: Port to listen on for SSE transport
- `--user-data-dir <path>`: Path to the user data directory
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
### User data directory
<details>
<summary><b>Install in Windsurf</b></summary>
Playwright MCP will launch the browser with the new profile, located at
Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary><b>Install in Claude Desktop</b></summary>
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
### Configuration
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
<!--- Options generated by update-readme.js -->
```
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
- `~/Library/Caches/ms-playwright/mcp-chrome-profile` on macOS
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
> npx @playwright/mcp@latest --help
--allowed-origins <origins> semicolon-separated list of origins to allow the
browser to request. Default is to allow all.
--blocked-origins <origins> semicolon-separated list of origins to block the
browser from requesting. Blocklist is evaluated
before allowlist. If used without the allowlist,
requests not matching the blocklist are still
allowed.
--block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge.
--caps <caps> comma-separated list of 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.
--no-image-responses do not send image responses to the client.
--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"
--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)
```
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
<!--- End of options generated section -->
### User profile
### Running headless browser (Browser without GUI).
You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
This mode is useful for background or batch operations.
**Persistent profile**
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
```bash
# Windows
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
# macOS
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
# Linux
- ~/.cache/ms-playwright/mcp-{channel}-profile
```
**Isolated**
In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
the session is closed and all the storage state for this session is lost. You can provide initial storage state
to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
state [here](https://playwright.dev/docs/auth).
```js
{
@@ -101,14 +193,104 @@ This mode is useful for background or batch operations.
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
"--isolated",
"--storage-state={path/to/storage.json}
]
}
}
}
```
### Running headed browser on Linux w/o DISPLAY
### Configuration file
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
using the `--config` command line option:
```bash
npx @playwright/mcp@latest --config path/to/config.json
```
<details>
<summary>Configuration file schema</summary>
```typescript
{
// Browser configuration
browser?: {
// Browser type to use (chromium, firefox, or webkit)
browserName?: 'chromium' | 'firefox' | 'webkit';
// Keep the browser profile in memory, do not save it to disk.
isolated?: boolean;
// Path to user data directory for browser profile persistence
userDataDir?: string;
// Browser launch options (see Playwright docs)
// @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch
launchOptions?: {
channel?: string; // Browser channel (e.g. 'chrome')
headless?: boolean; // Run in headless mode
executablePath?: string; // Path to browser executable
// ... other Playwright launch options
};
// Browser context options
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
contextOptions?: {
viewport?: { width: number, height: number };
// ... other Playwright context options
};
// CDP endpoint for connecting to existing browser
cdpEndpoint?: string;
// Remote Playwright server endpoint
remoteEndpoint?: string;
},
// Server configuration
server?: {
port?: number; // Port to listen on
host?: string; // Host to bind to (default: localhost)
},
// List of enabled capabilities
capabilities?: Array<
'core' | // Core browser automation
'tabs' | // Tab management
'pdf' | // PDF generation
'history' | // Browser history
'wait' | // Wait utilities
'files' | // File handling
'install' | // Browser installation
'testing' // Testing
>;
// Enable vision mode (screenshots instead of accessibility snapshots)
vision?: boolean;
// Directory for output files
outputDir?: string;
// Network configuration
network?: {
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
allowedOrigins?: string[];
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
blockedOrigins?: string[];
};
/**
* Do not send image responses to the client.
*/
noImageResponses?: boolean;
}
```
</details>
### Standalone MCP server
When running headed browser on system w/o display or from worker processes of the IDEs,
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
@@ -129,21 +311,52 @@ 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.
```js
{
"mcpServers": {
"playwright": {
"command": "docker",
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
}
}
}
```
### Tool Modes
You can build the Docker image yourself.
```
docker build -t mcr.microsoft.com/playwright/mcp .
```
</details>
<details>
<summary><b>Programmatic usage</b></summary>
```js
import http from 'http';
import { createConnection } from '@playwright/mcp';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
http.createServer(async (req, res) => {
// ...
// Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport);
// ...
});
```
</details>
### Tools
The tools are available in two modes:
@@ -169,73 +382,55 @@ 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
X Y coordinate space, based on the provided screenshot.
### Build with Docker
<!--- Tools generated by update-readme.js -->
You can build the Docker image yourself.
```
docker build -t mcp/playwright .
```
### Programmatic usage with custom transports
```js
import http from 'http';
import { createServer } from '@playwright/mcp';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
http.createServer(async (req, res) => {
// ...
// Creates a headless Playwright MCP server with SSE transport
const mcpServer = await createServer({ headless: true });
const transport = new SSEServerTransport('/messages', res);
await mcpServer.connect(transport);
// ...
});
```
<!--- Generated by update-readme.js -->
### Snapshot-based Interactions
<details>
<summary><b>Interactions</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_click**
- Title: Click
- Description: Perform click on a web page
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_drag**
- Title: Drag mouse
- Description: Perform drag and drop between two elements
- Parameters:
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
- `startRef` (string): Exact source element reference from the page snapshot
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
- `endRef` (string): Exact target element reference from the page snapshot
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_hover**
- Title: Hover mouse
- Description: Hover over element on page
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_type**
- Title: Type text
- Description: Type text into editable element
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
@@ -243,54 +438,256 @@ http.createServer(async (req, res) => {
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_select_option**
- Title: Select option
- Description: Select an option in a dropdown
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_file_upload**
- Title: Upload files
- Description: Upload one or multiple files
- 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**
- Title: Take a screenshot
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- Read-only: **true**
### 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 -->
- **browser_screen_capture**
- Title: Take a screenshot
- Description: Take a screenshot of the current page
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_move_mouse**
- Title: Move mouse
- Description: Move mouse to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_click**
- Title: Click
- Description: Click left mouse button
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_drag**
- Title: Drag mouse
- Description: Drag left mouse button
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
@@ -298,132 +695,58 @@ http.createServer(async (req, res) => {
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
- `endY` (number): End Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_screen_type**
- Title: Type text
- Description: Type text
- Parameters:
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
### Tab Management
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_list**
- Description: List browser tabs
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_new**
- Description: Open a new tab
- Parameters:
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_select**
- Description: Select a tab by index
- Parameters:
- `index` (number): The index of the tab to select
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_tab_close**
- Description: Close a tab
- Parameters:
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
### Navigation
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate**
- Description: Navigate to a URL
- Parameters:
- `url` (string): The URL to navigate to
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_back**
- Description: Go back to the previous page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate_forward**
- Description: Go forward to the next page
- Parameters: None
### Keyboard
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_press_key**
- Title: Press a key
- Description: Press a key on the keyboard
- Parameters:
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
### Console
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Description: Returns all console messages
- Parameters: None
### Files and Media
- **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.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_pdf_save**
- Description: Save page as PDF
- Parameters: None
### Utilities
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_close**
- Description: Close the page
- Parameters: None
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait**
- Description: Wait for a specified time in seconds
- Parameters:
- `time` (number): The time to wait in seconds
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_resize**
- Description: Resize the browser window
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_install**
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
- Parameters: None
- 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**
<!--- End of generated section -->
</details>
<!--- End of tools generated section -->

2
cli.js
View File

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

118
config.d.ts vendored Normal file
View File

@@ -0,0 +1,118 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
export type Config = {
/**
* The browser to use.
*/
browser?: {
/**
* The type of browser to use.
*/
browserName?: 'chromium' | 'firefox' | 'webkit';
/**
* Keep the browser profile in memory, do not save it to disk.
*/
isolated?: boolean;
/**
* Path to a user data directory for browser profile persistence.
* Temporary directory is created by default.
*/
userDataDir?: string;
/**
* Launch options passed to
* @see https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context
*
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
*/
launchOptions?: playwright.LaunchOptions;
/**
* Context options for the browser context.
*
* This is useful for settings options like `viewport`.
*/
contextOptions?: playwright.BrowserContextOptions;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
*/
cdpEndpoint?: string;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
},
server?: {
/**
* The port to listen on for SSE or MCP transport.
*/
port?: number;
/**
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
*/
host?: string;
},
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'tabs': Tab management features.
* - 'pdf': PDF generation and manipulation.
* - 'history': Browser history access.
* - 'wait': Wait and timing utilities.
* - 'files': File upload/download support.
* - 'install': Browser installation utilities.
*/
capabilities?: ToolCapability[];
/**
* Run server that uses screenshots (Aria snapshots are used by default).
*/
vision?: boolean;
/**
* The directory to save output files.
*/
outputDir?: string;
network?: {
/**
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
allowedOrigins?: string[];
/**
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
blockedOrigins?: string[];
};
/**
* Do not send image responses to the client.
*/
noImageResponses?: boolean;
};

View File

@@ -33,6 +33,8 @@ const plugins = {
};
export const baseRules = {
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
@@ -178,12 +180,16 @@ export const baseRules = {
// react
"react/react-in-jsx-scope": 0,
"no-console": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
parserOptions: {
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
}
};
export default [

View File

@@ -1,47 +0,0 @@
# Examples
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
## Write a regression test for a bug report
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
**Output:**
```ts
import { test, expect } from '@playwright/test';
/**
* Regression test for bug: The Garfield Movie showing incorrect runtime
* User reported actual runtime is 90 minutes, but site shows 101 minutes
*/
test('verify The Garfield Movie runtime bug', async ({ page }) => {
// Navigate to the movies app home page
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
// Wait for page to load completely
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
// Look for The Garfield Movie and click on it
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
await expect(garfieldMovieLink).toBeVisible();
await garfieldMovieLink.click();
// Verify we're on the correct movie page
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
// Check the runtime information
// The bug: Expected runtime should be 90 min, but site shows 101 min
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
// This test will fail because of the bug (which is what we want to demonstrate)
// Once fixed, this assertion should be updated to the correct runtime (90 min)
expect(movieInfoText).toContain('90 min');
// Alternative assertion that verifies the incorrect runtime is still present
// Uncomment this and comment the above assertion to verify the bug exists
// expect(movieInfoText).toContain('101 min');
});
```

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

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

47
index.d.ts vendored
View File

@@ -16,45 +16,14 @@
*/
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
type Options = {
/**
* The browser to use (e.g., 'chrome', 'chromium', 'firefox', 'webkit', 'msedge').
*/
browser?: string;
/**
* Path to a user data directory for browser profile persistence.
*/
userDataDir?: string;
/**
* Whether to run the browser in headless mode (default: true).
*/
headless?: boolean;
/**
* Path to a custom browser executable.
*/
executablePath?: string;
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance.
*/
cdpEndpoint?: string;
/**
* Enable vision capabilities (e.g., visual automation or OCR).
*/
vision?: boolean;
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'tabs': Tab management features.
* - 'pdf': PDF generation and manipulation.
* - 'history': Browser history access.
* - 'wait': Wait and timing utilities.
* - 'files': File upload/download support.
* - 'install': Browser installation utilities.
*/
capabilities?: ToolCapability[];
export type Connection = {
server: Server;
connect(transport: Transport): Promise<void>;
close(): Promise<void>;
};
export declare function createServer(options?: Options): Promise<Server>;
export declare function createConnection(config?: Config): Promise<Connection>;
export {};

View File

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

66
package-lock.json generated
View File

@@ -1,18 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.25",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1745357020000",
"yaml": "^2.7.1",
"playwright": "1.53.0-alpha-1746832516000",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -21,7 +20,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1745357020000",
"@playwright/test": "1.53.0-alpha-1746832516000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -228,17 +227,18 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.7.0.tgz",
"integrity": "sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz",
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^4.1.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
@@ -286,13 +286,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-7xQRHhsS//elVJVt2WybJPXAy++WiE8yJzMtVFcnzdQNg9VNSbpqo4b61io5IIG1nEfB22N4BhjQ/8jPrUyu9A==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.53.0-alpha-1745357020000"
"playwright": "1.53.0-alpha-1746832516000"
},
"bin": {
"playwright": "cli.js"
@@ -1091,7 +1091,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -2786,7 +2785,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/js-yaml": {
@@ -3256,7 +3254,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3292,21 +3289,21 @@
}
},
"node_modules/pkce-challenge": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz",
"integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-evnZJIB1CRSA1HfwCkLhyqyGZybSWdNwfyyUWhBoez9ISbYMuYrTtidx75oiGVXtbKr5s8iC0+opuvagu4L1vA==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.53.0-alpha-1745357020000"
"playwright-core": "1.53.0-alpha-1746832516000"
},
"bin": {
"playwright": "cli.js"
@@ -3319,9 +3316,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.53.0-alpha-1745357020000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1745357020000.tgz",
"integrity": "sha512-3oPzOUwJ/yhNWUs3fh5UbmI1Mf18sHUDo3gxzuPwqxN3QCSFKx9Ncg7cSB+FyJCkgz7ZD8fUlzJ75YsDE+PMfA==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -3796,7 +3793,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -3809,7 +3805,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4238,7 +4233,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -4355,18 +4349,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,7 +1,8 @@
{
"name": "@playwright/mcp",
"version": "0.0.15",
"version": "0.0.25",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
@@ -16,7 +17,7 @@
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "eslint .",
"lint": "npm run update-readme && eslint . && tsc --noEmit",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
@@ -34,16 +35,15 @@
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1745357020000",
"yaml": "^2.7.1",
"playwright": "1.53.0-alpha-1746832516000",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1745357020000",
"@playwright/test": "1.53.0-alpha-1746832516000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",

View File

@@ -16,9 +16,9 @@
import { defineConfig } from '@playwright/test';
import type { Project } from '@playwright/test';
import type { TestOptions } from './tests/fixtures.js';
export default defineConfig({
export default defineConfig<TestOptions>({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
@@ -29,7 +29,8 @@ export default defineConfig({
{ name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
].filter(Boolean) as Project[],
],
});

245
src/config.ts Normal file
View File

@@ -0,0 +1,245 @@
/**
* 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 net from 'net';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import type { Config, ToolCapability } from '../config.js';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
import { sanitizeForFilePath } from './tools/utils.js';
export type CLIOptions = {
allowedOrigins?: string[];
blockedOrigins?: string[];
blockServiceWorkers?: boolean;
browser?: string;
caps?: string;
cdpEndpoint?: string;
config?: string;
device?: string;
executablePath?: string;
headless?: boolean;
host?: string;
ignoreHttpsErrors?: boolean;
isolated?: boolean;
imageResponses: boolean;
sandbox: boolean;
outputDir?: string;
port?: number;
proxyBypass?: string;
proxyServer?: string;
storageState?: string;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
vision?: boolean;
};
const defaultConfig: Config = {
browser: {
browserName: 'chromium',
launchOptions: {
channel: 'chrome',
headless: os.platform() === 'linux' && !process.env.DISPLAY,
chromiumSandbox: true,
},
contextOptions: {
viewport: null,
},
},
network: {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
};
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
const config = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (cliOptions.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'chromium':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = cliOptions.browser;
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
// Launch options
const launchOptions: LaunchOptions = {
channel,
executablePath: cliOptions.executablePath,
headless: cliOptions.headless,
};
if (browserName === 'chromium') {
(launchOptions as any).cdpPort = await findFreePort();
if (!cliOptions.sandbox) {
// --no-sandbox was passed, disable the sandbox
launchOptions.chromiumSandbox = false;
}
}
if (cliOptions.proxyServer) {
launchOptions.proxy = {
server: cliOptions.proxyServer
};
if (cliOptions.proxyBypass)
launchOptions.proxy.bypass = cliOptions.proxyBypass;
}
// Context options
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
if (cliOptions.storageState)
contextOptions.storageState = cliOptions.storageState;
if (cliOptions.userAgent)
contextOptions.userAgent = cliOptions.userAgent;
if (cliOptions.viewportSize) {
try {
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
if (isNaN(width) || isNaN(height))
throw new Error('bad values');
contextOptions.viewport = { width, height };
} catch (e) {
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
}
}
if (cliOptions.ignoreHttpsErrors)
contextOptions.ignoreHTTPSErrors = true;
if (cliOptions.blockServiceWorkers)
contextOptions.serviceWorkers = 'block';
const result: Config = {
browser: {
browserName,
isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir,
launchOptions,
contextOptions,
cdpEndpoint: cliOptions.cdpEndpoint,
},
server: {
port: cliOptions.port,
host: cliOptions.host,
},
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
vision: !!cliOptions.vision,
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
outputDir: cliOptions.outputDir,
};
if (!cliOptions.imageResponses) {
// --no-image-responses was passed, disable image responses
result.noImageResponses = true;
}
return result;
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
async function loadConfig(configFile: string | undefined): Promise<Config> {
if (!configFile)
return {};
try {
return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
} catch (error) {
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
}
}
export async function outputFile(config: Config, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir();
await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(result, fileName);
}
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
return Object.fromEntries(
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
) as Partial<T>;
}
function mergeConfig(base: Config, overrides: Config): Config {
const browser: Config['browser'] = {
...pickDefined(base.browser),
...pickDefined(overrides.browser),
launchOptions: {
...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions),
...{ assistantMode: true },
},
contextOptions: {
...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions),
},
};
if (browser.browserName !== 'chromium' && browser.launchOptions)
delete browser.launchOptions.channel;
return {
...pickDefined(base),
...pickDefined(overrides),
browser,
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
},
};
}

103
src/connection.ts Normal file
View File

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

View File

@@ -14,42 +14,44 @@
* limitations under the License.
*/
import fs from 'node:fs';
import url from 'node:url';
import os from 'node:os';
import path from 'node:path';
import * as playwright from 'playwright';
import yaml from 'yaml';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import { waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
};
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js';
import { outputFile } from './config.js';
type PendingAction = {
dialogShown: ManualPromise<void>;
};
type BrowserContextAndBrowser = {
browser?: playwright.Browser;
browserContext: playwright.BrowserContext;
};
export class Context {
readonly tools: Tool[];
readonly options: ContextOptions;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
readonly config: Config;
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(tools: Tool[], options: ContextOptions) {
constructor(tools: Tool[], config: Config) {
this.tools = tools;
this.options = options;
this.config = config;
}
modalStates(): ModalState[] {
@@ -66,6 +68,8 @@ export class Context {
modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
if (this._modalStates.length === 0)
result.push('- There is no modal state present');
for (const state of this._modalStates) {
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
@@ -84,7 +88,7 @@ export class Context {
}
async newTab(): Promise<Tab> {
const browserContext = await this._ensureBrowserContext();
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
@@ -96,9 +100,9 @@ export class Context {
}
async ensureTab(): Promise<Tab> {
const context = await this._ensureBrowserContext();
const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab)
await context.newPage();
await browserContext.newPage();
return this._currentTab!;
}
@@ -124,7 +128,7 @@ export class Context {
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
@@ -170,6 +174,17 @@ ${code.join('\n')}
};
}
if (this._downloads.length) {
result.push('', '### Downloads');
for (const entry of this._downloads) {
if (entry.finished)
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
else
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
}
result.push('');
}
if (this.tabs().length > 1)
result.push(await this.listTabsMarkdown(), '');
@@ -234,6 +249,17 @@ ${code.join('\n')}
this._pendingAction?.dialogShown.resolve();
}
async downloadStarted(tab: Tab, download: playwright.Download) {
const entry = {
download,
finished: false,
outputFile: await outputFile(this.config, download.suggestedFilename())
};
this._downloads.push(entry);
await download.saveAs(entry.outputFile);
entry.finished = true;
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
@@ -250,221 +276,127 @@ ${code.join('\n')}
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (this._browserContext && !this._tabs.length)
if (!this._tabs.length)
void this.close();
}
async close() {
if (!this._browserContext)
if (!this._browserContextPromise)
return;
const browserContext = this._browserContext;
const browser = this._browser;
this._browserContext = undefined;
this._browser = undefined;
await browserContext?.close().then(async () => {
await browser?.close();
}).catch(() => {});
const promise = this._browserContextPromise;
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => {
await browserContext.close().then(async () => {
await browser?.close();
}).catch(() => {});
});
}
private async _ensureBrowserContext() {
if (!this._browserContext) {
const context = await this._createBrowserContext();
this._browser = context.browser;
this._browserContext = context.browserContext;
for (const page of this._browserContext.pages())
this._onPageCreated(page);
this._browserContext.on('page', page => this._onPageCreated(page));
private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));
for (const origin of this.config.network.allowedOrigins)
await context.route(`*://${origin}/**`, route => route.continue());
}
if (this.config.network?.blockedOrigins?.length) {
for (const origin of this.config.network.blockedOrigins)
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
}
return this._browserContext;
}
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.options.remoteEndpoint) {
const url = new URL(this.options.remoteEndpoint);
if (this.options.browserName)
url.searchParams.set('browser', this.options.browserName);
if (this.options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
private _ensureBrowserContext() {
if (!this._browserContextPromise) {
this._browserContextPromise = this._setupBrowserContext();
this._browserContextPromise.catch(() => {
this._browserContextPromise = undefined;
});
}
return this._browserContextPromise;
}
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
const { browser, browserContext } = await this._createBrowserContext();
await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
return { browser, browserContext };
}
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (this.config.browser.browserName)
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
const browserContext = browser.contexts()[0];
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await this._launchPersistentContext();
return this.config.browser?.isolated ?
await createIsolatedContext(this.config.browser) :
await launchPersistentContext(this.config.browser);
}
}
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const browserType = playwright[browserName];
const browser = await browserType.launch(browserConfig?.launchOptions);
const browserContext = await browser.newContext(browserConfig?.contextOptions);
return { browser, browserContext };
} 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 launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName];
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
return { browserContext };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
} 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;
}
}
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._clearCollectedArtifacts();
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._console.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
console(): playwright.ConsoleMessage[] {
return this._console;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}
class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
}
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'));

View File

@@ -14,129 +14,10 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import fs from 'fs';
import { Connection, createConnection as createConnectionImpl } from './connection.js';
import { createServerWithTools } from './server';
import common from './tools/common';
import console from './tools/console';
import dialogs from './tools/dialogs';
import files from './tools/files';
import install from './tools/install';
import keyboard from './tools/keyboard';
import navigate from './tools/navigate';
import network from './tools/network';
import pdf from './tools/pdf';
import snapshot from './tools/snapshot';
import tabs from './tools/tabs';
import screen from './tools/screen';
import type { Config } from '../config.js';
import type { Tool, ToolCapability } from './tools/tool';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions } from 'playwright';
const snapshotTools: Tool<any>[] = [
...common(true),
...console,
...dialogs(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
...network,
...pdf,
...snapshot,
...tabs(true),
];
const screenshotTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...screen,
...tabs(false),
];
type Options = {
browser?: string;
userDataDir?: string;
headless?: boolean;
executablePath?: string;
cdpEndpoint?: string;
vision?: boolean;
capabilities?: ToolCapability[];
};
const packageJSON = require('../package.json');
export async function createServer(options?: Options): Promise<Server> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options?.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const userDataDir = options?.userDataDir ?? await createUserDataDir(browserName);
const launchOptions: LaunchOptions = {
headless: !!(options?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: options?.executablePath,
};
const allTools = options?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !options?.capabilities || tool.capability === 'core' || options.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,
tools,
resources: [],
browserName,
userDataDir,
launchOptions,
cdpEndpoint: options?.cdpEndpoint,
});
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
export async function createConnection(config: Config = {}): Promise<Connection> {
return createConnectionImpl(config);
}

50
src/pageSnapshot.ts Normal file
View File

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

View File

@@ -14,56 +14,57 @@
* limitations under the License.
*/
import http from 'http';
import { program } from 'commander';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveConfig } from './config.js';
import { createServer } from './index';
import { ServerList } from './server';
import assert from 'assert';
import { ToolCapability } from './tools/tool';
const packageJSON = require('../package.json');
import type { Connection } from './connection.js';
import { packageJSON } from './context.js';
program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--user-data-dir <path>', 'Path to the user data directory')
.option('--config <path>', 'path to the configuration file.')
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
.option('--executable-path <path>', 'path to the browser executable.')
.option('--headless', 'run browser in headless mode, headed by default')
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--ignore-https-errors', 'ignore https errors')
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
.option('--no-image-responses', 'do not send image responses to the client.')
.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('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => {
const serverList = new ServerList(() => createServer({
browser: options.browser,
userDataDir: options.userDataDir,
headless: options.headless,
executablePath: options.executablePath,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
capabilities: options.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
}));
setupExitWatchdog(serverList);
const config = await resolveConfig(options);
const connectionList: Connection[] = [];
setupExitWatchdog(connectionList);
if (options.port) {
startSSEServer(+options.port, serverList);
} else {
const server = await serverList.create();
await server.connect(new StdioServerTransport());
}
if (options.port)
startHttpTransport(config, +options.port, options.host, connectionList);
else
await startStdioTransport(config, connectionList);
});
function setupExitWatchdog(serverList: ServerList) {
function setupExitWatchdog(connectionList: Connection[]) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
await serverList.closeAll();
for (const connection of connectionList)
await connection.close();
process.exit(0);
};
@@ -72,65 +73,8 @@ function setupExitWatchdog(serverList: ServerList) {
process.on('SIGTERM', handleExit);
}
program.parse(process.argv);
async function startSSEServer(port: number, serverList: ServerList) {
const sessions = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
if (req.method === 'POST') {
const searchParams = new URL(`http://localhost${req.url}`).searchParams;
const sessionId = searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
res.end('Missing sessionId');
return;
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
return;
}
await transport.handlePostMessage(req, res);
return;
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
const server = await serverList.create();
res.on('close', () => {
sessions.delete(transport.sessionId);
serverList.close(server).catch(e => console.error(e));
});
await server.connect(transport);
return;
} else {
res.statusCode = 405;
res.end('Method not allowed');
}
});
httpServer.listen(port, () => {
const address = httpServer.address();
assert(address, 'Could not bind server socket');
let url: string;
if (typeof address === 'string') {
url = address;
} else {
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
url = `http://${resolvedHost}:${resolvedPort}`;
}
console.log(`Listening on ${url}`);
console.log('Put this in your client config:');
console.log(JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
}
}, undefined, 2));
});
function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim());
}
program.parse(process.argv);

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Context } from '../context';
import type { Context } from '../context.js';
export type ResourceSchema = {
uri: string;

View File

@@ -1,133 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { ContextOptions } from './context';
type Options = ContextOptions & {
name: string;
version: string;
tools: Tool[];
resources: Resource[],
};
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources } = options;
const context = new Context(tools, options);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
resources: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
})),
};
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: resources.map(resource => resource.schema) };
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool) {
return {
content: [{ type: 'text', text: `Tool "${request.params.name}" not found` }],
isError: true,
};
}
const modalStates = context.modalStates().map(state => state.type);
if ((tool.clearsModalState && !modalStates.includes(tool.clearsModalState)) ||
(!tool.clearsModalState && modalStates.length)) {
const text = [
`Tool "${request.params.name}" does not handle the modal state.`,
...context.modalStatesMarkdown(),
].join('\n');
return {
content: [{ type: 'text', text }],
isError: true,
};
}
try {
return await context.run(tool, request.params.arguments);
} catch (error) {
return {
content: [{ type: 'text', text: String(error) }],
isError: true,
};
}
});
server.setRequestHandler(ReadResourceRequestSchema, async request => {
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
if (!resource)
return { contents: [] };
const contents = await resource.read(context, request.params.uri);
return { contents };
});
const oldClose = server.close.bind(server);
server.close = async () => {
await oldClose();
await context.close();
};
return server;
}
export class ServerList {
private _servers: Server[] = [];
private _serverFactory: () => Promise<Server>;
constructor(serverFactory: () => Promise<Server>) {
this._serverFactory = serverFactory;
}
async create() {
const server = await this._serverFactory();
this._servers.push(server);
return server;
}
async close(server: Server) {
const index = this._servers.indexOf(server);
if (index !== -1)
this._servers.splice(index, 1);
await server.close();
}
async closeAll() {
await Promise.all(this._servers.map(server => server.close()));
}
}

112
src/tab.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _consoleMessages: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._consoleMessages.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.on('download', download => {
void this.context.downloadStarted(this, download);
});
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._consoleMessages.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
const e = _e as Error;
const mightBeDownload =
e.message.includes('net::ERR_ABORTED') // chromium
|| e.message.includes('Download is starting'); // firefox + webkit
if (!mightBeDownload)
throw e;
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([
downloadEvent,
new Promise(resolve => setTimeout(resolve, 500)),
]);
if (!download)
throw e;
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
consoleMessages(): playwright.ConsoleMessage[] {
return this._consoleMessages;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}

66
src/tools.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import common from './tools/common.js';
import console from './tools/console.js';
import dialogs from './tools/dialogs.js';
import files from './tools/files.js';
import install from './tools/install.js';
import keyboard from './tools/keyboard.js';
import navigate from './tools/navigate.js';
import network from './tools/network.js';
import pdf from './tools/pdf.js';
import snapshot from './tools/snapshot.js';
import tabs from './tools/tabs.js';
import screenshot from './tools/screenshot.js';
import testing from './tools/testing.js';
import vision from './tools/vision.js';
import wait from './tools/wait.js';
import type { Tool } from './tools/tool.js';
export const snapshotTools: Tool<any>[] = [
...common(true),
...console,
...dialogs(true),
...files(true),
...install,
...keyboard(true),
...navigate(true),
...network,
...pdf,
...screenshot,
...snapshot,
...tabs(true),
...testing,
...wait(true),
];
export const visionTools: Tool<any>[] = [
...common(false),
...console,
...dialogs(false),
...files(false),
...install,
...keyboard(false),
...navigate(false),
...network,
...pdf,
...tabs(false),
...testing,
...vision,
...wait(false),
];

View File

@@ -15,42 +15,23 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait',
description: 'Wait for a specified time in seconds',
inputSchema: z.object({
time: z.number().describe('The time to wait in seconds'),
}),
},
handle: async (context, params) => {
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
return {
code: [`// Waited for ${params.time} seconds`],
captureSnapshot,
waitForNetwork: false,
};
},
});
import { defineTool, type ToolFactory } from './tool.js';
const close = defineTool({
capability: 'core',
schema: {
name: 'browser_close',
title: 'Close browser',
description: 'Close the page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
await context.close();
return {
code: [`// Internal to close the page`],
code: [`await page.close()`],
captureSnapshot: false,
waitForNetwork: false,
};
@@ -61,11 +42,13 @@ const resize: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_resize',
title: 'Resize browser window',
description: 'Resize the browser window',
inputSchema: z.object({
width: z.number().describe('Width of the browser window'),
height: z.number().describe('Height of the browser window'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
@@ -91,6 +74,5 @@ const resize: ToolFactory = captureSnapshot => defineTool({
export default (captureSnapshot: boolean) => [
close,
wait(captureSnapshot),
resize(captureSnapshot)
];

View File

@@ -15,17 +15,19 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
const console = defineTool({
capability: 'core',
schema: {
name: 'browser_console_messages',
title: 'Get console messages',
description: 'Returns all console messages',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const messages = context.currentTabOrDie().console();
const messages = context.currentTabOrDie().consoleMessages();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return {
code: [`// <internal code to get console messages>`],

View File

@@ -15,18 +15,20 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const handleDialog: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_handle_dialog',
title: 'Handle a dialog',
description: 'Handle a dialog',
inputSchema: z.object({
accept: z.boolean().describe('Whether to accept the dialog.'),
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
}),
type: 'destructive',
},
handle: async (context, params) => {

View File

@@ -15,17 +15,19 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const uploadFile: ToolFactory = captureSnapshot => defineTool({
capability: 'files',
schema: {
name: 'browser_file_upload',
title: 'Upload files',
description: 'Upload one or multiple files',
inputSchema: z.object({
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
}),
type: 'destructive',
},
handle: async (context, params) => {

View File

@@ -18,20 +18,25 @@ import { fork } from 'child_process';
import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import { fileURLToPath } from 'node:url';
const install = defineTool({
capability: 'install',
schema: {
name: 'browser_install',
title: 'Install the browser specified in the config',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: z.object({}),
type: 'destructive',
},
handle: async context => {
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
const child = fork(cliPath, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];

View File

@@ -15,17 +15,19 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const pressKey: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_press_key',
title: 'Press a key',
description: 'Press a key on the keyboard',
inputSchema: z.object({
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
}),
type: 'destructive',
},
handle: async (context, params) => {

View File

@@ -15,17 +15,19 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const navigate: ToolFactory = captureSnapshot => defineTool({
capability: 'core',
schema: {
name: 'browser_navigate',
title: 'Navigate to a URL',
description: 'Navigate to a URL',
inputSchema: z.object({
url: z.string().describe('The URL to navigate to'),
}),
type: 'destructive',
},
handle: async (context, params) => {
@@ -49,8 +51,10 @@ const goBack: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_back',
title: 'Go back',
description: 'Go back to the previous page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
@@ -73,8 +77,10 @@ const goForward: ToolFactory = captureSnapshot => defineTool({
capability: 'history',
schema: {
name: 'browser_navigate_forward',
title: 'Go forward',
description: 'Go forward to the next page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
const tab = context.currentTabOrDie();

View File

@@ -15,7 +15,7 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import type * as playwright from 'playwright';
@@ -24,8 +24,10 @@ const requests = defineTool({
schema: {
name: 'browser_network_requests',
title: 'List network requests',
description: 'Returns all network requests since loading the page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {

View File

@@ -14,27 +14,30 @@
* limitations under the License.
*/
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import { sanitizeForFilePath } from './utils';
import * as javascript from '../javascript';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
});
const pdf = defineTool({
capability: 'pdf',
schema: {
name: 'browser_pdf_save',
title: 'Save as PDF',
description: 'Save page as PDF',
inputSchema: z.object({}),
inputSchema: pdfSchema,
type: 'readOnly',
},
handle: async context => {
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
const code = [
`// Save page as ${fileName}`,

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(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 default [
screenshot,
];

View File

@@ -14,24 +14,20 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import { z } from 'zod';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import * as javascript from '../javascript';
import type * as playwright from 'playwright';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { generateLocator } from './utils.js';
const snapshot = defineTool({
capability: 'core',
schema: {
name: 'browser_snapshot',
title: 'Page snapshot',
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
@@ -54,8 +50,10 @@ const click = defineTool({
capability: 'core',
schema: {
name: 'browser_click',
title: 'Click',
description: 'Perform click on a web page',
inputSchema: elementSchema,
type: 'destructive',
},
handle: async (context, params) => {
@@ -80,6 +78,7 @@ const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_drag',
title: 'Drag mouse',
description: 'Perform drag and drop between two elements',
inputSchema: z.object({
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
@@ -87,6 +86,7 @@ const drag = defineTool({
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
endRef: z.string().describe('Exact target element reference from the page snapshot'),
}),
type: 'destructive',
},
handle: async (context, params) => {
@@ -112,8 +112,10 @@ const hover = defineTool({
capability: 'core',
schema: {
name: 'browser_hover',
title: 'Hover mouse',
description: 'Hover over element on page',
inputSchema: elementSchema,
type: 'readOnly',
},
handle: async (context, params) => {
@@ -144,8 +146,10 @@ const type = defineTool({
capability: 'core',
schema: {
name: 'browser_type',
title: 'Type text',
description: 'Type text into editable element',
inputSchema: typeSchema,
type: 'destructive',
},
handle: async (context, params) => {
@@ -188,8 +192,10 @@ const selectOption = defineTool({
capability: 'core',
schema: {
name: 'browser_select_option',
title: 'Select option',
description: 'Select an option in a dropdown',
inputSchema: selectOptionSchema,
type: 'destructive',
},
handle: async (context, params) => {
@@ -210,65 +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.'),
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',
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
inputSchema: screenshotSchema,
},
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`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 action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return {
content: [{
type: 'image' as 'image',
data: screenshot.toString('base64'),
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
}]
};
};
return {
code,
action,
captureSnapshot: true,
waitForNetwork: false,
};
}
});
export default [
snapshot,
click,
@@ -276,5 +223,4 @@ export default [
hover,
type,
selectOption,
screenshot,
];

View File

@@ -15,15 +15,17 @@
*/
import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import { defineTool, type ToolFactory } from './tool.js';
const listTabs = defineTool({
capability: 'tabs',
schema: {
name: 'browser_tab_list',
title: 'List tabs',
description: 'List browser tabs',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
@@ -47,10 +49,12 @@ const selectTab: ToolFactory = captureSnapshot => defineTool({
schema: {
name: 'browser_tab_select',
title: 'Select a tab',
description: 'Select a tab by index',
inputSchema: z.object({
index: z.number().describe('The index of the tab to select'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
@@ -72,10 +76,12 @@ const newTab: ToolFactory = captureSnapshot => defineTool({
schema: {
name: 'browser_tab_new',
title: 'Open a new tab',
description: 'Open a new tab',
inputSchema: z.object({
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
@@ -99,10 +105,12 @@ const closeTab: ToolFactory = captureSnapshot => defineTool({
schema: {
name: 'browser_tab_close',
title: 'Close a tab',
description: 'Close a tab',
inputSchema: z.object({
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
}),
type: 'destructive',
},
handle: async (context, params) => {

67
src/tools/testing.ts Normal file
View File

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

View File

@@ -14,16 +14,18 @@
* limitations under the License.
*/
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { z } from 'zod';
import type { Context } from '../context';
import type { Context } from '../context.js';
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
import type { ToolCapability } from '../../config.js';
export type ToolSchema<Input extends InputType> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
type InputType = z.Schema;

View File

@@ -15,7 +15,7 @@
*/
import type * as playwright from 'playwright';
import type { Context } from '../context';
import type { Context } from '../context.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
@@ -71,5 +71,13 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}

View File

@@ -15,9 +15,9 @@
*/
import { z } from 'zod';
import { defineTool } from './tool';
import { defineTool } from './tool.js';
import * as javascript from '../javascript';
import * as javascript from '../javascript.js';
const elementSchema = z.object({
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
@@ -27,8 +27,10 @@ const screenshot = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_capture',
title: 'Take a screenshot',
description: 'Take a screenshot of the current page',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async context => {
@@ -59,11 +61,13 @@ const moveMouse = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_move_mouse',
title: 'Move mouse',
description: 'Move mouse to a given position',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
@@ -86,11 +90,13 @@ const click = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_click',
title: 'Click',
description: 'Click left mouse button',
inputSchema: elementSchema.extend({
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
}),
type: 'destructive',
},
handle: async (context, params) => {
@@ -117,9 +123,9 @@ const click = defineTool({
const drag = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_drag',
title: 'Drag mouse',
description: 'Drag left mouse button',
inputSchema: elementSchema.extend({
startX: z.number().describe('Start X coordinate'),
@@ -127,6 +133,7 @@ const drag = defineTool({
endX: z.number().describe('End X coordinate'),
endY: z.number().describe('End Y coordinate'),
}),
type: 'destructive',
},
handle: async (context, params) => {
@@ -158,14 +165,15 @@ const drag = defineTool({
const type = defineTool({
capability: 'core',
schema: {
name: 'browser_screen_type',
title: 'Type text',
description: 'Type text',
inputSchema: z.object({
text: z.string().describe('Text to type into the element'),
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
}),
type: 'destructive',
},
handle: async (context, params) => {

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

145
src/transport.ts Normal file
View File

@@ -0,0 +1,145 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import http from 'node:http';
import assert from 'node:assert';
import crypto from 'node:crypto';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createConnection } from './connection.js';
import type { Config } from '../config.js';
import type { Connection } from './connection.js';
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[]) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
return res.end('Missing sessionId');
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
return res.end('Session not found');
}
return await transport.handlePostMessage(req, res);
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
const connection = await createConnection(config);
await connection.connect(transport);
connectionList.push(connection);
res.on('close', () => {
sessions.delete(transport.sessionId);
connection.close().catch(e => {
// eslint-disable-next-line no-console
console.error(e);
});
});
return;
}
res.statusCode = 405;
res.end('Method not allowed');
}
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
return;
}
return await transport.handleRequest(req, res);
}
if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: sessionId => {
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
if (transport.sessionId)
sessions.delete(transport.sessionId);
};
const connection = await createConnection(config);
connectionList.push(connection);
await Promise.all([
connection.connect(transport),
transport.handleRequest(req, res),
]);
return;
}
res.statusCode = 400;
res.end('Invalid request');
}
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp'))
await handleStreamable(config, req, res, streamableSessions, connectionList);
else
await handleSSE(config, req, res, url, sseSessions, connectionList);
});
httpServer.listen(port, hostname, () => {
const address = httpServer.address();
assert(address, 'Could not bind server socket');
let url: string;
if (typeof address === 'string') {
url = address;
} else {
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
url = `http://${resolvedHost}:${resolvedPort}`;
}
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify({
'mcpServers': {
'playwright': {
'url': `${url}/sse`
}
}
}, undefined, 2),
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.log(message);
});
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
@@ -23,6 +23,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_console_messages',
'browser_drag',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
@@ -42,7 +43,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
'browser_wait_for',
]));
});
@@ -52,6 +53,7 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_close',
'browser_console_messages',
'browser_file_upload',
'browser_generate_playwright_test',
'browser_handle_dialog',
'browser_install',
'browser_navigate_back',
@@ -70,15 +72,10 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
'browser_wait_for',
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],

View File

@@ -14,20 +14,18 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
expect(await client.callTool({
name: 'browser_click',
@@ -39,7 +37,6 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
@@ -50,7 +47,27 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
- Page Title:
- Page Snapshot
\`\`\`yaml
- text: hello world
- generic [ref=e1]: hello world
\`\`\`
`);
});
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
const port = 3200 + test.info().parallelIndex;
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port);
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});

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

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

View File

@@ -14,19 +14,28 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_console_messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
console.log("Hello, world!");
console.error("Error");
</script>
</html>
`, 'text/html');
test('browser_console_messages', async ({ client }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
url: server.PREFIX,
},
});
const resource = await client.callTool({
name: 'browser_console_messages',
arguments: {},
});
expect(resource).toHaveTextContent([
'[LOG] Hello, world!',

View File

@@ -14,44 +14,45 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_navigate', async ({ client }) => {
test('browser_navigate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
// Navigate to ${server.HELLO_WORLD}
await page.goto('${server.HELLO_WORLD}');
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
- generic [ref=e1]: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client }) => {
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`
- Ran Playwright code:
@@ -60,28 +61,34 @@ test('browser_click', async ({ client }) => {
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=s2e3]
- button "Submit" [ref=e2]
\`\`\`
`);
});
test('browser_select_option', async ({ client }) => {
test('browser_select_option', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<select>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
ref: 'e2',
values: ['bar'],
},
})).toHaveTextContent(`
@@ -91,30 +98,37 @@ test('browser_select_option', async ({ client }) => {
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- combobox [ref=e2]:
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
});
test('browser_select_option (multiple)', async ({ client }) => {
test('browser_select_option (multiple)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<select multiple>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
<option value="baz">Baz</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
ref: 'e2',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`
@@ -124,52 +138,62 @@ test('browser_select_option (multiple)', async ({ client }) => {
await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
- listbox [ref=e2]:
- option "Foo" [ref=e3]
- option "Bar" [selected] [ref=e4]
- option "Baz" [selected] [ref=e5]
\`\`\`
`);
});
test('browser_type', async ({ client }) => {
test('browser_type', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
ref: 'e2',
text: 'Hi!',
submit: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
});
test('browser_type (slowly)', async ({ client }) => {
test('browser_type (slowly)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
ref: 'e2',
text: 'Hi!',
submit: true,
slowly: true,
@@ -177,7 +201,6 @@ test('browser_type (slowly)', async ({ client }) => {
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent([
'[LOG] Key pressed: H Text: ',
'[LOG] Key pressed: i Text: H',
@@ -186,12 +209,18 @@ test('browser_type (slowly)', async ({ client }) => {
].join('\n'));
});
test('browser_resize', async ({ client }) => {
test('browser_resize', async ({ client, server }) => {
server.setContent('/', `
<title>Resize Test</title>
<body>
<div id="size">Waiting for resize...</div>
<script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
</script>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
},
arguments: { url: server.PREFIX },
});
const response = await client.callTool({
@@ -206,5 +235,5 @@ test('browser_resize', async ({ client }) => {
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
});

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

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

View File

@@ -14,24 +14,23 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
test('alert dialog', async ({ client }) => {
test('alert dialog', async ({ client, server }) => {
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
@@ -55,29 +54,35 @@ await page.getByRole('button', { name: 'Button' }).click();
// <internal code to handle "alert" dialog>
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
- Page Title: Title
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot
\`\`\`yaml
- button "Button" [ref=s2e3]
- button "Button" [ref=e2]
\`\`\`
`);
});
test('two alert dialogs', async ({ client }) => {
test('two alert dialogs', async ({ client, server }) => {
test.fixme(true, 'Race between the dialog and ariaSnapshot');
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
@@ -98,19 +103,24 @@ await page.getByRole('button', { name: 'Button' }).click();
expect(result).not.toContainTextContent('### Modal state');
});
test('confirm dialog (true)', async ({ client }) => {
test('confirm dialog (true)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
@@ -126,23 +136,28 @@ test('confirm dialog (true)', async ({ client }) => {
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "true"
- generic [ref=e1]: "true"
\`\`\``);
});
test('confirm dialog (false)', async ({ client }) => {
test('confirm dialog (false)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
@@ -156,23 +171,28 @@ test('confirm dialog (false)', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: "false"
- generic [ref=e1]: "false"
\`\`\``);
});
test('prompt dialog', async ({ client }) => {
test('prompt dialog', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
@@ -187,6 +207,6 @@ test('prompt dialog', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- text: Answer
- generic [ref=e1]: Answer
\`\`\``);
});

View File

@@ -14,32 +14,47 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
import path from 'path';
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
server.setContent('/', `
<input type="file" />
<button>Button</button>
`, 'text/html');
test('browser_file_upload', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- generic [ref=s1e2]:
- button "Choose File" [ref=s1e3]
- button "Button" [ref=s1e4]
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
{
expect(await client.callTool({
name: 'browser_file_upload',
arguments: { paths: [] },
})).toHaveTextContent(`
The tool "browser_file_upload" can only be used when there is related modal state present.
### Modal state
- There is no modal state present
`.trim());
}
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
const filePath = test.info().outputPath('test.txt');
const filePath = localOutputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
@@ -53,9 +68,9 @@ test('browser_file_upload', async ({ client }) => {
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- generic [ref=s3e2]:
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
}
@@ -64,7 +79,7 @@ test('browser_file_upload', async ({ client }) => {
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e3',
ref: 'e2',
},
});
@@ -76,7 +91,7 @@ test('browser_file_upload', async ({ client }) => {
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e4',
ref: 'e3',
},
});
@@ -85,3 +100,46 @@ test('browser_file_upload', async ({ client }) => {
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
});
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
server.setContent('/download', 'Data', 'text/plain');
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent('- link "Download" [ref=e2]');
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
});
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
server.route('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Disposition': 'attachment; filename=test.txt',
});
res.end('Hello world!');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX + 'download',
},
})).toContainTextContent('### Downloads');
});

View File

@@ -14,32 +14,41 @@
* limitations under the License.
*/
import fs from 'fs';
import url from 'url';
import path from 'path';
import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { spawn } from 'child_process';
import { TestServer } from './testserver';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { TestServer } from './testserver/index.ts';
import type { Config } from '../config';
export type TestOptions = {
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
};
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { args?: string[] }) => Promise<Client>;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
cdpEndpoint: (port?: number) => Promise<string>;
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
localOutputPath: (filePath: string) => string;
};
type WorkerFixtures = {
mcpHeadless: boolean;
mcpBrowser: string | undefined;
_workerServers: { server: TestServer, httpsServer: TestServer };
};
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => {
await use(await startClient());
@@ -49,23 +58,29 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
await use(await startClient({ args: ['--vision'] }));
},
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
let client: StdioClientTransport | undefined;
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
use(async options => {
const args = ['--user-data-dir', userDataDir];
await use(async options => {
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox');
if (mcpHeadless)
args.push('--headless');
if (mcpBrowser)
args.push(`--browser=${mcpBrowser}`);
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
});
const client = new Client({ name: 'test', version: '1.0.0' });
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
args.push(`--config=${path.relative(configDir, configFile)}`);
}
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode);
await client.connect(transport);
await client.ping();
return client;
@@ -81,33 +96,54 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
},
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const executablePath = chromium.executablePath();
const browserProcess = spawn(executablePath, [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
`data:text/html,hello world`,
], {
stdio: 'pipe',
let browserProcess: ChildProcessWithoutNullStreams | undefined;
await use(async port => {
if (!port)
port = 3200 + test.info().parallelIndex;
if (browserProcess)
return `http://localhost:${port}`;
browserProcess = spawn(chromium.executablePath(), [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--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 `http://localhost:${port}`;
});
await new Promise<void>(resolve => {
browserProcess.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
});
if (!browserProcess)
return resolve();
browserProcess.on('exit', () => resolve());
browserProcess.kill();
});
await use(`http://localhost:${port}`);
browserProcess.kill();
},
mcpHeadless: [async ({ headless }, use) => {
mcpHeadless: async ({ headless }, use) => {
await use(headless);
}, { scope: 'worker' }],
},
mcpBrowser: ['chrome', { option: true, scope: 'worker' }],
mcpBrowser: ['chrome', { option: true }],
mcpMode: [undefined, { option: true }],
localOutputPath: async ({ mcpMode }, use, testInfo) => {
await use(filePath => {
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
return testInfo.outputPath(filePath);
});
},
_workerServers: [async ({}, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4;
@@ -135,6 +171,24 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
},
});
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
return new StdioClientTransport({
command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
});
}
return new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
env: process.env as Record<string, string>,
});
}
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({

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

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

View File

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

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

@@ -0,0 +1,24 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
})).toContainTextContent(`No open pages available.`);
});

View File

@@ -14,36 +14,122 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
test('test reopen browser', async ({ client }) => {
import { test, expect } from './fixtures.js';
test('test reopen browser', async ({ client, server }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
name: 'browser_close',
arguments: {},
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('executable path', async ({ startClient }) => {
test('executable path', async ({ startClient, server }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(response).toContainTextContent(`executable doesn't exist`);
});
test('persistent context', async ({ startClient, server }) => {
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
localStorage.setItem('test', 'test');
</script>
`, 'text/html');
const client = await startClient();
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await new Promise(resolve => setTimeout(resolve, 3000));
await client.callTool({
name: 'browser_close',
});
const 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 = await startClient({ args: [`--isolated`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: NO`);
await client.callTool({
name: 'browser_close',
});
const 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, localOutputPath }) => {
const storageStatePath = localOutputPath('storage-state.json');
await fs.promises.writeFile(storageStatePath, JSON.stringify({
origins: [
{
origin: server.PREFIX,
localStorage: [{ name: 'test', value: 'session-value' }],
},
],
}));
server.setContent('/', `
<body>
</body>
<script>
document.body.textContent = 'Storage: ' + localStorage.getItem('test');
</script>
`, 'text/html');
const client = await startClient({ args: [
`--isolated`,
`--storage-state=${storageStatePath}`,
] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(response).toContainTextContent(`Storage: session-value`);
});

View File

@@ -14,18 +14,14 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
test('browser_network_requests', async ({ client, server }) => {
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<button onclick="fetch('/json')">Click me</button>`);
});
server.setContent('/', `
<button onclick="fetch('/json')">Click me</button>
`, 'text/html');
server.route('/json', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ name: 'John Doe' }));
});
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
await client.callTool({
name: 'browser_navigate',
@@ -38,12 +34,12 @@ test('browser_network_requests', async ({ client, server }) => {
name: 'browser_click',
arguments: {
element: 'Click me button',
ref: 's1e3',
ref: 'e2',
},
});
expect.poll(() => client.callTool({
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
arguments: {},
})).toHaveTextContent(`[GET] http://localhost:8907/json => [200] OK`);
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
});

View File

@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
test('save as pdf unavailable', async ({ startClient }) => {
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient, server }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
@@ -30,18 +30,49 @@ test('save as pdf unavailable', async ({ startClient }) => {
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client, mcpBrowser }) => {
test('save as pdf', async ({ client, mcpBrowser, server }) => {
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- text: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
arguments: {},
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
expect(await client.callTool({
name: 'browser_pdf_save',
arguments: {
filename: 'output.pdf',
},
})).toEqual({
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.pdf$/);
});

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test, expect } from './fixtures.ts';
const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
const fetchPage = async (client: Client, url: string) => {
const result = await client.callTool({
name: 'browser_navigate',
arguments: {
url,
},
});
return JSON.stringify(result, null, 2);
};
test('default to allow all', async ({ server, client }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
test('blocked works', async ({ startClient }) => {
const client = await startClient({
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('allowed works', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
test('blocked takes precedence', async ({ startClient }) => {
const client = await startClient({
args: [
'--blocked-origins', 'example.com',
'--allowed-origins', 'example.com',
],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
const client = await startClient({
args: ['--allowed-origins', 'playwright.dev'],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
args: ['--blocked-origins', 'example.com'],
});
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});

View File

@@ -14,19 +14,18 @@
* limitations under the License.
*/
import { test, expect } from './fixtures';
import fs from 'fs';
test('browser_take_screenshot (viewport)', async ({ client }) => {
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{
@@ -42,19 +41,17 @@ test('browser_take_screenshot (viewport)', async ({ client }) => {
});
});
test('browser_take_screenshot (element)', async ({ client }) => {
test('browser_take_screenshot (element)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
},
})).toContainTextContent(`[ref=s1e3]`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
element: 'hello button',
ref: 's1e3',
ref: 'e1',
},
})).toEqual({
content: [
@@ -64,7 +61,155 @@ test('browser_take_screenshot (element)', async ({ client }) => {
type: 'image',
},
{
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
],
});
});
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--output-dir', outputDir],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
});
for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
const ext = raw ? 'png' : 'jpeg';
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: { raw },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
);
});
}
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
filename: 'output.jpeg',
},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.jpeg$/);
});
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
const client = await startClient({
config: {
noImageResponses: true,
},
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],
});
});
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
const client = await startClient({ clientName: 'cursor:vscode' });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
});
expect(await client.callTool({
name: 'browser_take_screenshot',
})).toEqual({
content: [
{
text: expect.stringContaining(`Screenshot viewport and save it as`),
type: 'text',
},
],

View File

@@ -14,29 +14,90 @@
* limitations under the License.
*/
import url from 'node:url';
import http from 'node:http';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test } from './fixtures';
import type { AddressInfo } from 'node:net';
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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
test('sse transport', async () => {
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
import { createConnection } from '@playwright/mcp';
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
} finally {
cp.kill();
}
import { test as baseTest, expect } from './fixtures.js';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const test = baseTest.extend<{ serverEndpoint: string }>({
serverEndpoint: async ({}, use) => {
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
await use(url);
} finally {
cp.kill();
}
},
});
test('sse transport', async ({ serverEndpoint }) => {
const transport = new SSEClientTransport(new URL(serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
});
test('streamable http transport', async ({ serverEndpoint }) => {
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
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

@@ -16,7 +16,7 @@
import { chromium } from 'playwright';
import { test, expect } from './fixtures';
import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -32,7 +32,6 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
@@ -41,7 +40,6 @@ test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
@@ -63,7 +61,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
@@ -82,7 +80,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- text: Body two
- generic [ref=e1]: Body two
\`\`\``);
});
@@ -110,7 +108,7 @@ test('select tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=e1]: Body one
\`\`\``);
});
@@ -137,21 +135,21 @@ test('close tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- text: Body one
- generic [ref=e1]: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
const browser = await chromium.connectOverCDP(cdpEndpoint);
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
const browser = await chromium.connectOverCDP(await cdpEndpoint());
const [context] = browser.contexts();
const pages = context.pages();
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<title>Title</title><body>Body</body>',
},
arguments: { url: server.PREFIX },
});
expect(pages.length).toBe(1);

View File

@@ -16,13 +16,18 @@
*/
import fs from 'fs';
import url from 'node:url';
import http from 'http';
import https from 'https';
import path from 'path';
import debug from 'debug';
const fulfillSymbol = Symbol('fulfil callback');
const rejectSymbol = Symbol('reject callback');
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
export class TestServer {
private _server: http.Server;
readonly debugServer: any;
@@ -33,6 +38,7 @@ export class TestServer {
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
readonly HELLO_WORLD: string;
static async create(port: number): Promise<TestServer> {
const server = new TestServer(port);
@@ -42,8 +48,8 @@ export class TestServer {
static async createHTTPS(port: number): Promise<TestServer> {
const server = new TestServer(port, {
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
passphrase: 'aaaa',
});
await new Promise(x => server._server.once('listening', x));
@@ -56,14 +62,15 @@ export class TestServer {
else
this._server = http.createServer(this._onRequest.bind(this));
this._server.listen(port);
this.debugServer = require('debug')('pw:testserver');
this.debugServer = debug('pw:testserver');
const cross_origin = '127.0.0.1';
const same_origin = 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
}
setCSP(path: string, csp: string) {
@@ -83,6 +90,13 @@ export class TestServer {
this._routes.set(path, handler);
}
setContent(path: string, content: string, mimeType: string) {
this.route(path, (req, res) => {
res.writeHead(200, { 'Content-Type': mimeType });
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
});
}
redirect(from: string, to: string) {
this.route(from, (req, res) => {
const headers = this._extraHeaders.get(req.url!) || {};
@@ -115,6 +129,15 @@ export class TestServer {
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null, error);
this._requestSubscribers.clear();
this.setContent('/favicon.ico', '', 'image/x-icon');
this.setContent('/', ``, 'text/html');
this.setContent('/hello-world', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
}
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
@@ -139,7 +162,11 @@ export class TestServer {
this._requestSubscribers.delete(path);
}
const handler = this._routes.get(path);
if (handler)
if (handler) {
handler.call(null, request, response);
} else {
response.writeHead(404);
response.end();
}
}
}

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

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

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

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

4
tsconfig.all.json Normal file
View File

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

View File

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

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

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

View File

@@ -16,163 +16,178 @@
*/
// @ts-check
const fs = require('node:fs');
const path = require('node:path');
const zodToJsonSchema = require('zod-to-json-schema').default;
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zodToJsonSchema from 'zod-to-json-schema'
const commonTools = require('../lib/tools/common').default;
const consoleTools = require('../lib/tools/console').default;
const dialogsTools = require('../lib/tools/dialogs').default;
const filesTools = require('../lib/tools/files').default;
const installTools = require('../lib/tools/install').default;
const keyboardTools = require('../lib/tools/keyboard').default;
const navigateTools = require('../lib/tools/navigate').default;
const pdfTools = require('../lib/tools/pdf').default;
const snapshotTools = require('../lib/tools/snapshot').default;
const tabsTools = require('../lib/tools/tabs').default;
const screenTools = require('../lib/tools/screen').default;
import commonTools from '../lib/tools/common.js';
import consoleTools from '../lib/tools/console.js';
import dialogsTools from '../lib/tools/dialogs.js';
import filesTools from '../lib/tools/files.js';
import installTools from '../lib/tools/install.js';
import keyboardTools from '../lib/tools/keyboard.js';
import navigateTools from '../lib/tools/navigate.js';
import networkTools from '../lib/tools/network.js';
import pdfTools from '../lib/tools/pdf.js';
import snapshotTools from '../lib/tools/snapshot.js';
import tabsTools from '../lib/tools/tabs.js';
import screenshotTools from '../lib/tools/screenshot.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 = {
'Snapshot-based Interactions': [
'Interactions': [
...snapshotTools,
],
'Vision-based Interactions': [
...screenTools
],
'Tab Management': [
...tabsTools(true),
...keyboardTools(true),
...waitTools(true),
...filesTools(true),
...dialogsTools(true),
],
'Navigation': [
...navigateTools(true),
],
'Keyboard': [
...keyboardTools(true)
],
'Console': [
...consoleTools
],
'Files and Media': [
...filesTools(true),
...pdfTools
'Resources': [
...screenshotTools,
...pdfTools,
...networkTools,
...consoleTools,
],
'Utilities': [
...commonTools(true),
...installTools,
...dialogsTools(true),
...commonTools(true),
],
'Tabs': [
...tabsTools(true),
],
'Testing': [
...testTools,
],
'Vision mode': [
...visionTools,
...keyboardTools(),
...waitTools(false),
...filesTools(false),
...dialogsTools(false),
],
};
const kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
const kEndMarker = `<!--- End of generated section -->`;
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
/**
* @param {ParsedToolSchema} tool
* @returns {string}
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
* @returns {string[]}
*/
function formatToolForReadme(tool) {
const lines = /** @type {string[]} */ ([]);
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->\n\n`);
lines.push(`- **${tool.name}**\n`);
lines.push(` - Description: ${tool.description}\n`);
lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
lines.push(``);
lines.push(`- **${tool.name}**`);
lines.push(` - Title: ${tool.title}`);
lines.push(` - Description: ${tool.description}`);
if (tool.parameters && tool.parameters.length > 0) {
lines.push(` - Parameters:\n`);
tool.parameters.forEach(param => {
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
const requiredParams = inputSchema.required || [];
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
lines.push(` - Parameters:`);
Object.entries(inputSchema.properties).forEach(([name, param]) => {
const optional = !requiredParams.includes(name);
const meta = /** @type {string[]} */ ([]);
if (param.type)
meta.push(param.type);
if (param.optional)
if (optional)
meta.push('optional');
lines.push(` - \`${param.name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}\n`);
lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
});
} else {
lines.push(` - Parameters: None\n`);
lines.push(` - Parameters: None`);
}
lines.push('\n');
return lines.join('');
lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
lines.push('');
return lines;
}
/**
* @typedef {{
* name: any;
* description: any;
* parameters: {
* name: string;
* description: string;
* optional: boolean;
* type: string;
* }[];
*}} ParsedToolSchema
* @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');
/**
* @param {import('../src/tools/tool').ToolSchema<any>} schema
* @returns {ParsedToolSchema}
*/
function processToolSchema(schema) {
const inputSchema = /** @type {import('zod-to-json-schema').JsonSchema7ObjectType} */ zodToJsonSchema(schema.inputSchema || {});
if (inputSchema.type !== 'object')
throw new Error(`Tool ${schema.name} input schema is not an object`);
// In JSON Schema, properties are considered optional unless listed in the required array
const requiredParams = inputSchema?.required || [];
const parameters = Object.entries(inputSchema.properties).map(([name, prop]) => {
return {
name,
description: prop.description || '',
optional: !requiredParams.includes(name),
type: /** @type {any} */ (prop).type,
};
});
return {
name: schema.name,
description: schema.description,
parameters
};
return [
content.slice(0, startMarkerIndex + startMarker.length),
'',
generatedLines.join('\n'),
'',
content.slice(endMarkerIndex),
].join('\n');
}
async function updateReadme() {
/**
* @param {string} content
* @returns {Promise<string>}
*/
async function updateTools(content) {
console.log('Loading tool information from compiled modules...');
// Count the tools processed
const totalTools = Object.values(categories).flat().length;
console.log(`Found ${totalTools} tools`);
const generatedLines = /** @type {string[]} */ ([]);
for (const [category, categoryTools] of Object.entries(categories)) {
generatedLines.push(`### ${category}\n\n`);
for (const tool of categoryTools) {
const scheme = processToolSchema(tool.schema);
generatedLines.push(formatToolForReadme(scheme));
}
generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
generatedLines.push('');
for (const tool of categoryTools)
generatedLines.push(...formatToolForReadme(tool.schema));
generatedLines.push(`</details>`);
generatedLines.push('');
}
const readmePath = path.join(__dirname, '..', 'README.md');
const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of tools generated section -->`;
return updateSection(content, startMarker, endMarker, generatedLines);
}
/**
* @param {string} content
* @returns {Promise<string>}
*/
async function updateOptions(content) {
console.log('Listing options...');
const output = execSync('node cli.js --help');
const lines = output.toString().split('\n');
const firstLine = lines.findIndex(line => line.includes('--version'));
lines.splice(0, firstLine + 1);
const lastLine = lines.findIndex(line => line.includes('--help'));
lines.splice(lastLine);
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of options generated section -->`;
return updateSection(content, startMarker, endMarker, [
'```',
'> npx @playwright/mcp@latest --help',
...lines,
'```',
]);
}
async function updateReadme() {
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const startMarker = readmeContent.indexOf(kStartMarker);
const endMarker = readmeContent.indexOf(kEndMarker);
if (startMarker === -1 || endMarker === -1)
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');
const withTools = await updateTools(readmeContent);
const withOptions = await updateOptions(withTools);
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
console.log('README updated successfully');
}
// Run the update
updateReadme().catch(err => {
console.error('Error updating README:', err);
process.exit(1);