Compare commits

..

103 Commits

Author SHA1 Message Date
Pavel Feldman
cd9819d0e8 chore: mark v0.0.61 (#1349) 2026-01-26 14:54:15 -08:00
Pavel Feldman
542b74d2b4 chore: remove dummy cli tests (#1348) 2026-01-26 14:33:53 -08:00
Pavel Feldman
15c299778a chore: read esbuild deps (#1347) 2026-01-26 14:20:58 -08:00
Pavel Feldman
5e0ac89c28 chore: split mcp into mcp and cli (#1346) 2026-01-26 14:17:36 -08:00
Pavel Feldman
9e176c409f chore: mark v0.0.60 (#1345) 2026-01-26 10:34:02 -08:00
Pavel Feldman
44fa8026c9 Update README for clarity on MCP and CLI usage 2026-01-25 18:15:16 -08:00
Pavel Feldman
d6414a6426 Fix formatting and punctuation in README.md 2026-01-25 18:08:10 -08:00
Pavel Feldman
c8a520fc48 Update readme 2026-01-25 18:04:04 -08:00
Pavel Feldman
f531b2c9cb chore: mark v0.0.59 (#1340) 2026-01-25 11:20:28 -08:00
Pavel Feldman
4b62f68979 chore: render nicer config (#1339) 2026-01-25 11:06:30 -08:00
Pavel Feldman
5b497bcca8 chore: roll pw to latest (#1338) 2026-01-25 11:00:23 -08:00
sno
00b9c54515 fix: update extension README link to packages/extension (#1335) 2026-01-25 10:17:32 -08:00
Pavel Feldman
79111366a9 chore: mark v0.0.58 (#1336) 2026-01-23 17:18:55 -08:00
Pavel Feldman
956b79a1ab chore: mark v0.0.57 (#1332) 2026-01-23 16:08:05 -08:00
Pavel Feldman
b58ad48e0a chore: roll pw to latest (#1334) 2026-01-23 16:06:09 -08:00
Pavel Feldman
41fba2bd71 chore: roll playwright to latest (#1331) 2026-01-23 19:31:12 +00:00
Dmitry Gozman
cd2b589338 chore: add "playwright-cli" binary (#1330) 2026-01-23 17:52:48 +00:00
Dmitry Gozman
fbd62cd838 chore: roll to 1.59.0-alpha-1769176698000 (#1327) 2026-01-23 17:24:40 +00:00
Dmitry Gozman
6aab683338 chore: monorepo (#1325) 2026-01-23 10:37:33 +00:00
Yury Semikhatsky
85c64bbe0f chore: add cli package (#1320) 2026-01-22 07:54:03 +00:00
dependabot[bot]
b213c187b0 chore(deps-dev): bump qs from 6.14.0 to 6.14.1 (#1292) 2026-01-16 10:32:34 -08:00
Yury Semikhatsky
412f6dc6fe chore: mark v0.0.55 (#1310) 2026-01-16 10:31:07 -08:00
Yury Semikhatsky
4b1a6842b1 chore: roll 1.58.0-alpha-2026-01-16 (#1312) 2026-01-16 09:49:16 -08:00
Yury Semikhatsky
9cc61b4faf chore: roll 1.58.0-alpha-2026-01-15 (#1309) 2026-01-15 11:18:57 -08:00
Yury Semikhatsky
33b4c00923 docs: reformat cline instructions, follow up to #1297 (#1303) 2026-01-09 14:59:08 -08:00
Alex Tumanov
f5ed83a4ca docs: add Cline configuration to Readme.md (#1297) 2026-01-09 14:18:01 -08:00
Yury Semikhatsky
2f7467ba29 chore: mark v0.0.55 (#1301) 2026-01-09 09:57:39 -08:00
Yury Semikhatsky
d47197f41f chore: roll 1.58.0-alpha-2026-01-07 (#1300) 2026-01-07 09:45:30 -08:00
Yury Semikhatsky
dba2fd054d Revert "Add step for publishing to MCP Registry #1197" (#1289) 2025-12-30 17:57:46 -08:00
Pavel Feldman
075397e57e chore: mark v0.0.54 (#1285) 2025-12-29 11:02:27 -08:00
Yury Semikhatsky
e8b471ec60 chore: roll 1.58.0-alpha-2025-12-29 (#1287) 2025-12-29 09:55:46 -08:00
Muhammad Salman
c806df7b13 docs: point to monorepo for the source code (#1282) 2025-12-26 18:10:02 -08:00
Yury Semikhatsky
a0b4ffbe15 chore(extensions): allow connections only from 127.0.0.1 (#1275)
Requires https://github.com/microsoft/playwright/pull/38626
2025-12-19 18:42:34 -08:00
Yury Semikhatsky
0a6f1c4ea4 chore: mark v0.0.53 (#1277) 2025-12-19 18:32:16 -08:00
Yury Semikhatsky
c784f93a65 chore: roll 1.58.0-alpha-1766189059000 (#1276) 2025-12-19 17:44:20 -08:00
Yury Semikhatsky
10c340c0b3 chore: mark v0.0.52 (#1256) 2025-12-11 11:01:20 -08:00
Yury Semikhatsky
850520321b chore: roll 1.58.0-alpha-2025-12-11 (#1255) 2025-12-11 10:25:03 -08:00
Pavel Feldman
b717a2a8ed chore: mark v0.0.51 (#1248) 2025-12-08 08:13:35 -08:00
Pavel Feldman
ff9544db83 chore: mark v0.0.50 (#1244) 2025-12-05 13:24:54 -08:00
Devraj Mehta
c85ee1a6ec docs: update README with Copilot CLI usage instructions (#1243) 2025-12-05 12:52:12 -08:00
Pavel Feldman
0fcb25d118 chore: mark v0.0.49 (#1232) 2025-11-30 11:28:12 -08:00
Simon Knott
f4df37ca71 chore: generate config section from config.d.ts (#1224) 2025-11-28 15:38:42 -08:00
Pavel Feldman
64f65ccd10 chore: mark v0.0.48 (#1218) 2025-11-21 14:34:32 -08:00
AJ (@techfren)
3f7e2d1b45 docs: add authentication token instructions to extension README (#1207) 2025-11-21 11:28:37 -08:00
Simon Knott
e0cb424dea docs: update config file section (#1209)
Copy-paste of
7671e2b7f0/packages/playwright/src/mcp/config.d.ts (L21).
2025-11-19 17:06:43 +01:00
Joel Verhagen
86e0020b4a Update version to 0.0.47 in server.json (#1204) 2025-11-18 08:47:32 -08:00
Joel Verhagen
009aa9275b Add step for publishing to MCP Registry (#1197)
This adds a new step for the `release` event to publish to the Official
MCP Registry.

Summary of changes:
- Add `server.json` which is the MCP Server manifest
  - Used metadata from the GH MCP Registry
  - Used the name `com.microsoft/playwright-mcp`
- Add `publish-release-mcp-registry` job that runs manually
   - Install Microsoft Go for compliant Go crypto
   - Log in to Azure for Key Vault access
   - Publish to the MCP Registry in the `microsoft.com` namespace.
- Add `mcpName` to the `package.json` which the MCP Registry validates.

The publishing requires some secret variables:
- `AZURE_MCP_REGISTRY_CLIENT_ID` - service principal client ID (app ID)
with Get Key and Sign access to a Key Vault
- `AZURE_MCP_REGISTRY_TENANT_ID` - tenant ID for the service principal
(should be a production tenant)
- `AZURE_MCP_REGISTRY_SUBSCRIPTION_ID` - Azure subscription ID holding
the Key Vault
- `KV_NAME` - the Key Vault name, e.g. `mykeyvault`
- `KV_KEY_NAME` - the name of a Key in the Key Vault, needs to be ECDSA
P-384.

I can provide internal documentation for how to set up the service
principal (managed identity is easiest), vault, and key as well as
registering the key so it can be used for the `microsoft.com` namespace.

**Note:** after the new job is proven to work as expected, we can switch
it to automatically running after the `publish-release-npm` job on the
`release` event.
2025-11-17 13:43:07 -08:00
Pavel Feldman
c016643bf9 chore: mark v0.0.47 (#1198) 2025-11-14 12:23:56 -08:00
Pavel Feldman
8cc557d677 chore: mark v0.0.46 (#1188) 2025-11-07 13:41:19 -08:00
Ben Tossell
15d382b940 docs: add Factory installation instructions (#1184) 2025-11-04 08:30:14 -08:00
Pavel Feldman
e72701b21c chore: mark v0.0.45 (#1179) 2025-10-31 14:05:32 -07:00
Dmitry Gozman
8ee8445342 chore: roll to 1.57.0-alpha-1761929702000 (#1178) 2025-10-31 20:16:50 +00:00
Raduan A.
ac6e678135 docs: add codex CLI command to README (#1168) 2025-10-29 17:46:42 -07:00
Yury Semikhatsky
b945ace746 chore: mark v0.0.44 (#1165) 2025-10-24 12:33:27 -07:00
Yury Semikhatsky
b3dce4097e chore: roll 1.57.0-alpha-2025-10-24 (#1164) 2025-10-24 11:29:05 -07:00
Doan Bac Tam
67ed859c2a docs: update with Kiro IDE MCP Servers details (#1143)
Added Kiro IDE MCP Servers documentation with example configuration.
2025-10-21 18:40:09 -07:00
Pavel Feldman
7da5e7273c chore: add docker run for testing purposes (#1153) 2025-10-21 18:39:27 -07:00
dependabot[bot]
fe59d4b35f chore(deps-dev): bump vite from 5.4.20 to 5.4.21 in /extension (#1156) 2025-10-21 08:06:41 -07:00
Pavel Feldman
a03ec7ad56 chore: mark v0.0.43 (#1151) 2025-10-16 13:08:38 -07:00
Yury Semikhatsky
ad14743235 chore: roll 1.57.0-alpha-2025-10-16 (#1150) 2025-10-16 12:55:34 -07:00
Pavel Feldman
a9ffccd40f docs: fix the testing capability docs (#1149) 2025-10-16 11:59:45 -07:00
Yury Semikhatsky
b4e016a0b8 chore: mark v0.0.42 (#1125) 2025-10-09 14:57:41 -07:00
Yury Semikhatsky
e17bf17dff chore: roll 1.57.0-alpha-2025-10-09 (#1123) 2025-10-09 13:32:16 -07:00
Isuru-F
7ee5c87a4b docs: add Amp setup instructions to README (#1105) 2025-10-03 14:00:36 -07:00
TheLazyIndianTechie
2817952d0d docs: updated Warp MCP documentation (#1104) 2025-10-03 13:59:34 -07:00
Kim Hallberg
fb900a8827 docs: add instructions for Warp (#1103) 2025-10-02 13:05:41 -07:00
Yury Semikhatsky
29e532687c chore: mark v0.0.41 (#1099) 2025-10-01 15:54:05 -07:00
Yury Semikhatsky
d149b89889 chore: roll to 1.56.0-alpha-2025-10-01 (#1098) 2025-10-01 14:55:17 -07:00
Pavel Feldman
1caecd00c7 Revert "chore: include cwd in a vscode config" (#1095)
Reverts microsoft/playwright-mcp#1085
2025-09-30 13:30:06 -07:00
Pavel Feldman
9657b58e17 chore: include cwd in a vscode config (#1085) 2025-09-26 17:35:03 -07:00
zhu733756
8dfea1c67f docs: running the Playwright-MCP server in docker (#1074) 2025-09-25 08:23:28 -07:00
Pavel Feldman
a86b580797 chore: mark v0.0.40 (#1079) 2025-09-24 17:08:20 -07:00
Pavel Feldman
927e570c18 chore: roll Playwright to latest (#1078) 2025-09-24 17:07:37 -07:00
Yury Semikhatsky
24e68fa41d devops: do not use npm token (#1076)
https://github.com/microsoft/playwright/issues/37495
2025-09-24 15:22:10 -07:00
Yury Semikhatsky
e50322731b devops: merge canary publishing workflow into publish.yml (#1075)
Reference https://github.com/microsoft/playwright/issues/37495
2025-09-24 14:54:43 -07:00
Yury Semikhatsky
711089d261 chore: add auth token example (#1070)
Fixes https://github.com/microsoft/playwright-mcp/issues/1069
2025-09-22 16:47:13 -07:00
Max Schmitt
4a7be8de75 devops: migrate to OIDC NPM publishing (#1068)
https://github.com/microsoft/playwright/issues/37495
2025-09-22 16:19:16 +02:00
wagner-psouza
1523338246 feat(config): add sharedBrowserContext option to reuse browser context across HTTP client (#1058) 2025-09-19 19:30:59 -07:00
Pavel Feldman
08bd91d119 chore: mark v0.0.39 (#1059) 2025-09-19 07:55:26 -07:00
Pavel Feldman
ef83729796 chore: roll Playwright to latest (#1060) 2025-09-19 07:55:12 -07:00
Pavel Feldman
a70854c02f chore: roll Playwright to latest (#1056) 2025-09-18 16:39:32 -07:00
Pavel Feldman
d42e0e12fc chore: mark v0.0.38 (#1049) 2025-09-17 09:42:45 -07:00
Pavel Feldman
335315f158 chore: roll Playwright to latest (#1050) 2025-09-17 09:38:19 -07:00
Tejas Kumar
c49dedb83f docs: fix programmatic usage (#1047) 2025-09-17 07:22:12 -07:00
Zeke
0bfcb05422 docs: fix cursor mcp link redirect error (#1037) 2025-09-15 11:21:12 -07:00
dependabot[bot]
ca0820c55e chore(deps-dev): bump vite from 5.4.19 to 5.4.20 in /extension (#1029) 2025-09-10 15:38:22 -07:00
Yury Semikhatsky
45fee738bc feat(extension): bypass connection dialog when token is provided (#1024)
<img width="698" height="551" alt="image"
src="https://github.com/user-attachments/assets/feee2375-7654-42cb-b9fa-f48aee5dd045"
/>


Reference: https://github.com/microsoft/playwright-mcp/issues/965
2025-09-10 15:33:31 -07:00
Yury Semikhatsky
dc3419023f chore: roll playwright to 1.56.0-alpha-2025-09-10 (#1027) 2025-09-10 10:16:40 -07:00
Dmytro Durach
2036f8a9d5 fix(docker): path issue with Claude Code (#1018) 2025-09-08 11:22:57 -07:00
Max Schmitt
38f0222d0e devops: fix publishing (#1021)
Fixes
https://github.com/microsoft/playwright-mcp/actions/runs/17521002811/job/49764720465
Fixes https://github.com/microsoft/playwright-mcp/issues/1014
2025-09-08 16:43:04 +02:00
Pavel Feldman
78795fc7c7 chore: mark v0.0.37 (#1010) 2025-09-06 13:04:30 -07:00
Pavel Feldman
e4b545c438 chore: roll Playwright to latest (#1009) 2025-09-06 10:42:37 -07:00
Pavel Feldman
3d6a66fd08 chore: roll to the latest Playwright (#1003) 2025-09-05 12:31:08 -07:00
Yury Semikhatsky
971489536e chore: resore extension sources (#999) 2025-09-04 15:00:42 -07:00
Yury Semikhatsky
6171abd277 devops: get extension version from package.json, not tag (#1000) 2025-09-04 14:21:14 -07:00
Yury Semikhatsky
60d2b97b43 docs: link to extension readme (#997) 2025-09-04 11:22:21 -07:00
Pavel Feldman
3fe5b8a408 chore: point to monorepo for the source code (#995) 2025-09-04 08:30:23 -07:00
Pavel Feldman
c58b2a93da chore: use mcp implementation in Playwright (#992) 2025-09-04 06:40:19 -07:00
Pavel Feldman
d142f13d80 chore: prep folders for copying upstream (#987) 2025-09-03 16:02:07 -07:00
Pavel Feldman
2461f32d05 chore: move from esm to cjs before upstreaming (#986) 2025-09-03 08:00:58 -07:00
Pavel Feldman
8d86ce4958 chore: use mcp sdk via playwright (#973) 2025-09-03 07:58:36 -07:00
Pavel Feldman
87741662f4 docs: fix Cursor link (#985) 2025-09-02 14:20:20 -07:00
Simon Knott
b85dc6954a chore(vscode): expose debug controller (#979)
See https://github.com/microsoft/playwright-vscode/pull/684 for the
other side.
2025-09-02 08:59:36 +02:00
165 changed files with 5292 additions and 16641 deletions

View File

@@ -16,15 +16,12 @@ jobs:
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- run: npm run build
- name: Run ESLint
run: npm run lint
- run: npm ci
- run: npm run lint
- name: Ensure no changes
run: git diff --exit-code
test:
test_mcp:
strategy:
fail-fast: false
matrix:
@@ -41,16 +38,11 @@ jobs:
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
# MS Edge is not preinstalled on macOS runners.
if: ${{ matrix.os == 'macos-latest' }}
run: npx playwright install msedge
- name: Build
run: npm run build
- name: Run tests
run: npm test
run: npm run test
working-directory: ./packages/playwright-mcp
test_docker:
test_mcp_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -63,8 +55,6 @@ jobs:
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
@@ -80,16 +70,12 @@ jobs:
# Used for the Docker tests to share the test-results folder with the container.
umask 0000
npm run test -- --project=chromium-docker
working-directory: ./packages/playwright-mcp
env:
MCP_IN_DOCKER: 1
test_extension:
strategy:
fail-fast: false
runs-on: macos-latest
defaults:
run:
working-directory: ./extension
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
@@ -99,20 +85,17 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Build extension
run: npm run build
working-directory: ./packages/extension
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: extension
path: ./extension/dist
retention-days: 7
- name: Install and build MCP server
run: |
cd ..
npm ci
npm run build
npx playwright install chromium
- name: Run tests
run: |
if [[ "$(uname)" == "Linux" ]]; then
@@ -121,3 +104,4 @@ jobs:
npm run test
fi
shell: bash
working-directory: ./packages/extension

View File

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

View File

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

View File

@@ -1,36 +1,108 @@
name: Publish
on:
workflow_dispatch:
schedule:
- cron: '0 8 * * *'
release:
types: [published]
jobs:
publish-npm:
publish-mcp-canary-npm:
if: github.event.schedule || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Needed for npm provenance
id-token: write # Required for OIDC npm publishing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 18
node-version: 20
registry-url: https://registry.npmjs.org/
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
- name: Update npm
run: npm install -g npm@latest
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Get current version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Set canary version
id: canary-version
run: echo "version=${{ steps.version.outputs.version }}-alpha-${{ steps.date.outputs.date }}" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
npm version ${{ steps.canary-version.outputs.version }} --no-git-tag-version
working-directory: ./packages/playwright-mcp
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run lint
- run: npm run ctest
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
working-directory: ./packages/playwright-mcp
publish-docker:
- name: Publish to npm with next tag
run: npm publish --tag next
working-directory: ./packages/playwright-mcp
publish-mcp-release-npm:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC npm publishing
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 20
registry-url: https://registry.npmjs.org/
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run lint
- run: npm run ctest
working-directory: ./packages/playwright-mcp
- run: npm publish
working-directory: ./packages/playwright-mcp
publish-cli-release-npm:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC npm publishing
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 20
registry-url: https://registry.npmjs.org/
# Ensure npm 11.5.1 or later is installed (for OIDC npm publishing)
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run lint
- run: npm publish
working-directory: ./packages/playwright-cli
publish-mcp-release-docker:
if: github.event_name == 'release'
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
- uses: actions/checkout@v5
- 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
@@ -47,8 +119,7 @@ jobs:
id: build-push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
@@ -69,30 +140,34 @@ jobs:
attach_eol_manifest $tag
done
package-extension:
package-release-extension:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write # Needed to upload release assets
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 20
cache: 'npm'
- name: Install extension dependencies
working-directory: ./extension
run: npm ci
- name: Build extension
working-directory: ./extension
working-directory: ./packages/extension
run: npm run build
- name: Get extension version
id: get-version
working-directory: ./packages/extension
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Package extension
working-directory: ./extension
working-directory: ./packages/extension
run: |
cd dist
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip .
zip -r ../playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip .
cd ..
- name: Upload extension to release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip
gh release upload ${{github.event.release.tag_name}} ./packages/extension/playwright-mcp-extension-${{ steps.get-version.outputs.version }}.zip

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
lib/
dist/
node_modules/
test-results/
playwright-report/

137
CONTRIBUTING.md Normal file
View File

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

View File

@@ -16,6 +16,7 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=bind,source=packages/playwright-mcp/package.json,target=packages/playwright-mcp/package.json \
npm ci --omit=dev && \
# Install system dependencies for playwright
npx -y playwright-core install-deps chromium
@@ -28,14 +29,11 @@ FROM base AS builder
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
--mount=type=bind,source=packages/playwright-mcp/package.json,target=packages/playwright-mcp/package.json \
npm ci
# Copy the rest of the app
COPY *.json *.js *.ts .
COPY src src/
# Build the app
RUN npm run build
COPY packages/playwright-mcp/*.json packages/playwright-mcp/*.js packages/playwright-mcp/*.ts .
# ------------------------------
# Browser
@@ -55,6 +53,7 @@ FROM base
ARG PLAYWRIGHT_BROWSERS_PATH
ARG USERNAME=node
ENV NODE_ENV=production
ENV PLAYWRIGHT_MCP_OUTPUT_DIR=/tmp/playwright-output
# Set the correct ownership for the runtime user on production `node_modules`
RUN chown -R ${USERNAME}:${USERNAME} node_modules
@@ -62,8 +61,7 @@ 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
COPY --chown=${USERNAME}:${USERNAME} packages/playwright-mcp/cli.js packages/playwright-mcp/package.json ./
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]

View File

@@ -186,8 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
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.

623
README.md
View File

@@ -2,6 +2,14 @@
A Model Context Protocol (MCP) server that provides browser automation capabilities using [Playwright](https://playwright.dev). This server enables LLMs to interact with web pages through structured accessibility snapshots, bypassing the need for screenshots or visually-tuned models.
### Playwright MCP vs Playwright CLI
This package provides MCP interface into Playwright. If you are using a **coding agent**, you might benefit from using the [CLI+SKILLS](https://github.com/microsoft/playwright-cli) instead.
- **CLI**: Modern **coding agents** increasingly favor CLIbased workflows exposed as SKILLs over MCP because CLI invocations are more token-efficient: they avoid loading large tool schemas and verbose accessibility trees into the model context, allowing agents to act through concise, purpose-built commands. This makes CLI + SKILLs better suited for high-throughput coding agents that must balance browser automation with large codebases, tests, and reasoning within limited context windows.<br>**Learn more about [Playwright CLI with SKILLS](https://github.com/microsoft/playwright-cli)**.
- **MCP**: MCP remains relevant for specialized agentic loops that benefit from persistent state, rich introspection, and iterative reasoning over page structure, such as exploratory automation, self-healing tests, or long-running autonomous workflows where maintaining continuous browser context outweighs token cost concerns.
### Key Features
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
@@ -38,6 +46,31 @@ First, install the Playwright MCP server with your client.
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
<details>
<summary>Amp</summary>
Add via the Amp VS Code extension settings screen or by updating your settings.json file:
```json
"amp.mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
```
**Amp CLI Setup:**
Add via the `amp mcp add`command below
```bash
amp mcp add playwright -- npx @playwright/mcp@latest
```
</details>
<details>
<summary>Claude Code</summary>
@@ -56,10 +89,44 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
</details>
<details>
<summary>Cline</summary>
Follow the instruction in the section [Configuring MCP Servers](https://docs.cline.bot/mcp/configuring-mcp-servers)
**Example: Local Setup**
Add the following to your [`cline_mcp_settings.json`](https://docs.cline.bot/mcp/configuring-mcp-servers#editing-mcp-settings-files) file:
```json
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"timeout": 30,
"args": [
"-y",
"@playwright/mcp@latest"
],
"disabled": false
}
}
}
```
</details>
<details>
<summary>Codex</summary>
Create or edit the configuration file `~/.codex/config.toml` and add:
Use the Codex CLI to add the Playwright MCP server:
```bash
codex mcp add playwright npx "@playwright/mcp@latest"
```
Alternatively, create or edit the configuration file `~/.codex/config.toml` and add:
```toml
[mcp_servers.playwright]
@@ -71,12 +138,44 @@ For more information, see the [Codex MCP documentation](https://github.com/opena
</details>
<details>
<summary>Copilot</summary>
Use the Copilot CLI to interactively add the Playwright MCP server:
```bash
/mcp add
```
Alternatively, create or edit the configuration file `~/.copilot/mcp-config.json` and add:
```json
{
"mcpServers": {
"playwright": {
"type": "local",
"command": "npx",
"tools": [
"*"
],
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
For more information, see the [Copilot CLI documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli).
</details>
<details>
<summary>Cursor</summary>
#### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually:
@@ -84,6 +183,21 @@ Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, u
</details>
<details>
<summary>Factory</summary>
Use the Factory CLI to add the Playwright MCP server:
```bash
droid mcp add playwright "npx @playwright/mcp@latest"
```
Alternatively, type `/mcp` within Factory droid to open an interactive UI for managing MCP servers.
For more information, see the [Factory MCP documentation](https://docs.factory.ai/cli/configuration/mcp).
</details>
<details>
<summary>Gemini CLI</summary>
@@ -103,6 +217,25 @@ Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
</details>
<details>
<summary>Kiro</summary>
Follow the MCP Servers [documentation](https://kiro.dev/docs/mcp/). For example in `.kiro/settings/mcp.json`:
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary>LM Studio</summary>
@@ -165,6 +298,27 @@ code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@la
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
</details>
<details>
<summary>Warp</summary>
Go to `Settings` -> `AI` -> `Manage MCP Servers` -> `+ Add` to [add an MCP Server](https://docs.warp.dev/knowledge-and-collaboration/mcp#adding-an-mcp-server). Use the standard config above.
Alternatively, use the slash command `/add-mcp` in the Warp prompt and paste the standard config from above:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
</details>
<details>
<summary>Windsurf</summary>
@@ -178,55 +332,50 @@ Playwright MCP server supports following arguments. They can be provided in the
<!--- Options generated by update-readme.js -->
```
> npx @playwright/mcp@latest --help
--allowed-origins <origins> semicolon-separated list of origins to allow the
browser to request. Default is to allow all.
--blocked-origins <origins> semicolon-separated list of origins to block the
browser from requesting. Blocklist is evaluated
before allowlist. If used without the allowlist,
requests not matching the blocklist are still
allowed.
--block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge.
--caps <caps> comma-separated list of additional capabilities
to enable, possible values: vision, pdf.
--cdp-endpoint <endpoint> CDP endpoint to connect to.
--config <path> path to the configuration file.
--device <device> device to emulate, for example: "iPhone 15"
--executable-path <path> path to the browser executable.
--extension Connect to a running browser instance
(Edge/Chrome only). Requires the "Playwright MCP
Bridge" browser extension to be installed.
--headless run browser in headless mode, headed by default
--host <host> host to bind server to. Default is localhost. Use
0.0.0.0 to bind to all interfaces.
--ignore-https-errors ignore https errors
--isolated keep the browser profile in memory, do not save
it to disk.
--image-responses <mode> whether to send image responses to the client.
Can be "allow" or "omit", Defaults to "allow".
--no-sandbox disable the sandbox for all process types that
are normally sandboxed.
--output-dir <path> path to the directory for output files.
--port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-session Whether to save the Playwright MCP session into
the output directory.
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--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"
```
| Option | Description |
|--------|-------------|
| --allowed-hosts <hosts...> | comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass '*' to disable the host check.<br>*env* `PLAYWRIGHT_MCP_ALLOWED_HOSTS` |
| --allowed-origins <origins> | semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all. Important: *does not* serve as a security boundary and *does not* affect redirects.<br>*env* `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` |
| --allow-unrestricted-file-access | allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.<br>*env* `PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS` |
| --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. Important: *does not* serve as a security boundary and *does not* affect redirects.<br>*env* `PLAYWRIGHT_MCP_BLOCKED_ORIGINS` |
| --block-service-workers | block service workers<br>*env* `PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS` |
| --browser <browser> | browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.<br>*env* `PLAYWRIGHT_MCP_BROWSER` |
| --caps <caps> | comma-separated list of additional capabilities to enable, possible values: vision, pdf.<br>*env* `PLAYWRIGHT_MCP_CAPS` |
| --cdp-endpoint <endpoint> | CDP endpoint to connect to.<br>*env* `PLAYWRIGHT_MCP_CDP_ENDPOINT` |
| --cdp-header <headers...> | CDP headers to send with the connect request, multiple can be specified.<br>*env* `PLAYWRIGHT_MCP_CDP_HEADER` |
| --codegen <lang> | specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript".<br>*env* `PLAYWRIGHT_MCP_CODEGEN` |
| --config <path> | path to the configuration file.<br>*env* `PLAYWRIGHT_MCP_CONFIG` |
| --console-level <level> | level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels.<br>*env* `PLAYWRIGHT_MCP_CONSOLE_LEVEL` |
| --device <device> | device to emulate, for example: "iPhone 15"<br>*env* `PLAYWRIGHT_MCP_DEVICE` |
| --executable-path <path> | path to the browser executable.<br>*env* `PLAYWRIGHT_MCP_EXECUTABLE_PATH` |
| --extension | Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.<br>*env* `PLAYWRIGHT_MCP_EXTENSION` |
| --grant-permissions <permissions...> | List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".<br>*env* `PLAYWRIGHT_MCP_GRANT_PERMISSIONS` |
| --headless | run browser in headless mode, headed by default<br>*env* `PLAYWRIGHT_MCP_HEADLESS` |
| --host <host> | host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.<br>*env* `PLAYWRIGHT_MCP_HOST` |
| --ignore-https-errors | ignore https errors<br>*env* `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS` |
| --init-page <path...> | path to TypeScript file to evaluate on Playwright page object<br>*env* `PLAYWRIGHT_MCP_INIT_PAGE` |
| --init-script <path...> | path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page's scripts. Can be specified multiple times.<br>*env* `PLAYWRIGHT_MCP_INIT_SCRIPT` |
| --isolated | keep the browser profile in memory, do not save it to disk.<br>*env* `PLAYWRIGHT_MCP_ISOLATED` |
| --image-responses <mode> | whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".<br>*env* `PLAYWRIGHT_MCP_IMAGE_RESPONSES` |
| --no-sandbox | disable the sandbox for all process types that are normally sandboxed.<br>*env* `PLAYWRIGHT_MCP_NO_SANDBOX` |
| --output-dir <path> | path to the directory for output files.<br>*env* `PLAYWRIGHT_MCP_OUTPUT_DIR` |
| --output-mode <mode> | whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout".<br>*env* `PLAYWRIGHT_MCP_OUTPUT_MODE` |
| --port <port> | port to listen on for SSE transport.<br>*env* `PLAYWRIGHT_MCP_PORT` |
| --proxy-bypass <bypass> | comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"<br>*env* `PLAYWRIGHT_MCP_PROXY_BYPASS` |
| --proxy-server <proxy> | specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"<br>*env* `PLAYWRIGHT_MCP_PROXY_SERVER` |
| --save-session | Whether to save the Playwright MCP session into the output directory.<br>*env* `PLAYWRIGHT_MCP_SAVE_SESSION` |
| --save-trace | Whether to save the Playwright Trace of the session into the output directory.<br>*env* `PLAYWRIGHT_MCP_SAVE_TRACE` |
| --save-video <size> | Whether to save the video of the session into the output directory. For example "--save-video=800x600"<br>*env* `PLAYWRIGHT_MCP_SAVE_VIDEO` |
| --secrets <path> | path to a file containing secrets in the dotenv format<br>*env* `PLAYWRIGHT_MCP_SECRETS` |
| --shared-browser-context | reuse the same browser context between all connected HTTP clients.<br>*env* `PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT` |
| --snapshot-mode <mode> | when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental.<br>*env* `PLAYWRIGHT_MCP_SNAPSHOT_MODE` |
| --storage-state <path> | path to the storage state file for isolated sessions.<br>*env* `PLAYWRIGHT_MCP_STORAGE_STATE` |
| --test-id-attribute <attribute> | specify the attribute to use for test ids, defaults to "data-testid"<br>*env* `PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE` |
| --timeout-action <timeout> | specify action timeout in milliseconds, defaults to 5000ms<br>*env* `PLAYWRIGHT_MCP_TIMEOUT_ACTION` |
| --timeout-navigation <timeout> | specify navigation timeout in milliseconds, defaults to 60000ms<br>*env* `PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION` |
| --user-agent <ua string> | specify user agent string<br>*env* `PLAYWRIGHT_MCP_USER_AGENT` |
| --user-data-dir <path> | path to the user data directory. If not specified, a temporary directory will be created.<br>*env* `PLAYWRIGHT_MCP_USER_DATA_DIR` |
| --viewport-size <size> | specify browser viewport size in pixels, for example "1280x720"<br>*env* `PLAYWRIGHT_MCP_VIEWPORT_SIZE` |
<!--- End of options generated section -->
@@ -274,7 +423,36 @@ state [here](https://playwright.dev/docs/auth).
**Browser Extension**
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [packages/extension/README.md](packages/extension/README.md) for installation and setup instructions.
### Initial state
There are multiple ways to provide the initial state to the browser context or a page.
For the storage state, you can either:
- Start with a user data directory using the `--user-data-dir` argument. This will persist all browser data between the sessions.
- Start with a storage state file using the `--storage-state` argument. This will load cookies and local storage from the file into an isolated browser context.
For the page state, you can use:
- `--init-page` to point to a TypeScript file that will be evaluated on the Playwright page object. This allows you to run arbitrary code to set up the page.
```ts
// init-page.ts
export default async ({ page }) => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
await page.setViewportSize({ width: 1280, height: 720 });
};
```
- `--init-script` to point to a JavaScript file that will be added as an initialization script. The script will be evaluated in every page before any of the page's scripts.
This is useful for overriding browser APIs or setting up the environment.
```js
// init-script.js
window.isPlaywrightMCP = true;
```
### Configuration file
@@ -288,75 +466,206 @@ npx @playwright/mcp@latest --config path/to/config.json
<details>
<summary>Configuration file schema</summary>
<!--- Config generated by update-readme.js -->
```typescript
{
// Browser configuration
/**
* The browser to use.
*/
browser?: {
// Browser type to use (chromium, firefox, or webkit)
/**
* The type of browser to use.
*/
browserName?: 'chromium' | 'firefox' | 'webkit';
// Keep the browser profile in memory, do not save it to disk.
/**
* Keep the browser profile in memory, do not save it to disk.
*/
isolated?: boolean;
// Path to user data directory for browser profile persistence
/**
* Path to a user data directory for browser profile persistence.
* Temporary directory is created by default.
*/
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
};
/**
* 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;
// Browser context options
// @see https://playwright.dev/docs/api/class-browser#browser-new-context
contextOptions?: {
viewport?: { width: number, height: number };
// ... other Playwright context options
};
/**
* Context options for the browser context.
*
* This is useful for settings options like `viewport`.
*/
contextOptions?: playwright.BrowserContextOptions;
// CDP endpoint for connecting to existing browser
/**
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
*/
cdpEndpoint?: string;
// Remote Playwright server endpoint
/**
* CDP headers to send with the connect request.
*/
cdpHeaders?: Record<string, string>;
/**
* Timeout in milliseconds for connecting to CDP endpoint. Defaults to 30000 (30 seconds). Pass 0 to disable timeout.
*/
cdpTimeout?: number;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
/**
* Paths to TypeScript files to add as initialization scripts for Playwright page.
*/
initPage?: string[];
/**
* Paths to JavaScript files to add as initialization scripts.
* The scripts will be evaluated in every page before any of the page's scripts.
*/
initScript?: string[];
},
// Server configuration
server?: {
port?: number; // Port to listen on
host?: string; // Host to bind to (default: localhost)
/**
* 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;
/**
* The hosts this server is allowed to serve from. Defaults to the host server is bound to.
* This is not for CORS, but rather for the DNS rebinding protection.
*/
allowedHosts?: string[];
},
// List of additional capabilities
capabilities?: Array<
'tabs' | // Tab management
'install' | // Browser installation
'pdf' | // PDF generation
'vision' | // Coordinate-based interactions
>;
/**
* List of enabled tool capabilities. Possible values:
* - 'core': Core browser automation features.
* - 'pdf': PDF generation and manipulation.
* - 'vision': Coordinate-based interactions.
*/
capabilities?: ToolCapability[];
// Directory for output files
/**
* Whether to save the Playwright session into the output directory.
*/
saveSession?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/
saveTrace?: boolean;
/**
* If specified, saves the Playwright video of the session into the output directory.
*/
saveVideo?: {
width: number;
height: number;
};
/**
* Reuse the same browser context between all connected HTTP clients.
*/
sharedBrowserContext?: boolean;
/**
* Secrets are used to prevent LLM from getting sensitive data while
* automating scenarios such as authentication.
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
*/
secrets?: Record<string, string>;
/**
* The directory to save output files.
*/
outputDir?: string;
// Network configuration
/**
* Whether to save snapshots, console messages, network logs and other session logs to a file or to the standard output. Defaults to "stdout".
*/
outputMode?: 'file' | 'stdout';
console?: {
/**
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
*/
level?: 'error' | 'warning' | 'info' | 'debug';
},
network?: {
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
/**
* 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.
/**
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
blockedOrigins?: string[];
};
/**
* Whether to send image responses to the client. Can be "allow" or "omit".
* Defaults to "allow".
* Specify the attribute to use for test ids, defaults to "data-testid".
*/
testIdAttribute?: string;
timeouts?: {
/*
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
*/
action?: number;
/*
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
*/
navigation?: number;
};
/**
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/
imageResponses?: 'allow' | 'omit';
snapshot?: {
/**
* When taking snapshots for responses, specifies the mode to use.
*/
mode?: 'incremental' | 'full' | 'none';
};
/**
* Whether to allow file uploads from anywhere on the file system.
* By default (false), file uploads are restricted to paths within the MCP roots only.
*/
allowUnrestrictedFileAccess?: boolean;
/**
* Specify the language to use for code generation.
*/
codegen?: 'typescript' | 'none';
}
```
<!--- End of config generated section -->
</details>
### Standalone MCP server
@@ -396,6 +705,19 @@ And then in MCP client config, set the `url` to the HTTP endpoint:
}
```
Or If you prefer to run the container as a long-lived service instead of letting the MCP client spawn it, use:
```
docker run -d -i --rm --init --pull=always \
--entrypoint node \
--name playwright \
-p 8931:8931 \
mcr.microsoft.com/playwright/mcp \
cli.js --headless --browser chromium --no-sandbox --port 8931
```
The server will listen on host port **8931** and can be reached by any MCP client.
You can build the Docker image yourself.
```
@@ -418,7 +740,7 @@ http.createServer(async (req, res) => {
// Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.sever.connect(transport);
await connection.connect(transport);
// ...
});
@@ -438,10 +760,11 @@ http.createServer(async (req, res) => {
- 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
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
- `button` (string, optional): Button to click, defaults to left
- `modifiers` (array, optional): Modifier keys to press
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -450,14 +773,16 @@ http.createServer(async (req, res) => {
- Title: Close browser
- Description: Close the page
- Parameters: None
- Read-only: **true**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages**
- Title: Get console messages
- Description: Returns all console messages
- Parameters: None
- Parameters:
- `level` (string): Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
- `filename` (string, optional): Filename to save the console messages to. If not provided, messages are returned as text.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -489,7 +814,7 @@ http.createServer(async (req, res) => {
- 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.
- `paths` (array, optional): The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled.
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -517,9 +842,9 @@ http.createServer(async (req, res) => {
- 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
- `element` (string, optional): 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**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -534,16 +859,18 @@ http.createServer(async (req, res) => {
- **browser_navigate_back**
- Title: Go back
- Description: Go back to the previous page
- Description: Go back to the previous page in the history
- Parameters: None
- Read-only: **true**
- Read-only: **false**
<!-- 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
- Parameters:
- `includeStatic` (boolean): Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.
- `filename` (string, optional): Filename to save the network requests to. If not provided, requests are returned as text.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -563,7 +890,16 @@ http.createServer(async (req, res) => {
- Parameters:
- `width` (number): Width of the browser window
- `height` (number): Height of the browser window
- Read-only: **true**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_run_code**
- Title: Run Playwright code
- Description: Run Playwright code snippet
- Parameters:
- `code` (string): A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }`
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -571,7 +907,7 @@ http.createServer(async (req, res) => {
- 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
- `element` (string, optional): 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**
@@ -581,7 +917,8 @@ http.createServer(async (req, res) => {
- **browser_snapshot**
- Title: Page snapshot
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
- Parameters: None
- Parameters:
- `filename` (string, optional): Save snapshot to markdown file instead of returning it in the response.
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -590,8 +927,8 @@ http.createServer(async (req, res) => {
- Title: Take a screenshot
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `type` (string, optional): Image format for the screenshot. Default is png.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `type` (string): Image format for the screenshot. Default is png.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
@@ -603,7 +940,7 @@ http.createServer(async (req, res) => {
- 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
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
- `ref` (string): Exact target element reference from the page snapshot
- `text` (string): Text to type into the element
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
@@ -619,7 +956,7 @@ http.createServer(async (req, res) => {
- `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**
- Read-only: **false**
</details>
@@ -660,18 +997,25 @@ http.createServer(async (req, res) => {
- Title: Click
- Description: Click left mouse button at a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `x` (number): X coordinate
- `y` (number): Y coordinate
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_down**
- Title: Press mouse down
- Description: Press mouse down
- Parameters:
- `button` (string, optional): Button to press, defaults to left
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_drag_xy**
- Title: Drag mouse
- Description: Drag left mouse button to a given position
- Parameters:
- `element` (string): Human-readable element description used to obtain permission to interact with the element
- `startX` (number): Start X coordinate
- `startY` (number): Start Y coordinate
- `endX` (number): End X coordinate
@@ -684,10 +1028,28 @@ http.createServer(async (req, res) => {
- 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**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_up**
- Title: Press mouse up
- Description: Press mouse up
- Parameters:
- `button` (string, optional): Button to press, defaults to left
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_mouse_wheel**
- Title: Scroll mouse wheel
- Description: Scroll mouse wheel
- Parameters:
- `deltaX` (number): X delta
- `deltaY` (number): Y delta
- Read-only: **false**
</details>
@@ -700,13 +1062,23 @@ http.createServer(async (req, res) => {
- 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.
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified. Prefer relative file names to stay within the output directory.
- Read-only: **true**
</details>
<details>
<summary><b>Verify (opt-in via --caps=verify)</b></summary>
<summary><b>Test assertions (opt-in via --caps=testing)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_generate_locator**
- Title: Create locator for element
- Description: Generate locator for the given element to use in tests
- Parameters:
- `element` (string, optional): 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 -->
@@ -716,7 +1088,7 @@ http.createServer(async (req, res) => {
- Parameters:
- `role` (string): ROLE of the element. Can be found in the snapshot like this: `- {ROLE} "Accessible Name":`
- `accessibleName` (string): ACCESSIBLE_NAME of the element. Can be found in the snapshot like this: `- role "{ACCESSIBLE_NAME}"`
- Read-only: **true**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -727,7 +1099,7 @@ http.createServer(async (req, res) => {
- `element` (string): Human-readable list description
- `ref` (string): Exact target element reference that points to the list
- `items` (array): Items to verify
- Read-only: **true**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -736,7 +1108,7 @@ http.createServer(async (req, res) => {
- Description: Verify text is visible on the page. Prefer browser_verify_element_visible if possible.
- Parameters:
- `text` (string): TEXT to verify. Can be found in the snapshot like this: `- role "Accessible Name": {TEXT}` or like this: `- text: {TEXT}`
- Read-only: **true**
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
@@ -748,6 +1120,27 @@ http.createServer(async (req, res) => {
- `element` (string): Human-readable element description
- `ref` (string): Exact target element reference that points to the element
- `value` (string): Value to verify. For checkbox, use "true" or "false".
- Read-only: **false**
</details>
<details>
<summary><b>Tracing (opt-in via --caps=tracing)</b></summary>
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_start_tracing**
- Title: Start tracing
- Description: Start trace recording
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_stop_tracing**
- Title: Stop tracing
- Description: Stop trace recording
- Parameters: None
- Read-only: **true**
</details>

View File

@@ -1,235 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import notice from "eslint-plugin-notice";
import path from "path";
import { fileURLToPath } from "url";
import stylistic from "@stylistic/eslint-plugin";
import importRules from "eslint-plugin-import";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const plugins = {
"@stylistic": stylistic,
"@typescript-eslint": typescriptEslint,
notice,
import: importRules,
};
export const baseRules = {
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": [
2,
{ args: "none", caughtErrors: "none" },
],
/**
* Enforced rules
*/
// syntax preferences
"object-curly-spacing": ["error", "always"],
quotes: [
2,
"single",
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
"jsx-quotes": [2, "prefer-single"],
"no-extra-semi": 2,
"@stylistic/semi": [2],
"comma-style": [2, "last"],
"wrap-iife": [2, "inside"],
"spaced-comment": [
2,
"always",
{
markers: ["*"],
},
],
eqeqeq: [2],
"accessor-pairs": [
2,
{
getWithoutSet: false,
setWithoutGet: false,
},
],
"brace-style": [2, "1tbs", { allowSingleLine: true }],
curly: [2, "multi-or-nest", "consistent"],
"new-parens": 2,
"arrow-parens": [2, "as-needed"],
"prefer-const": 2,
"quote-props": [2, "consistent"],
"nonblock-statement-body-position": [2, "below"],
// anti-patterns
"no-var": 2,
"no-with": 2,
"no-multi-str": 2,
"no-caller": 2,
"no-implied-eval": 2,
"no-labels": 2,
"no-new-object": 2,
"no-octal-escape": 2,
"no-self-compare": 2,
"no-shadow-restricted-names": 2,
"no-cond-assign": 2,
"no-debugger": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-unreachable": 2,
"no-unsafe-negation": 2,
radix: 2,
"valid-typeof": 2,
"no-implicit-globals": [2],
"no-unused-expressions": [
2,
{ allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true },
],
"no-proto": 2,
// es2015 features
"require-yield": 2,
"template-curly-spacing": [2, "never"],
// spacing details
"space-infix-ops": 2,
"space-in-parens": [2, "never"],
"array-bracket-spacing": [2, "never"],
"comma-spacing": [2, { before: false, after: true }],
"keyword-spacing": [2, "always"],
"space-before-function-paren": [
2,
{
anonymous: "never",
named: "never",
asyncArrow: "always",
},
],
"no-whitespace-before-property": 2,
"keyword-spacing": [
2,
{
overrides: {
if: { after: true },
else: { after: true },
for: { after: true },
while: { after: true },
do: { after: true },
switch: { after: true },
return: { after: true },
},
},
],
"arrow-spacing": [
2,
{
after: true,
before: true,
},
],
"@stylistic/func-call-spacing": 2,
"@stylistic/type-annotation-spacing": 2,
// file whitespace
"no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }],
"no-mixed-spaces-and-tabs": 2,
"no-trailing-spaces": 2,
"linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
indent: [
2,
2,
{ SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 },
],
"key-spacing": [
2,
{
beforeColon: false,
},
],
"eol-last": 2,
// copyright
"notice/notice": [
2,
{
mustMatch: "Copyright",
templateFile: path.join(__dirname, "utils", "copyright.js"),
},
],
// react
"react/react-in-jsx-scope": 0,
"no-console": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
parserOptions: {
project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
}
};
const importOrderRules = {
"import/order": [
2,
{
groups: [
"builtin",
"external",
"internal",
["parent", "sibling"],
"index",
"type",
],
},
],
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
};
const noFloatingPromisesRules = {
"@typescript-eslint/no-floating-promises": "error",
};
const noBooleanCompareRules = {
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
};
export default [
{
ignores: ["**/*.js"],
},
{
files: ["**/*.ts", "**/*.tsx"],
plugins,
languageOptions,
rules: {
...baseRules,
...importOrderRules,
...noFloatingPromisesRules,
...noBooleanCompareRules,
},
},
];

View File

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

File diff suppressed because it is too large Load Diff

View File

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

4964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +1,30 @@
{
"name": "@playwright/mcp",
"version": "0.0.36",
"description": "Playwright Tools for MCP",
"type": "module",
"name": "playwright-mcp-internal",
"version": "0.0.61",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc",
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
"lint-fix": "eslint . --fix",
"check-deps": "node utils/check-deps.js",
"update-readme": "node utils/update-readme.js",
"watch": "tsc --watch",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"run-server": "node lib/browserServer.js",
"clean": "rm -rf lib",
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0",
"commander": "^13.1.0",
"debug": "^4.4.1",
"dotenv": "^17.2.0",
"mime": "^4.0.7",
"playwright": "1.56.0-alpha-1756505518000",
"playwright-core": "1.56.0-alpha-1756505518000",
"ws": "^8.18.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4"
"docker-build": "docker build --no-cache -t playwright-mcp-dev:latest .",
"docker-rm": "docker rm playwright-mcp-dev",
"docker-run": "docker run -it -p 8080:8080 --name playwright-mcp-dev playwright-mcp-dev:latest",
"lint": "npm run lint --workspaces",
"test": "npm run test --workspaces",
"build": "npm run build --workspaces"
},
"workspaces": [
"packages/*"
],
"devDependencies": {
"@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.56.0-alpha-1756505518000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"esbuild": "^0.20.1",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"openai": "^5.10.2",
"typescript": "^5.8.2"
},
"bin": {
"mcp-server-playwright": "cli.js"
"@modelcontextprotocol/sdk": "^1.25.2",
"@playwright/test": "1.59.0-alpha-1769452054000",
"@types/node": "^24.3.0"
}
}

1
packages/extension/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

View File

@@ -45,4 +45,33 @@ Configure Playwright MCP server to connect to the browser using the extension by
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
### Bypassing the Connection Approval Dialog
By default, you'll need to approve each connection when the MCP server tries to connect to your browser. To bypass this approval dialog and allow automatic connections, you can use an authentication token.
#### Using Your Unique Authentication Token
1. After installing the extension, click on the extension icon or navigate to the extension's status page
2. Copy the `PLAYWRIGHT_MCP_EXTENSION_TOKEN` value displayed in the extension UI
3. Add it to your MCP server configuration:
```json
{
"mcpServers": {
"playwright-extension": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--extension"
],
"env": {
"PLAYWRIGHT_MCP_EXTENSION_TOKEN": "your-token-here"
}
}
}
}
```
This token is unique to your browser profile and provides secure authentication between the MCP server and the extension. Once configured, you won't need to manually approve connections each time.

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 571 B

After

Width:  |  Height:  |  Size: 571 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Playwright MCP Bridge",
"version": "0.0.36",
"version": "0.0.61",
"description": "Share browser tabs with Playwright MCP server",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
"permissions": [
@@ -14,7 +14,7 @@
"<all_urls>"
],
"background": {
"service_worker": "lib/background.js",
"service_worker": "lib/background.mjs",
"type": "module"
},
"action": {

View File

@@ -1,8 +1,7 @@
{
"name": "@playwright/mcp-extension",
"version": "0.0.36",
"version": "0.0.61",
"description": "Playwright MCP Browser Extension",
"type": "module",
"private": true,
"repository": {
"type": "git",
@@ -17,9 +16,10 @@
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build && vite build --config vite.sw.config.mts",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch & vite build --watch --config vite.sw.config.mts",
"test": "playwright test",
"lint": "tsc --project .",
"clean": "rm -rf dist"
},
"devDependencies": {
@@ -30,7 +30,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.8.2",
"vite": "^5.0.0",
"vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1"
}
}

View File

@@ -16,7 +16,7 @@
import { defineConfig } from '@playwright/test';
import type { TestOptions } from '../tests/fixtures.js';
import type { TestOptions } from '../playwright-mcp/tests/fixtures';
export default defineConfig<TestOptions>({
testDir: './tests',

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { RelayConnection, debugLog } from './relayConnection.js';
import { RelayConnection, debugLog } from './relayConnection';
type PageMessage = {
type: 'connectToMCPRelay';

View File

@@ -0,0 +1,142 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.auth-token-section {
margin: 16px 0;
padding: 16px;
background-color: #f6f8fa;
border-radius: 6px;
}
.auth-token-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-container {
display: flex;
align-items: center;
gap: 8px;
background-color: #ffffff;
padding: 8px;
}
.auth-token-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #1f2328;
border: none;
flex: 1;
padding: 0;
word-break: break-all;
}
.auth-token-refresh {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.auth-token-refresh svg {
margin: 0;
}
.auth-token-refresh:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}
.auth-token-example-section {
margin-top: 16px;
}
.auth-token-example-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding: 8px 0;
font-size: 12px;
color: #656d76;
cursor: pointer;
outline: none;
text-align: left;
width: 100%;
}
.auth-token-example-toggle:hover {
color: #1f2328;
}
.auth-token-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
transform: rotate(-90deg);
flex-shrink: 0;
}
.auth-token-chevron.expanded {
transform: rotate(0deg);
}
.auth-token-chevron svg {
width: 12px;
height: 12px;
}
.auth-token-chevron .octicon {
margin: 0px;
}
.auth-token-example-content {
margin-top: 12px;
padding: 12px 0;
}
.auth-token-example-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-example-config {
display: flex;
align-items: flex-start;
gap: 8px;
background-color: #ffffff;
padding: 12px;
}
.auth-token-example-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
color: #1f2328;
white-space: pre;
flex: 1;
line-height: 1.4;
}

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 React, { useCallback, useState } from 'react';
import { CopyToClipboard } from './copyToClipboard';
import * as icons from './icons';
import './authToken.css';
export const AuthTokenSection: React.FC<{}> = ({}) => {
const [authToken, setAuthToken] = useState<string>(getOrCreateAuthToken);
const [isExampleExpanded, setIsExampleExpanded] = useState<boolean>(false);
const onRegenerateToken = useCallback(() => {
const newToken = generateAuthToken();
localStorage.setItem('auth-token', newToken);
setAuthToken(newToken);
}, []);
const toggleExample = useCallback(() => {
setIsExampleExpanded(!isExampleExpanded);
}, [isExampleExpanded]);
return (
<div className='auth-token-section'>
<div className='auth-token-description'>
Set this environment variable to bypass the connection dialog:
</div>
<div className='auth-token-container'>
<code className='auth-token-code'>{authTokenCode(authToken)}</code>
<button className='auth-token-refresh' title='Generate new token' aria-label='Generate new token'onClick={onRegenerateToken}>{icons.refresh()}</button>
<CopyToClipboard value={authTokenCode(authToken)} />
</div>
<div className='auth-token-example-section'>
<button
className='auth-token-example-toggle'
onClick={toggleExample}
aria-expanded={isExampleExpanded}
title={isExampleExpanded ? 'Hide example config' : 'Show example config'}
>
<span className={`auth-token-chevron ${isExampleExpanded ? 'expanded' : ''}`}>
{icons.chevronDown()}
</span>
Example MCP server configuration
</button>
{isExampleExpanded && (
<div className='auth-token-example-content'>
<div className='auth-token-example-description'>
Add this configuration to your MCP client (e.g., VS Code) to connect to the Playwright MCP Bridge:
</div>
<div className='auth-token-example-config'>
<code className='auth-token-example-code'>{exampleConfig(authToken)}</code>
<CopyToClipboard value={exampleConfig(authToken)} />
</div>
</div>
)}
</div>
</div>
);
};
function authTokenCode(authToken: string) {
return `PLAYWRIGHT_MCP_EXTENSION_TOKEN=${authToken}`;
}
function exampleConfig(authToken: string) {
return `{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--extension"],
"env": {
"PLAYWRIGHT_MCP_EXTENSION_TOKEN":
"${authToken}"
}
}
}
}`;
}
function generateAuthToken(): string {
// Generate a cryptographically secure random token
const array = new Uint8Array(32);
crypto.getRandomValues(array);
// Convert to base64 and make it URL-safe
return btoa(String.fromCharCode.apply(null, Array.from(array)))
.replace(/[+/=]/g, match => {
switch (match) {
case '+': return '-';
case '/': return '_';
case '=': return '';
default: return match;
}
});
}
export const getOrCreateAuthToken = (): string => {
let token = localStorage.getItem('auth-token');
if (!token) {
token = generateAuthToken();
localStorage.setItem('auth-token', token);
}
return token;
}

View File

@@ -0,0 +1,891 @@
/* The MIT License (MIT)
Copyright (c) 2021 GitHub Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
:root {
--color-canvas-default-transparent: rgba(255,255,255,0);
--color-marketing-icon-primary: #218bff;
--color-marketing-icon-secondary: #54aeff;
--color-diff-blob-addition-num-text: #24292f;
--color-diff-blob-addition-fg: #24292f;
--color-diff-blob-addition-num-bg: #CCFFD8;
--color-diff-blob-addition-line-bg: #E6FFEC;
--color-diff-blob-addition-word-bg: #ABF2BC;
--color-diff-blob-deletion-num-text: #24292f;
--color-diff-blob-deletion-fg: #24292f;
--color-diff-blob-deletion-num-bg: #FFD7D5;
--color-diff-blob-deletion-line-bg: #FFEBE9;
--color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4);
--color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4);
--color-diff-blob-expander-icon: #57606a;
--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;
--color-diffstat-deletion-border: rgba(27,31,36,0.15);
--color-diffstat-addition-border: rgba(27,31,36,0.15);
--color-diffstat-addition-bg: #2da44e;
--color-search-keyword-hl: #fff8c5;
--color-prettylights-syntax-comment: #6e7781;
--color-prettylights-syntax-constant: #0550ae;
--color-prettylights-syntax-entity: #8250df;
--color-prettylights-syntax-storage-modifier-import: #24292f;
--color-prettylights-syntax-entity-tag: #116329;
--color-prettylights-syntax-keyword: #cf222e;
--color-prettylights-syntax-string: #0a3069;
--color-prettylights-syntax-variable: #953800;
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
--color-prettylights-syntax-carriage-return-bg: #cf222e;
--color-prettylights-syntax-string-regexp: #116329;
--color-prettylights-syntax-markup-list: #3b2300;
--color-prettylights-syntax-markup-heading: #0550ae;
--color-prettylights-syntax-markup-italic: #24292f;
--color-prettylights-syntax-markup-bold: #24292f;
--color-prettylights-syntax-markup-deleted-text: #82071e;
--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;
--color-prettylights-syntax-markup-inserted-text: #116329;
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
--color-prettylights-syntax-markup-changed-text: #953800;
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
--color-prettylights-syntax-meta-diff-range: #8250df;
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
--color-codemirror-text: #24292f;
--color-codemirror-bg: #ffffff;
--color-codemirror-gutters-bg: #ffffff;
--color-codemirror-guttermarker-text: #ffffff;
--color-codemirror-guttermarker-subtle-text: #6e7781;
--color-codemirror-linenumber-text: #57606a;
--color-codemirror-cursor: #24292f;
--color-codemirror-selection-bg: rgba(84,174,255,0.4);
--color-codemirror-activeline-bg: rgba(234,238,242,0.5);
--color-codemirror-matchingbracket-text: #24292f;
--color-codemirror-lines-bg: #ffffff;
--color-codemirror-syntax-comment: #24292f;
--color-codemirror-syntax-constant: #0550ae;
--color-codemirror-syntax-entity: #8250df;
--color-codemirror-syntax-keyword: #cf222e;
--color-codemirror-syntax-storage: #cf222e;
--color-codemirror-syntax-string: #0a3069;
--color-codemirror-syntax-support: #0550ae;
--color-codemirror-syntax-variable: #953800;
--color-checks-bg: #24292f;
--color-checks-run-border-width: 0px;
--color-checks-container-border-width: 0px;
--color-checks-text-primary: #f6f8fa;
--color-checks-text-secondary: #8c959f;
--color-checks-text-link: #54aeff;
--color-checks-btn-icon: #afb8c1;
--color-checks-btn-hover-icon: #f6f8fa;
--color-checks-btn-hover-bg: rgba(255,255,255,0.125);
--color-checks-input-text: #eaeef2;
--color-checks-input-placeholder-text: #8c959f;
--color-checks-input-focus-text: #8c959f;
--color-checks-input-bg: #32383f;
--color-checks-input-shadow: none;
--color-checks-donut-error: #fa4549;
--color-checks-donut-pending: #bf8700;
--color-checks-donut-success: #2da44e;
--color-checks-donut-neutral: #afb8c1;
--color-checks-dropdown-text: #afb8c1;
--color-checks-dropdown-bg: #32383f;
--color-checks-dropdown-border: #424a53;
--color-checks-dropdown-shadow: rgba(27,31,36,0.3);
--color-checks-dropdown-hover-text: #f6f8fa;
--color-checks-dropdown-hover-bg: #424a53;
--color-checks-dropdown-btn-hover-text: #f6f8fa;
--color-checks-dropdown-btn-hover-bg: #32383f;
--color-checks-scrollbar-thumb-bg: #57606a;
--color-checks-header-label-text: #d0d7de;
--color-checks-header-label-open-text: #f6f8fa;
--color-checks-header-border: #32383f;
--color-checks-header-icon: #8c959f;
--color-checks-line-text: #d0d7de;
--color-checks-line-num-text: rgba(140,149,159,0.75);
--color-checks-line-timestamp-text: #8c959f;
--color-checks-line-hover-bg: #32383f;
--color-checks-line-selected-bg: rgba(33,139,255,0.15);
--color-checks-line-selected-num-text: #54aeff;
--color-checks-line-dt-fm-text: #24292f;
--color-checks-line-dt-fm-bg: #9a6700;
--color-checks-gate-bg: rgba(125,78,0,0.15);
--color-checks-gate-text: #d0d7de;
--color-checks-gate-waiting-text: #afb8c1;
--color-checks-step-header-open-bg: #32383f;
--color-checks-step-error-text: #ff8182;
--color-checks-step-warning-text: #d4a72c;
--color-checks-logline-text: #8c959f;
--color-checks-logline-num-text: rgba(140,149,159,0.75);
--color-checks-logline-debug-text: #c297ff;
--color-checks-logline-error-text: #d0d7de;
--color-checks-logline-error-num-text: #ff8182;
--color-checks-logline-error-bg: rgba(164,14,38,0.15);
--color-checks-logline-warning-text: #d0d7de;
--color-checks-logline-warning-num-text: #d4a72c;
--color-checks-logline-warning-bg: rgba(125,78,0,0.15);
--color-checks-logline-command-text: #54aeff;
--color-checks-logline-section-text: #4ac26b;
--color-checks-ansi-black: #24292f;
--color-checks-ansi-black-bright: #32383f;
--color-checks-ansi-white: #d0d7de;
--color-checks-ansi-white-bright: #d0d7de;
--color-checks-ansi-gray: #8c959f;
--color-checks-ansi-red: #ff8182;
--color-checks-ansi-red-bright: #ffaba8;
--color-checks-ansi-green: #4ac26b;
--color-checks-ansi-green-bright: #6fdd8b;
--color-checks-ansi-yellow: #d4a72c;
--color-checks-ansi-yellow-bright: #eac54f;
--color-checks-ansi-blue: #54aeff;
--color-checks-ansi-blue-bright: #80ccff;
--color-checks-ansi-magenta: #c297ff;
--color-checks-ansi-magenta-bright: #d8b9ff;
--color-checks-ansi-cyan: #76e3ea;
--color-checks-ansi-cyan-bright: #b3f0ff;
--color-project-header-bg: #24292f;
--color-project-sidebar-bg: #ffffff;
--color-project-gradient-in: #ffffff;
--color-project-gradient-out: rgba(255,255,255,0);
--color-mktg-success: rgba(36,146,67,1);
--color-mktg-info: rgba(19,119,234,1);
--color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065);
--color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0);
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
--color-mktg-btn-bg-bottom: #4969ed;
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
--color-mktg-btn-bg-overlay-bottom: #3355e0;
--color-mktg-btn-text: #ffffff;
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
--color-mktg-btn-primary-bg-bottom: #2ea44f;
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
--color-mktg-btn-primary-text: #ffffff;
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
--color-mktg-btn-enterprise-text: #ffffff;
--color-mktg-btn-outline-text: #4969ed;
--color-mktg-btn-outline-border: rgba(73,105,237,0.3);
--color-mktg-btn-outline-hover-text: #3355e0;
--color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5);
--color-mktg-btn-outline-focus-border: #4969ed;
--color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5);
--color-mktg-btn-dark-text: #ffffff;
--color-mktg-btn-dark-border: rgba(255,255,255,0.3);
--color-mktg-btn-dark-hover-text: #ffffff;
--color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5);
--color-mktg-btn-dark-focus-border: #ffffff;
--color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5);
--color-avatar-bg: #ffffff;
--color-avatar-border: rgba(27,31,36,0.15);
--color-avatar-stack-fade: #afb8c1;
--color-avatar-stack-fade-more: #d0d7de;
--color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8);
--color-topic-tag-border: rgba(0,0,0,0);
--color-select-menu-backdrop-border: rgba(0,0,0,0);
--color-select-menu-tap-highlight: rgba(175,184,193,0.5);
--color-select-menu-tap-focus-bg: #b6e3ff;
--color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12);
--color-header-text: rgba(255,255,255,0.7);
--color-header-bg: #24292f;
--color-header-logo: #ffffff;
--color-header-search-bg: #24292f;
--color-header-search-border: #57606a;
--color-sidenav-selected-bg: #ffffff;
--color-menu-bg-active: rgba(0,0,0,0);
--color-control-transparent-bg-hover: #818b981a;
--color-input-disabled-bg: rgba(175,184,193,0.2);
--color-timeline-badge-bg: #eaeef2;
--color-ansi-black: #24292f;
--color-ansi-black-bright: #57606a;
--color-ansi-white: #6e7781;
--color-ansi-white-bright: #8c959f;
--color-ansi-gray: #6e7781;
--color-ansi-red: #cf222e;
--color-ansi-red-bright: #a40e26;
--color-ansi-green: #116329;
--color-ansi-green-bright: #1a7f37;
--color-ansi-yellow: #4d2d00;
--color-ansi-yellow-bright: #633c01;
--color-ansi-blue: #0969da;
--color-ansi-blue-bright: #218bff;
--color-ansi-magenta: #8250df;
--color-ansi-magenta-bright: #a475f9;
--color-ansi-cyan: #1b7c83;
--color-ansi-cyan-bright: #3192aa;
--color-btn-text: #24292f;
--color-btn-bg: #f6f8fa;
--color-btn-border: rgba(27,31,36,0.15);
--color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04);
--color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
--color-btn-hover-bg: #f3f4f6;
--color-btn-hover-border: rgba(27,31,36,0.15);
--color-btn-active-bg: hsla(220,14%,93%,1);
--color-btn-active-border: rgba(27,31,36,0.15);
--color-btn-selected-bg: hsla(220,14%,94%,1);
--color-btn-focus-bg: #f6f8fa;
--color-btn-focus-border: rgba(27,31,36,0.15);
--color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3);
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15);
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3);
--color-btn-counter-bg: rgba(27,31,36,0.08);
--color-btn-primary-text: #ffffff;
--color-btn-primary-bg: #2da44e;
--color-btn-primary-border: rgba(27,31,36,0.15);
--color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-primary-hover-bg: #2c974b;
--color-btn-primary-hover-border: rgba(27,31,36,0.15);
--color-btn-primary-selected-bg: hsla(137,55%,36%,1);
--color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2);
--color-btn-primary-disabled-text: rgba(255,255,255,0.8);
--color-btn-primary-disabled-bg: #94d3a2;
--color-btn-primary-disabled-border: rgba(27,31,36,0.15);
--color-btn-primary-focus-bg: #2da44e;
--color-btn-primary-focus-border: rgba(27,31,36,0.15);
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4);
--color-btn-primary-icon: rgba(255,255,255,0.8);
--color-btn-primary-counter-bg: rgba(255,255,255,0.2);
--color-btn-outline-text: #0969da;
--color-btn-outline-hover-text: #ffffff;
--color-btn-outline-hover-bg: #0969da;
--color-btn-outline-hover-border: rgba(27,31,36,0.15);
--color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-outline-selected-text: #ffffff;
--color-btn-outline-selected-bg: hsla(212,92%,42%,1);
--color-btn-outline-selected-border: rgba(27,31,36,0.15);
--color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2);
--color-btn-outline-disabled-text: rgba(9,105,218,0.5);
--color-btn-outline-disabled-bg: #f6f8fa;
--color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05);
--color-btn-outline-focus-border: rgba(27,31,36,0.15);
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4);
--color-btn-outline-counter-bg: rgba(9,105,218,0.1);
--color-btn-danger-text: #cf222e;
--color-btn-danger-hover-text: #ffffff;
--color-btn-danger-hover-bg: #a40e26;
--color-btn-danger-hover-border: rgba(27,31,36,0.15);
--color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
--color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-danger-selected-text: #ffffff;
--color-btn-danger-selected-bg: hsla(356,72%,44%,1);
--color-btn-danger-selected-border: rgba(27,31,36,0.15);
--color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2);
--color-btn-danger-disabled-text: rgba(207,34,46,0.5);
--color-btn-danger-disabled-bg: #f6f8fa;
--color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05);
--color-btn-danger-focus-border: rgba(27,31,36,0.15);
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4);
--color-btn-danger-counter-bg: rgba(207,34,46,0.1);
--color-btn-danger-icon: #cf222e;
--color-btn-danger-hover-icon: #ffffff;
--color-underlinenav-icon: #6e7781;
--color-underlinenav-border-hover: rgba(175,184,193,0.2);
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-fg-subtle: #6e7781;
--color-fg-on-emphasis: #ffffff;
--color-canvas-default: #ffffff;
--color-canvas-overlay: #ffffff;
--color-canvas-inset: #f6f8fa;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: hsla(210,18%,87%,1);
--color-border-subtle: rgba(27,31,36,0.15);
--color-shadow-small: 0 1px 0 rgba(27,31,36,0.04);
--color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15);
--color-shadow-large: 0 8px 24px rgba(140,149,159,0.2);
--color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3);
--color-neutral-emphasis-plus: #24292f;
--color-neutral-emphasis: #6e7781;
--color-neutral-muted: rgba(175,184,193,0.2);
--color-neutral-subtle: rgba(234,238,242,0.5);
--color-accent-fg: #0969da;
--color-accent-emphasis: #0969da;
--color-accent-muted: rgba(84,174,255,0.4);
--color-accent-subtle: #ddf4ff;
--color-success-fg: #1a7f37;
--color-success-emphasis: #2da44e;
--color-success-muted: rgba(74,194,107,0.4);
--color-success-subtle: #dafbe1;
--color-attention-fg: #9a6700;
--color-attention-emphasis: #bf8700;
--color-attention-muted: rgba(212,167,44,0.4);
--color-attention-subtle: #fff8c5;
--color-severe-fg: #bc4c00;
--color-severe-emphasis: #bc4c00;
--color-severe-muted: rgba(251,143,68,0.4);
--color-severe-subtle: #fff1e5;
--color-danger-fg: #cf222e;
--color-danger-emphasis: #cf222e;
--color-danger-muted: rgba(255,129,130,0.4);
--color-danger-subtle: #FFEBE9;
--color-done-fg: #8250df;
--color-done-emphasis: #8250df;
--color-done-muted: rgba(194,151,255,0.4);
--color-done-subtle: #fbefff;
--color-sponsors-fg: #bf3989;
--color-sponsors-emphasis: #bf3989;
--color-sponsors-muted: rgba(255,128,200,0.4);
--color-sponsors-subtle: #ffeff7;
--color-primer-canvas-backdrop: rgba(27,31,36,0.5);
--color-primer-canvas-sticky: rgba(255,255,255,0.95);
--color-primer-border-active: #FD8C73;
--color-primer-border-contrast: rgba(27,31,36,0.1);
--color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25);
--color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2);
--color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3);
--color-scale-black: #1b1f24;
--color-scale-white: #ffffff;
--color-scale-gray-0: #f6f8fa;
--color-scale-gray-1: #eaeef2;
--color-scale-gray-2: #d0d7de;
--color-scale-gray-3: #afb8c1;
--color-scale-gray-4: #8c959f;
--color-scale-gray-5: #6e7781;
--color-scale-gray-6: #57606a;
--color-scale-gray-7: #424a53;
--color-scale-gray-8: #32383f;
--color-scale-gray-9: #24292f;
--color-scale-blue-0: #ddf4ff;
--color-scale-blue-1: #b6e3ff;
--color-scale-blue-2: #80ccff;
--color-scale-blue-3: #54aeff;
--color-scale-blue-4: #218bff;
--color-scale-blue-5: #0969da;
--color-scale-blue-6: #0550ae;
--color-scale-blue-7: #033d8b;
--color-scale-blue-8: #0a3069;
--color-scale-blue-9: #002155;
--color-scale-green-0: #dafbe1;
--color-scale-green-1: #aceebb;
--color-scale-green-2: #6fdd8b;
--color-scale-green-3: #4ac26b;
--color-scale-green-4: #2da44e;
--color-scale-green-5: #1a7f37;
--color-scale-green-6: #116329;
--color-scale-green-7: #044f1e;
--color-scale-green-8: #003d16;
--color-scale-green-9: #002d11;
--color-scale-yellow-0: #fff8c5;
--color-scale-yellow-1: #fae17d;
--color-scale-yellow-2: #eac54f;
--color-scale-yellow-3: #d4a72c;
--color-scale-yellow-4: #bf8700;
--color-scale-yellow-5: #9a6700;
--color-scale-yellow-6: #7d4e00;
--color-scale-yellow-7: #633c01;
--color-scale-yellow-8: #4d2d00;
--color-scale-yellow-9: #3b2300;
--color-scale-orange-0: #fff1e5;
--color-scale-orange-1: #ffd8b5;
--color-scale-orange-2: #ffb77c;
--color-scale-orange-3: #fb8f44;
--color-scale-orange-4: #e16f24;
--color-scale-orange-5: #bc4c00;
--color-scale-orange-6: #953800;
--color-scale-orange-7: #762c00;
--color-scale-orange-8: #5c2200;
--color-scale-orange-9: #471700;
--color-scale-red-0: #FFEBE9;
--color-scale-red-1: #ffcecb;
--color-scale-red-2: #ffaba8;
--color-scale-red-3: #ff8182;
--color-scale-red-4: #fa4549;
--color-scale-red-5: #cf222e;
--color-scale-red-6: #a40e26;
--color-scale-red-7: #82071e;
--color-scale-red-8: #660018;
--color-scale-red-9: #4c0014;
--color-scale-purple-0: #fbefff;
--color-scale-purple-1: #ecd8ff;
--color-scale-purple-2: #d8b9ff;
--color-scale-purple-3: #c297ff;
--color-scale-purple-4: #a475f9;
--color-scale-purple-5: #8250df;
--color-scale-purple-6: #6639ba;
--color-scale-purple-7: #512a97;
--color-scale-purple-8: #3e1f79;
--color-scale-purple-9: #2e1461;
--color-scale-pink-0: #ffeff7;
--color-scale-pink-1: #ffd3eb;
--color-scale-pink-2: #ffadda;
--color-scale-pink-3: #ff80c8;
--color-scale-pink-4: #e85aad;
--color-scale-pink-5: #bf3989;
--color-scale-pink-6: #99286e;
--color-scale-pink-7: #772057;
--color-scale-pink-8: #611347;
--color-scale-pink-9: #4d0336;
--color-scale-coral-0: #FFF0EB;
--color-scale-coral-1: #FFD6CC;
--color-scale-coral-2: #FFB4A1;
--color-scale-coral-3: #FD8C73;
--color-scale-coral-4: #EC6547;
--color-scale-coral-5: #C4432B;
--color-scale-coral-6: #9E2F1C;
--color-scale-coral-7: #801F0F;
--color-scale-coral-8: #691105;
--color-scale-coral-9: #510901
}
@media(prefers-color-scheme: dark) {
:root {
--color-canvas-default-transparent: rgba(13,17,23,0);
--color-marketing-icon-primary: #79c0ff;
--color-marketing-icon-secondary: #1f6feb;
--color-diff-blob-addition-num-text: #c9d1d9;
--color-diff-blob-addition-fg: #c9d1d9;
--color-diff-blob-addition-num-bg: rgba(63,185,80,0.3);
--color-diff-blob-addition-line-bg: rgba(46,160,67,0.15);
--color-diff-blob-addition-word-bg: rgba(46,160,67,0.4);
--color-diff-blob-deletion-num-text: #c9d1d9;
--color-diff-blob-deletion-fg: #c9d1d9;
--color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3);
--color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15);
--color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4);
--color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4);
--color-diff-blob-expander-icon: #8b949e;
--color-diff-blob-selected-line-highlight-mix-blend-mode: screen;
--color-diffstat-deletion-border: rgba(240,246,252,0.1);
--color-diffstat-addition-border: rgba(240,246,252,0.1);
--color-diffstat-addition-bg: #3fb950;
--color-search-keyword-hl: rgba(210,153,34,0.4);
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
--color-prettylights-syntax-storage-modifier-import: #c9d1d9;
--color-prettylights-syntax-entity-tag: #7ee787;
--color-prettylights-syntax-keyword: #ff7b72;
--color-prettylights-syntax-string: #a5d6ff;
--color-prettylights-syntax-variable: #ffa657;
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
--color-prettylights-syntax-invalid-illegal-bg: #8e1519;
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
--color-prettylights-syntax-carriage-return-bg: #b62324;
--color-prettylights-syntax-string-regexp: #7ee787;
--color-prettylights-syntax-markup-list: #f2cc60;
--color-prettylights-syntax-markup-heading: #1f6feb;
--color-prettylights-syntax-markup-italic: #c9d1d9;
--color-prettylights-syntax-markup-bold: #c9d1d9;
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
--color-prettylights-syntax-markup-deleted-bg: #67060c;
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
--color-prettylights-syntax-markup-inserted-bg: #033a16;
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
--color-codemirror-text: #c9d1d9;
--color-codemirror-bg: #0d1117;
--color-codemirror-gutters-bg: #0d1117;
--color-codemirror-guttermarker-text: #0d1117;
--color-codemirror-guttermarker-subtle-text: #484f58;
--color-codemirror-linenumber-text: #8b949e;
--color-codemirror-cursor: #c9d1d9;
--color-codemirror-selection-bg: rgba(56,139,253,0.4);
--color-codemirror-activeline-bg: rgba(110,118,129,0.1);
--color-codemirror-matchingbracket-text: #c9d1d9;
--color-codemirror-lines-bg: #0d1117;
--color-codemirror-syntax-comment: #8b949e;
--color-codemirror-syntax-constant: #79c0ff;
--color-codemirror-syntax-entity: #d2a8ff;
--color-codemirror-syntax-keyword: #ff7b72;
--color-codemirror-syntax-storage: #ff7b72;
--color-codemirror-syntax-string: #a5d6ff;
--color-codemirror-syntax-support: #79c0ff;
--color-codemirror-syntax-variable: #ffa657;
--color-checks-bg: #010409;
--color-checks-run-border-width: 1px;
--color-checks-container-border-width: 1px;
--color-checks-text-primary: #c9d1d9;
--color-checks-text-secondary: #8b949e;
--color-checks-text-link: #58a6ff;
--color-checks-btn-icon: #8b949e;
--color-checks-btn-hover-icon: #c9d1d9;
--color-checks-btn-hover-bg: rgba(110,118,129,0.1);
--color-checks-input-text: #8b949e;
--color-checks-input-placeholder-text: #484f58;
--color-checks-input-focus-text: #c9d1d9;
--color-checks-input-bg: #161b22;
--color-checks-input-shadow: none;
--color-checks-donut-error: #f85149;
--color-checks-donut-pending: #d29922;
--color-checks-donut-success: #2ea043;
--color-checks-donut-neutral: #8b949e;
--color-checks-dropdown-text: #c9d1d9;
--color-checks-dropdown-bg: #161b22;
--color-checks-dropdown-border: #30363d;
--color-checks-dropdown-shadow: rgba(1,4,9,0.3);
--color-checks-dropdown-hover-text: #c9d1d9;
--color-checks-dropdown-hover-bg: rgba(110,118,129,0.1);
--color-checks-dropdown-btn-hover-text: #c9d1d9;
--color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1);
--color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4);
--color-checks-header-label-text: #8b949e;
--color-checks-header-label-open-text: #c9d1d9;
--color-checks-header-border: #21262d;
--color-checks-header-icon: #8b949e;
--color-checks-line-text: #8b949e;
--color-checks-line-num-text: #484f58;
--color-checks-line-timestamp-text: #484f58;
--color-checks-line-hover-bg: rgba(110,118,129,0.1);
--color-checks-line-selected-bg: rgba(56,139,253,0.15);
--color-checks-line-selected-num-text: #58a6ff;
--color-checks-line-dt-fm-text: #f0f6fc;
--color-checks-line-dt-fm-bg: #9e6a03;
--color-checks-gate-bg: rgba(187,128,9,0.15);
--color-checks-gate-text: #8b949e;
--color-checks-gate-waiting-text: #d29922;
--color-checks-step-header-open-bg: #161b22;
--color-checks-step-error-text: #f85149;
--color-checks-step-warning-text: #d29922;
--color-checks-logline-text: #8b949e;
--color-checks-logline-num-text: #484f58;
--color-checks-logline-debug-text: #a371f7;
--color-checks-logline-error-text: #8b949e;
--color-checks-logline-error-num-text: #484f58;
--color-checks-logline-error-bg: rgba(248,81,73,0.15);
--color-checks-logline-warning-text: #8b949e;
--color-checks-logline-warning-num-text: #d29922;
--color-checks-logline-warning-bg: rgba(187,128,9,0.15);
--color-checks-logline-command-text: #58a6ff;
--color-checks-logline-section-text: #3fb950;
--color-checks-ansi-black: #0d1117;
--color-checks-ansi-black-bright: #161b22;
--color-checks-ansi-white: #b1bac4;
--color-checks-ansi-white-bright: #b1bac4;
--color-checks-ansi-gray: #6e7681;
--color-checks-ansi-red: #ff7b72;
--color-checks-ansi-red-bright: #ffa198;
--color-checks-ansi-green: #3fb950;
--color-checks-ansi-green-bright: #56d364;
--color-checks-ansi-yellow: #d29922;
--color-checks-ansi-yellow-bright: #e3b341;
--color-checks-ansi-blue: #58a6ff;
--color-checks-ansi-blue-bright: #79c0ff;
--color-checks-ansi-magenta: #bc8cff;
--color-checks-ansi-magenta-bright: #d2a8ff;
--color-checks-ansi-cyan: #76e3ea;
--color-checks-ansi-cyan-bright: #b3f0ff;
--color-project-header-bg: #0d1117;
--color-project-sidebar-bg: #161b22;
--color-project-gradient-in: #161b22;
--color-project-gradient-out: rgba(22,27,34,0);
--color-mktg-success: rgba(41,147,61,1);
--color-mktg-info: rgba(42,123,243,1);
--color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065);
--color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0);
--color-mktg-btn-bg-top: hsla(228,82%,66%,1);
--color-mktg-btn-bg-bottom: #4969ed;
--color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
--color-mktg-btn-bg-overlay-bottom: #3355e0;
--color-mktg-btn-text: #f0f6fc;
--color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
--color-mktg-btn-primary-bg-bottom: #2ea44f;
--color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
--color-mktg-btn-primary-bg-overlay-bottom: #22863a;
--color-mktg-btn-primary-text: #f0f6fc;
--color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
--color-mktg-btn-enterprise-bg-bottom: #6f57ff;
--color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
--color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
--color-mktg-btn-enterprise-text: #f0f6fc;
--color-mktg-btn-outline-text: #f0f6fc;
--color-mktg-btn-outline-border: rgba(240,246,252,0.3);
--color-mktg-btn-outline-hover-text: #f0f6fc;
--color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5);
--color-mktg-btn-outline-focus-border: #f0f6fc;
--color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5);
--color-mktg-btn-dark-text: #f0f6fc;
--color-mktg-btn-dark-border: rgba(240,246,252,0.3);
--color-mktg-btn-dark-hover-text: #f0f6fc;
--color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5);
--color-mktg-btn-dark-focus-border: #f0f6fc;
--color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5);
--color-avatar-bg: rgba(240,246,252,0.1);
--color-avatar-border: rgba(240,246,252,0.1);
--color-avatar-stack-fade: #30363d;
--color-avatar-stack-fade-more: #21262d;
--color-avatar-child-shadow: -2px -2px 0 #0d1117;
--color-topic-tag-border: rgba(0,0,0,0);
--color-select-menu-backdrop-border: #484f58;
--color-select-menu-tap-highlight: rgba(48,54,61,0.5);
--color-select-menu-tap-focus-bg: #0c2d6b;
--color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85);
--color-header-text: rgba(240,246,252,0.7);
--color-header-bg: #161b22;
--color-header-logo: #f0f6fc;
--color-header-search-bg: #0d1117;
--color-header-search-border: #30363d;
--color-sidenav-selected-bg: #21262d;
--color-menu-bg-active: #161b22;
--color-control-transparent-bg-hover: #656c7633;
--color-input-disabled-bg: rgba(110,118,129,0);
--color-timeline-badge-bg: #21262d;
--color-ansi-black: #484f58;
--color-ansi-black-bright: #6e7681;
--color-ansi-white: #b1bac4;
--color-ansi-white-bright: #f0f6fc;
--color-ansi-gray: #6e7681;
--color-ansi-red: #ff7b72;
--color-ansi-red-bright: #ffa198;
--color-ansi-green: #3fb950;
--color-ansi-green-bright: #56d364;
--color-ansi-yellow: #d29922;
--color-ansi-yellow-bright: #e3b341;
--color-ansi-blue: #58a6ff;
--color-ansi-blue-bright: #79c0ff;
--color-ansi-magenta: #bc8cff;
--color-ansi-magenta-bright: #d2a8ff;
--color-ansi-cyan: #39c5cf;
--color-ansi-cyan-bright: #56d4dd;
--color-btn-text: #c9d1d9;
--color-btn-bg: #21262d;
--color-btn-border: rgba(240,246,252,0.1);
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: #30363d;
--color-btn-hover-border: #8b949e;
--color-btn-active-bg: hsla(212,12%,18%,1);
--color-btn-active-border: #6e7681;
--color-btn-selected-bg: #161b22;
--color-btn-focus-bg: #21262d;
--color-btn-focus-border: #8b949e;
--color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3);
--color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15);
--color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3);
--color-btn-counter-bg: #30363d;
--color-btn-primary-text: #ffffff;
--color-btn-primary-bg: #238636;
--color-btn-primary-border: rgba(240,246,252,0.1);
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: #2ea043;
--color-btn-primary-hover-border: rgba(240,246,252,0.1);
--color-btn-primary-selected-bg: #238636;
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: rgba(240,246,252,0.5);
--color-btn-primary-disabled-bg: rgba(35,134,54,0.6);
--color-btn-primary-disabled-border: rgba(240,246,252,0.1);
--color-btn-primary-focus-bg: #238636;
--color-btn-primary-focus-border: rgba(240,246,252,0.1);
--color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4);
--color-btn-primary-icon: #f0f6fc;
--color-btn-primary-counter-bg: rgba(240,246,252,0.2);
--color-btn-outline-text: #58a6ff;
--color-btn-outline-hover-text: #58a6ff;
--color-btn-outline-hover-bg: #30363d;
--color-btn-outline-hover-border: rgba(240,246,252,0.1);
--color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1);
--color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03);
--color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2);
--color-btn-outline-selected-text: #f0f6fc;
--color-btn-outline-selected-bg: #0d419d;
--color-btn-outline-selected-border: rgba(240,246,252,0.1);
--color-btn-outline-selected-shadow: 0 0 transparent;
--color-btn-outline-disabled-text: rgba(88,166,255,0.5);
--color-btn-outline-disabled-bg: #0d1117;
--color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05);
--color-btn-outline-focus-border: rgba(240,246,252,0.1);
--color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4);
--color-btn-outline-counter-bg: rgba(31,111,235,0.1);
--color-btn-danger-text: #f85149;
--color-btn-danger-hover-text: #f0f6fc;
--color-btn-danger-hover-bg: #da3633;
--color-btn-danger-hover-border: #f85149;
--color-btn-danger-hover-shadow: 0 0 transparent;
--color-btn-danger-hover-inset-shadow: 0 0 transparent;
--color-btn-danger-hover-icon: #f0f6fc;
--color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
--color-btn-danger-selected-text: #ffffff;
--color-btn-danger-selected-bg: #b62324;
--color-btn-danger-selected-border: #ff7b72;
--color-btn-danger-selected-shadow: 0 0 transparent;
--color-btn-danger-disabled-text: rgba(248,81,73,0.5);
--color-btn-danger-disabled-bg: #0d1117;
--color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05);
--color-btn-danger-focus-border: #f85149;
--color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4);
--color-btn-danger-counter-bg: rgba(218,54,51,0.1);
--color-btn-danger-icon: #f85149;
--color-underlinenav-icon: #484f58;
--color-underlinenav-border-hover: rgba(110,118,129,0.4);
--color-fg-default: #c9d1d9;
--color-fg-muted: #8b949e;
--color-fg-subtle: #484f58;
--color-fg-on-emphasis: #f0f6fc;
--color-canvas-default: #0d1117;
--color-canvas-overlay: #161b22;
--color-canvas-inset: #010409;
--color-canvas-subtle: #161b22;
--color-border-default: #30363d;
--color-border-muted: #21262d;
--color-border-subtle: rgba(240,246,252,0.1);
--color-shadow-small: 0 0 transparent;
--color-shadow-medium: 0 3px 6px #010409;
--color-shadow-large: 0 8px 24px #010409;
--color-shadow-extra-large: 0 12px 48px #010409;
--color-neutral-emphasis-plus: #6e7681;
--color-neutral-emphasis: #6e7681;
--color-neutral-muted: rgba(110,118,129,0.4);
--color-neutral-subtle: rgba(110,118,129,0.1);
--color-accent-fg: #58a6ff;
--color-accent-emphasis: #1f6feb;
--color-accent-muted: rgba(56,139,253,0.4);
--color-accent-subtle: rgba(56,139,253,0.15);
--color-success-fg: #3fb950;
--color-success-emphasis: #238636;
--color-success-muted: rgba(46,160,67,0.4);
--color-success-subtle: rgba(46,160,67,0.15);
--color-attention-fg: #d29922;
--color-attention-emphasis: #9e6a03;
--color-attention-muted: rgba(187,128,9,0.4);
--color-attention-subtle: rgba(187,128,9,0.15);
--color-severe-fg: #db6d28;
--color-severe-emphasis: #bd561d;
--color-severe-muted: rgba(219,109,40,0.4);
--color-severe-subtle: rgba(219,109,40,0.15);
--color-danger-fg: #f85149;
--color-danger-emphasis: #da3633;
--color-danger-muted: rgba(248,81,73,0.4);
--color-danger-subtle: rgba(248,81,73,0.15);
--color-done-fg: #a371f7;
--color-done-emphasis: #8957e5;
--color-done-muted: rgba(163,113,247,0.4);
--color-done-subtle: rgba(163,113,247,0.15);
--color-sponsors-fg: #db61a2;
--color-sponsors-emphasis: #bf4b8a;
--color-sponsors-muted: rgba(219,97,162,0.4);
--color-sponsors-subtle: rgba(219,97,162,0.15);
--color-primer-canvas-backdrop: rgba(1,4,9,0.8);
--color-primer-canvas-sticky: rgba(13,17,23,0.95);
--color-primer-border-active: #F78166;
--color-primer-border-contrast: rgba(240,246,252,0.2);
--color-primer-shadow-highlight: 0 0 transparent;
--color-primer-shadow-inset: 0 0 transparent;
--color-primer-shadow-focus: 0 0 0 3px #0c2d6b;
--color-scale-black: #010409;
--color-scale-white: #f0f6fc;
--color-scale-gray-0: #f0f6fc;
--color-scale-gray-1: #c9d1d9;
--color-scale-gray-2: #b1bac4;
--color-scale-gray-3: #8b949e;
--color-scale-gray-4: #6e7681;
--color-scale-gray-5: #484f58;
--color-scale-gray-6: #30363d;
--color-scale-gray-7: #21262d;
--color-scale-gray-8: #161b22;
--color-scale-gray-9: #0d1117;
--color-scale-blue-0: #cae8ff;
--color-scale-blue-1: #a5d6ff;
--color-scale-blue-2: #79c0ff;
--color-scale-blue-3: #58a6ff;
--color-scale-blue-4: #388bfd;
--color-scale-blue-5: #1f6feb;
--color-scale-blue-6: #1158c7;
--color-scale-blue-7: #0d419d;
--color-scale-blue-8: #0c2d6b;
--color-scale-blue-9: #051d4d;
--color-scale-green-0: #aff5b4;
--color-scale-green-1: #7ee787;
--color-scale-green-2: #56d364;
--color-scale-green-3: #3fb950;
--color-scale-green-4: #2ea043;
--color-scale-green-5: #238636;
--color-scale-green-6: #196c2e;
--color-scale-green-7: #0f5323;
--color-scale-green-8: #033a16;
--color-scale-green-9: #04260f;
--color-scale-yellow-0: #f8e3a1;
--color-scale-yellow-1: #f2cc60;
--color-scale-yellow-2: #e3b341;
--color-scale-yellow-3: #d29922;
--color-scale-yellow-4: #bb8009;
--color-scale-yellow-5: #9e6a03;
--color-scale-yellow-6: #845306;
--color-scale-yellow-7: #693e00;
--color-scale-yellow-8: #4b2900;
--color-scale-yellow-9: #341a00;
--color-scale-orange-0: #ffdfb6;
--color-scale-orange-1: #ffc680;
--color-scale-orange-2: #ffa657;
--color-scale-orange-3: #f0883e;
--color-scale-orange-4: #db6d28;
--color-scale-orange-5: #bd561d;
--color-scale-orange-6: #9b4215;
--color-scale-orange-7: #762d0a;
--color-scale-orange-8: #5a1e02;
--color-scale-orange-9: #3d1300;
--color-scale-red-0: #ffdcd7;
--color-scale-red-1: #ffc1ba;
--color-scale-red-2: #ffa198;
--color-scale-red-3: #ff7b72;
--color-scale-red-4: #f85149;
--color-scale-red-5: #da3633;
--color-scale-red-6: #b62324;
--color-scale-red-7: #8e1519;
--color-scale-red-8: #67060c;
--color-scale-red-9: #490202;
--color-scale-purple-0: #eddeff;
--color-scale-purple-1: #e2c5ff;
--color-scale-purple-2: #d2a8ff;
--color-scale-purple-3: #bc8cff;
--color-scale-purple-4: #a371f7;
--color-scale-purple-5: #8957e5;
--color-scale-purple-6: #6e40c9;
--color-scale-purple-7: #553098;
--color-scale-purple-8: #3c1e70;
--color-scale-purple-9: #271052;
--color-scale-pink-0: #ffdaec;
--color-scale-pink-1: #ffbedd;
--color-scale-pink-2: #ff9bce;
--color-scale-pink-3: #f778ba;
--color-scale-pink-4: #db61a2;
--color-scale-pink-5: #bf4b8a;
--color-scale-pink-6: #9e3670;
--color-scale-pink-7: #7d2457;
--color-scale-pink-8: #5e103e;
--color-scale-pink-9: #42062a;
--color-scale-coral-0: #FFDDD2;
--color-scale-coral-1: #FFC2B2;
--color-scale-coral-2: #FFA28B;
--color-scale-coral-3: #F78166;
--color-scale-coral-4: #EA6045;
--color-scale-coral-5: #CF462D;
--color-scale-coral-6: #AC3220;
--color-scale-coral-7: #872012;
--color-scale-coral-8: #640D04;
--color-scale-coral-9: #460701
}
}

View File

@@ -203,4 +203,60 @@ body {
cursor: pointer;
padding: 0;
font: inherit;
}
}
/* Auth token section */
.auth-token-section {
margin: 16px 0;
padding: 16px;
background-color: #f6f8fa;
border-radius: 6px;
}
.auth-token-description {
font-size: 12px;
color: #656d76;
margin-bottom: 12px;
}
.auth-token-container {
display: flex;
align-items: center;
gap: 8px;
background-color: #ffffff;
padding: 8px;
}
.auth-token-code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: #1f2328;
border: none;
flex: 1;
padding: 0;
word-break: break-all;
}
.auth-token-refresh {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.auth-token-refresh svg {
margin: 0;
}
.auth-token-refresh:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}

View File

@@ -14,10 +14,12 @@
* limitations under the License.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
import { Button, TabItem } from './tabItem';
import { AuthTokenSection, getOrCreateAuthToken } from './authToken';
import type { TabInfo } from './tabItem';
type Status =
| { type: 'connecting'; message: string }
@@ -37,54 +39,79 @@ const ConnectApp: React.FC = () => {
const [newTab, setNewTab] = useState<boolean>(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
const runAsync = async () => {
const params = new URLSearchParams(window.location.search);
const relayUrl = params.get('mcpRelayUrl');
if (!relayUrl) {
setShowButtons(false);
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
return;
}
if (!relayUrl) {
handleReject('Missing mcpRelayUrl parameter in URL.');
return;
}
setMcpRelayUrl(relayUrl);
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
const extensionVersion = chrome.runtime.getManifest().version;
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
extensionVersion,
try {
const host = new URL(relayUrl).hostname;
if (host !== '127.0.0.1' && host !== '[::1]') {
handleReject(`MCP extension only allows loopback connections (127.0.0.1 or [::1]). Received host: ${host}`);
return;
}
});
return;
}
} catch (e) {
handleReject(`Invalid mcpRelayUrl parameter in URL: ${relayUrl}. ${e}`);
return;
}
void connectToMCPRelay(relayUrl);
setMcpRelayUrl(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
if (params.get('newTab') === 'true') {
setNewTab(true);
setShowTabList(false);
} else {
void loadTabs();
}
try {
const client = JSON.parse(params.get('client') || '{}');
const info = `${client.name}/${client.version}`;
setClientInfo(info);
setStatus({
type: 'connecting',
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
});
} catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' });
return;
}
const parsedVersion = parseInt(params.get('protocolVersion') ?? '', 10);
const requiredVersion = isNaN(parsedVersion) ? 1 : parsedVersion;
if (requiredVersion > SUPPORTED_PROTOCOL_VERSION) {
const extensionVersion = chrome.runtime.getManifest().version;
setShowButtons(false);
setShowTabList(false);
setStatus({
type: 'error',
versionMismatch: {
extensionVersion,
}
});
return;
}
const expectedToken = getOrCreateAuthToken();
const token = params.get('token');
if (token === expectedToken) {
await connectToMCPRelay(relayUrl);
await handleConnectToTab();
return;
}
if (token) {
handleReject('Invalid token provided.');
return;
}
await connectToMCPRelay(relayUrl);
// If this is a browser_navigate command, hide the tab list and show simple allow/reject
if (params.get('newTab') === 'true') {
setNewTab(true);
setShowTabList(false);
} else {
await loadTabs();
}
};
void runAsync();
}, []);
const handleReject = useCallback((message: string) => {
@@ -94,7 +121,6 @@ const ConnectApp: React.FC = () => {
}, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
handleReject(response.error);
@@ -174,6 +200,10 @@ const ConnectApp: React.FC = () => {
</div>
)}
{status?.type === 'connecting' && (
<AuthTokenSection />
)}
{showTabList && (
<div>
<div className='tab-section-title'>

View File

@@ -0,0 +1,39 @@
/*
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.
*/
.copy-icon {
flex: none;
height: 24px;
width: 24px;
border: none;
outline: none;
color: var(--color-fg-muted);
background: transparent;
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.copy-icon svg {
margin: 0;
}
.copy-icon:not(:disabled):hover {
background-color: var(--color-btn-selected-bg);
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import * as icons from './icons';
import './copyToClipboard.css';
type CopyToClipboardProps = {
value: string;
};
/**
* A copy to clipboard button.
*/
export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({ value }) => {
type IconType = 'copy' | 'check' | 'cross';
const [icon, setIcon] = React.useState<IconType>('copy');
React.useEffect(() => {
setIcon('copy');
}, [value]);
React.useEffect(() => {
if (icon === 'check') {
const timeout = setTimeout(() => {
setIcon('copy');
}, 3000);
return () => clearTimeout(timeout);
}
}, [icon]);
const handleCopy = React.useCallback(() => {
navigator.clipboard.writeText(value).then(() => {
setIcon('check');
}, () => {
setIcon('cross');
});
}, [value]);
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
return <button className='copy-icon' title='Copy to clipboard' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
};

View File

@@ -0,0 +1,32 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.octicon {
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
margin-right: 7px;
flex: none;
}
.color-icon-success {
color: var(--color-success-fg) !important;
}
.color-text-danger {
color: var(--color-danger-fg) !important;
}

View File

@@ -0,0 +1,49 @@
/*
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 './icons.css';
import './colors.css';
export const cross = () => {
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
</svg>;
};
export const check = () => {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
</svg>;
};
export const copy = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16' aria-hidden='true'>
<path d='M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z'></path>
<path d='M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z'></path>
</svg>;
};
export const refresh = () => {
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"></path>
</svg>;
};
export const chevronDown = () => {
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16" aria-hidden='true'>
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"></path>
</svg>;
};

View File

@@ -16,9 +16,10 @@
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem.js';
import type { TabInfo } from './tabItem';
import { AuthTokenSection } from './authToken';
interface ConnectionStatus {
isConnected: boolean;
@@ -97,6 +98,7 @@ const StatusApp: React.FC = () => {
No MCP clients are currently connected.
</div>
)}
<AuthTokenSection />
</div>
</div>
);

View File

@@ -0,0 +1,304 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
import { test as base, expect } from '../../playwright-mcp/tests/fixtures';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../playwright-mcp/tests/fixtures';
type BrowserWithExtension = {
userDataDir: string;
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
};
type TestFixtures = {
browserWithExtension: BrowserWithExtension,
pathToExtension: string,
useShortConnectionTimeout: (timeoutMs: number) => void
overrideProtocolVersion: (version: number) => void
};
const test = base.extend<TestFixtures>({
pathToExtension: async ({}, use) => {
await use(path.resolve(__dirname, '../dist'));
},
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
// The flags no longer work in Chrome since
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async (mode?: 'disable-extension') => {
browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: mcpBrowser,
// Opening the browser singleton only works in headed.
headless: false,
// Automation disables singleton browser process behavior, which is necessary for the extension.
ignoreDefaultArgs: ['--enable-automation'],
args: mode === 'disable-extension' ? [] : [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// for manifest v3:
let [serviceWorker] = browserContext.serviceWorkers();
if (!serviceWorker)
serviceWorker = await browserContext.waitForEvent('serviceworker');
return browserContext;
}
});
await browserContext?.close();
},
useShortConnectionTimeout: async ({}, use) => {
await use((timeoutMs: number) => {
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
});
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
},
overrideProtocolVersion: async ({}, use) => {
await use((version: number) => {
process.env.PWMCP_TEST_PROTOCOL_VERSION = version.toString();
});
process.env.PWMCP_TEST_PROTOCOL_VERSION = undefined;
}
});
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
return client;
}
const testWithOldExtensionVersion = test.extend({
pathToExtension: async ({}, use, testInfo) => {
const extensionDir = testInfo.outputPath('extension');
const oldPath = path.resolve(__dirname, '../dist');
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
const manifestPath = path.join(extensionDir, 'manifest.json');
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
manifest.version = '0.0.1';
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
await use(extensionDir);
},
});
test(`navigate with extension`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const client = await startWithExtensionFlag(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`snapshot of an existing page`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto(server.HELLO_WORLD);
// Another empty page.
await browserContext.newPage();
expect(browserContext.pages()).toHaveLength(3);
const client = await startWithExtensionFlag(browserWithExtension, startClient);
expect(browserContext.pages()).toHaveLength(3);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_snapshot',
arguments: { },
});
const selectorPage = await confirmationPagePromise;
expect(browserContext.pages()).toHaveLength(4);
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
expect(browserContext.pages()).toHaveLength(4);
});
test(`extension not installed timeout`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(100);
const browserContext = await browserWithExtension.launch();
const client = await startWithExtensionFlag(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
error: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
isError: true,
});
await confirmationPagePromise;
});
testWithOldExtensionVersion(`works with old extension version`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(500);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startWithExtensionFlag(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const selectorPage = await confirmationPagePromise;
// For browser_navigate command, the UI shows Allow/Reject buttons instead of tab selector
await selectorPage.getByRole('button', { name: 'Allow' }).click();
expect(await navigateResponse).toHaveResponse({
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});
test(`extension needs update`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout, overrideProtocolVersion }) => {
useShortConnectionTimeout(500);
overrideProtocolVersion(1000);
// Prelaunch the browser, so that it is properly closed after the test.
const browserContext = await browserWithExtension.launch();
const client = await startWithExtensionFlag(browserWithExtension, startClient);
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
});
const navigateResponse = client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
const confirmationPage = await confirmationPagePromise;
await expect(confirmationPage.locator('.status-banner')).toContainText(`Playwright MCP version trying to connect requires newer extension version`);
expect(await navigateResponse).toHaveResponse({
error: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
});
test(`custom executablePath`, async ({ startClient, server, useShortConnectionTimeout }) => {
useShortConnectionTimeout(1000);
const executablePath = test.info().outputPath('echo.sh');
await fs.promises.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
const { client } = await startClient({
args: [`--extension`],
config: {
browser: {
launchOptions: {
executablePath,
},
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(await navigateResponse).toHaveResponse({
error: expect.stringContaining('Extension connection timeout.'),
isError: true,
});
expect(await fs.promises.readFile(test.info().outputPath('output.txt'), 'utf8')).toContain('Custom exec args: chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html?');
});
test(`bypass connection dialog with token`, async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const page = await browserContext.newPage();
await page.goto('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/status.html');
const token = await page.locator('.auth-token-code').textContent();
const [name, value] = token?.split('=') || [];
const { client } = await startClient({
args: [`--extension`],
extensionToken: value,
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
expect(await navigateResponse).toHaveResponse({
snapshot: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
});
});

View File

@@ -10,7 +10,8 @@
"resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx",
"jsxImportSource": "react"
"jsxImportSource": "react",
"noEmit": true
},
"include": [
"src",

View File

@@ -14,12 +14,18 @@
* limitations under the License.
*/
import debug from 'debug';
import { resolve } from 'path';
import { defineConfig } from 'vite';
const errorsDebug = debug('pw:mcp:errors');
export function logUnhandledError(error: unknown) {
errorsDebug(error);
}
export const testDebug = debug('pw:mcp:test');
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/background.ts'),
fileName: 'lib/background',
formats: ['es']
},
outDir: 'dist',
emptyOutDir: false,
minify: false
}
});

0
packages/playwright-cli/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,4 @@
**/*
!README.md
!LICENSE
!playwright-cli.js

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.

View File

@@ -0,0 +1,391 @@
# playwright-cli
Playwright CLI with SKILLS
### Playwright CLI vs Playwright MCP
This package provides CLI interface into Playwright. If you are using **coding agents**, that is the best fit.
- **CLI**: Modern **coding agents** increasingly favor CLIbased workflows exposed as SKILLs over MCP because CLI invocations are more token-efficient: they avoid loading large tool schemas and verbose accessibility trees into the model context, allowing agents to act through concise, purpose-built commands. This makes CLI + SKILLs better suited for high-throughput coding agents that must balance browser automation with large codebases, tests, and reasoning within limited context windows.
- **MCP**: MCP remains relevant for specialized agentic loops that benefit from persistent state, rich introspection, and iterative reasoning over page structure, such as exploratory automation, self-healing tests, or long-running autonomous workflows where maintaining continuous browser context outweighs token cost concerns. Learn more about [Playwright MCP](https://github.com/microsoft/playwright-mcp).
### Key Features
- **Token-efficient**. Does not force page data into LLM.
### Requirements
- Node.js 18 or newer
- Claude Code, GitHub Copilot, or any other coding agent.
## Getting Started
## Installation
```bash
npm install -g @playwright/cli@latest
playwright-cli --help
```
## Demo
```
> Use playwright skills to test https://demo.playwright.dev/todomvc/.
Take screenshots for all successful and failing scenarios.
```
Your agent will be running commands, but it does not mean you can't play with it manually:
```
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
playwright-cli type "Buy groceries"
playwright-cli press Enter
playwright-cli type "Water flowers"
playwright-cli press Enter
playwright-cli check e21
playwright-cli check e35
playwright-cli screenshot
```
### Skills-less operation
Point your agent at the CLI and let it cook. It'll read the skill off `playwright-cli --help` on its own:
```
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
Check playwright-cli --help for available commands.
```
### Installing skills
Claude Code, GitHub copilot and others will let you install the Playwright skills into the agentic loop.
#### plugin (recommended)
```bash
/plugin marketplace add microsoft/playwright-cli
/plugin install playwright-cli
```
#### manual
```bash
mkdir -p .claude/skills/playwright-cli
curl -o .claude/skills/playwright-cli/SKILL.md \
https://raw.githubusercontent.com/microsoft/playwright-cli/main/skills/playwright-cli/SKILL.md
```
## Headed operation
Playwright CLI is headless by default. If you'd like to see the browser, pass `--headed` to `open`:
```bash
playwright-cli open https://playwright.dev --headed
```
## Sessions
Playwright CLI will use a dedicated persistent profile by default. It means that
your cookies and other storage state will be preserved between the calls. You can use different
instances of the browser for different projects with sessions.
Following will result in two browsers with separate profiles being available. Pass `--session` to
the invocation to talk to a specific browser.
```bash
playwright-cli open https://playwright.dev
playwright-cli --session=example open https://example.com
playwright-cli session-list
```
You can run your coding agent with the `PLAYWRIGHT_CLI_SESSION` environment variable:
```bash
PLAYWRIGHT_CLI_SESSION=todo-app claude .
```
Or instruct it to prepend `--session` to the calls.
Manage your sessions as follows:
```bash
playwright-cli session-list # list all sessions
playwright-cli session-stop [name] # stop session
playwright-cli session-stop-all # stop all sessions
playwright-cli session-delete [name] # delete session data along with the profiles
```
<!-- BEGIN GENERATED CLI HELP -->
## Commands
### Core
```bash
playwright-cli open <url> # open url
playwright-cli close # close the page
playwright-cli type <text> # type text into editable element
playwright-cli click <ref> [button] # perform click on a web page
playwright-cli dblclick <ref> [button] # perform double click on a web page
playwright-cli fill <ref> <text> # fill text into editable element
playwright-cli drag <startRef> <endRef> # perform drag and drop between two elements
playwright-cli hover <ref> # hover over element on page
playwright-cli select <ref> <val> # select an option in a dropdown
playwright-cli upload <file> # upload one or multiple files
playwright-cli check <ref> # check a checkbox or radio button
playwright-cli uncheck <ref> # uncheck a checkbox or radio button
playwright-cli snapshot # capture page snapshot to obtain element ref
playwright-cli eval <func> [ref] # evaluate javascript expression on page or element
playwright-cli dialog-accept [prompt] # accept a dialog
playwright-cli dialog-dismiss # dismiss a dialog
playwright-cli resize <w> <h> # resize the browser window
```
### Navigation
```bash
playwright-cli go-back # go back to the previous page
playwright-cli go-forward # go forward to the next page
playwright-cli reload # reload the current page
```
### Keyboard
```bash
playwright-cli press <key> # press a key on the keyboard, `a`, `arrowleft`
playwright-cli keydown <key> # press a key down on the keyboard
playwright-cli keyup <key> # press a key up on the keyboard
```
### Mouse
```bash
playwright-cli mousemove <x> <y> # move mouse to a given position
playwright-cli mousedown [button] # press mouse down
playwright-cli mouseup [button] # press mouse up
playwright-cli mousewheel <dx> <dy> # scroll mouse wheel
```
### Save as
```bash
playwright-cli screenshot [ref] # screenshot of the current page or element
playwright-cli pdf # save page as pdf
```
### Tabs
```bash
playwright-cli tab-list # list all tabs
playwright-cli tab-new [url] # create a new tab
playwright-cli tab-close [index] # close a browser tab
playwright-cli tab-select <index> # select a browser tab
```
### DevTools
```bash
playwright-cli console [min-level] # list console messages
playwright-cli network # list all network requests since loading the page
playwright-cli run-code <code> # run playwright code snippet
playwright-cli tracing-start # start trace recording
playwright-cli tracing-stop # stop trace recording
```
<!-- END GENERATED CLI HELP -->
## Configuration file
The Playwright CLI can be configured using a JSON configuration file. You can specify the configuration file using the `--config` command line option:
```bash
playwright-cli --config path/to/config.json open example.com
```
Playwright CLI will load config from `playwright-cli.json` by default so that you did not need to specify it every time.
<details>
<summary>Configuration file schema</summary>
```typescript
{
/**
* 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;
/**
* CDP headers to send with the connect request.
*/
cdpHeaders?: Record<string, string>;
/**
* Timeout in milliseconds for connecting to CDP endpoint. Defaults to 30000 (30 seconds). Pass 0 to disable timeout.
*/
cdpTimeout?: number;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
/**
* Paths to TypeScript files to add as initialization scripts for Playwright page.
*/
initPage?: string[];
/**
* Paths to JavaScript files to add as initialization scripts.
* The scripts will be evaluated in every page before any of the page's scripts.
*/
initScript?: string[];
},
/**
* If specified, saves the Playwright video of the session into the output directory.
*/
saveVideo?: {
width: number;
height: number;
};
/**
* The directory to save output files.
*/
outputDir?: string;
/**
* Whether to save snapshots, console messages, network logs and other session logs to a file or to the standard output. Defaults to "stdout".
*/
outputMode?: 'file' | 'stdout';
console?: {
/**
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
*/
level?: 'error' | 'warning' | 'info' | 'debug';
},
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[];
};
/**
* Specify the attribute to use for test ids, defaults to "data-testid".
*/
testIdAttribute?: string;
timeouts?: {
/*
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
*/
action?: number;
/*
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
*/
navigation?: number;
};
/**
* Whether to allow file uploads from anywhere on the file system.
* By default (false), file uploads are restricted to paths within the MCP roots only.
*/
allowUnrestrictedFileAccess?: boolean;
/**
* Specify the language to use for code generation.
*/
codegen?: 'typescript' | 'none';
}
```
</details>
## Environment
| Environment |
|-------------|
| `PLAYWRIGHT_MCP_ALLOWED_HOSTS` comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass '*' to disable the host check. |
| `PLAYWRIGHT_MCP_ALLOWED_ORIGINS` semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all. Important: *does not* serve as a security boundary and *does not* affect redirects. |
| `PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS` allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked. |
| `PLAYWRIGHT_MCP_BLOCKED_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. Important: *does not* serve as a security boundary and *does not* affect redirects. |
| `PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS` block service workers |
| `PLAYWRIGHT_MCP_BROWSER` browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge. |
| `PLAYWRIGHT_MCP_CAPS` comma-separated list of additional capabilities to enable, possible values: vision, pdf. |
| `PLAYWRIGHT_MCP_CDP_ENDPOINT` CDP endpoint to connect to. |
| `PLAYWRIGHT_MCP_CDP_HEADER` CDP headers to send with the connect request, multiple can be specified. |
| `PLAYWRIGHT_MCP_CODEGEN` specify the language to use for code generation, possible values: "typescript", "none". Default is "typescript". |
| `PLAYWRIGHT_MCP_CONFIG` path to the configuration file. |
| `PLAYWRIGHT_MCP_CONSOLE_LEVEL` level of console messages to return: "error", "warning", "info", "debug". Each level includes the messages of more severe levels. |
| `PLAYWRIGHT_MCP_DEVICE` device to emulate, for example: "iPhone 15" |
| `PLAYWRIGHT_MCP_EXECUTABLE_PATH` path to the browser executable. |
| `PLAYWRIGHT_MCP_EXTENSION` Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed. |
| `PLAYWRIGHT_MCP_GRANT_PERMISSIONS` List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write". |
| `PLAYWRIGHT_MCP_HEADLESS` run browser in headless mode, headed by default |
| `PLAYWRIGHT_MCP_HOST` host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. |
| `PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS` ignore https errors |
| `PLAYWRIGHT_MCP_INIT_PAGE` path to TypeScript file to evaluate on Playwright page object |
| `PLAYWRIGHT_MCP_INIT_SCRIPT` path to JavaScript file to add as an initialization script. The script will be evaluated in every page before any of the page's scripts. Can be specified multiple times. |
| `PLAYWRIGHT_MCP_ISOLATED` keep the browser profile in memory, do not save it to disk. |
| `PLAYWRIGHT_MCP_IMAGE_RESPONSES` whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow". |
| `PLAYWRIGHT_MCP_NO_SANDBOX` disable the sandbox for all process types that are normally sandboxed. |
| `PLAYWRIGHT_MCP_OUTPUT_DIR` path to the directory for output files. |
| `PLAYWRIGHT_MCP_OUTPUT_MODE` whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout". |
| `PLAYWRIGHT_MCP_PORT` port to listen on for SSE transport. |
| `PLAYWRIGHT_MCP_PROXY_BYPASS` comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com" |
| `PLAYWRIGHT_MCP_PROXY_SERVER` specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080" |
| `PLAYWRIGHT_MCP_SAVE_SESSION` Whether to save the Playwright MCP session into the output directory. |
| `PLAYWRIGHT_MCP_SAVE_TRACE` Whether to save the Playwright Trace of the session into the output directory. |
| `PLAYWRIGHT_MCP_SAVE_VIDEO` Whether to save the video of the session into the output directory. For example "--save-video=800x600" |
| `PLAYWRIGHT_MCP_SECRETS` path to a file containing secrets in the dotenv format |
| `PLAYWRIGHT_MCP_SHARED_BROWSER_CONTEXT` reuse the same browser context between all connected HTTP clients. |
| `PLAYWRIGHT_MCP_SNAPSHOT_MODE` when taking snapshots for responses, specifies the mode to use. Can be "incremental", "full", or "none". Default is incremental. |
| `PLAYWRIGHT_MCP_STORAGE_STATE` path to the storage state file for isolated sessions. |
| `PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE` specify the attribute to use for test ids, defaults to "data-testid" |
| `PLAYWRIGHT_MCP_TIMEOUT_ACTION` specify action timeout in milliseconds, defaults to 5000ms |
| `PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION` specify navigation timeout in milliseconds, defaults to 60000ms |
| `PLAYWRIGHT_MCP_USER_AGENT` specify user agent string |
| `PLAYWRIGHT_MCP_USER_DATA_DIR` path to the user data directory. If not specified, a temporary directory will be created. |
| `PLAYWRIGHT_MCP_VIEWPORT_SIZE` specify browser viewport size in pixels, for example "1280x720" |

View File

@@ -0,0 +1,30 @@
{
"name": "@playwright/cli",
"version": "0.0.61",
"description": "Playwright CLI",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-cli.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"lint": "echo OK",
"build": "echo OK",
"test": "echo OK"
},
"dependencies": {
"minimist": "^1.2.5",
"playwright": "1.59.0-alpha-1769452054000",
"playwright-core": "1.59.0-alpha-1769452054000"
},
"bin": {
"playwright-cli": "playwright-cli.js"
}
}

View File

@@ -15,4 +15,9 @@
* limitations under the License.
*/
import './lib/program.js';
const { program } = require('playwright/lib/mcp/terminal/program');
const packageJSON = require('./package.json');
program({ version: packageJSON.version }).catch(e => {
console.error(e.message);
process.exit(1);
});

2
packages/playwright-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
README.md
LICENSE

View File

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

12
src/utils/package.ts → packages/playwright-mcp/cli.js Normal file → Executable file
View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
@@ -14,9 +15,10 @@
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import url from 'url';
const { program } = require('playwright-core/lib/utilsBundle');
const { decorateCommand } = require('playwright/lib/mcp/program');
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', '..', 'package.json'), 'utf8'));
const packageJSON = require('./package.json');
const p = program.version('Version ' + packageJSON.version).name('Playwright MCP');
decorateCommand(p, packageJSON.version)
void program.parseAsync(process.argv);

View File

@@ -16,7 +16,17 @@
import type * as playwright from 'playwright';
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
export type ToolCapability =
'core' |
'core-input' |
'core-navigation' |
'core-tabs' |
'core-install' |
'core-input' |
'vision' |
'pdf' |
'testing' |
'tracing';
export type Config = {
/**
@@ -59,10 +69,31 @@ export type Config = {
*/
cdpEndpoint?: string;
/**
* CDP headers to send with the connect request.
*/
cdpHeaders?: Record<string, string>;
/**
* Timeout in milliseconds for connecting to CDP endpoint. Defaults to 30000 (30 seconds). Pass 0 to disable timeout.
*/
cdpTimeout?: number;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
/**
* Paths to TypeScript files to add as initialization scripts for Playwright page.
*/
initPage?: string[];
/**
* Paths to JavaScript files to add as initialization scripts.
* The scripts will be evaluated in every page before any of the page's scripts.
*/
initScript?: string[];
},
server?: {
@@ -75,6 +106,12 @@ export type Config = {
* The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
*/
host?: string;
/**
* The hosts this server is allowed to serve from. Defaults to the host server is bound to.
* This is not for CORS, but rather for the DNS rebinding protection.
*/
allowedHosts?: string[];
},
/**
@@ -95,11 +132,43 @@ export type Config = {
*/
saveTrace?: boolean;
/**
* If specified, saves the Playwright video of the session into the output directory.
*/
saveVideo?: {
width: number;
height: number;
};
/**
* Reuse the same browser context between all connected HTTP clients.
*/
sharedBrowserContext?: boolean;
/**
* Secrets are used to prevent LLM from getting sensitive data while
* automating scenarios such as authentication.
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
*/
secrets?: Record<string, string>;
/**
* The directory to save output files.
*/
outputDir?: string;
/**
* Whether to save snapshots, console messages, network logs and other session logs to a file or to the standard output. Defaults to "stdout".
*/
outputMode?: 'file' | 'stdout';
console?: {
/**
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
*/
level?: 'error' | 'warning' | 'info' | 'debug';
},
network?: {
/**
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
@@ -112,8 +181,43 @@ export type Config = {
blockedOrigins?: string[];
};
/**
* Specify the attribute to use for test ids, defaults to "data-testid".
*/
testIdAttribute?: string;
timeouts?: {
/*
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
*/
action?: number;
/*
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
*/
navigation?: number;
};
/**
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/
imageResponses?: 'allow' | 'omit';
snapshot?: {
/**
* When taking snapshots for responses, specifies the mode to use.
*/
mode?: 'incremental' | 'full' | 'none';
};
/**
* Whether to allow file uploads from anywhere on the file system.
* By default (false), file uploads are restricted to paths within the MCP roots only.
*/
allowUnrestrictedFileAccess?: boolean;
/**
* Specify the language to use for code generation.
*/
codegen?: 'typescript' | 'none';
};

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
{
"name": "@playwright/mcp",
"version": "0.0.61",
"description": "Playwright Tools for MCP",
"repository": {
"type": "git",
"url": "git+https://github.com/microsoft/playwright-mcp.git"
},
"homepage": "https://playwright.dev",
"engines": {
"node": ">=18"
},
"author": {
"name": "Microsoft Corporation"
},
"license": "Apache-2.0",
"scripts": {
"lint": "node update-readme.js",
"test": "playwright test",
"ctest": "playwright test --project=chrome",
"ftest": "playwright test --project=firefox",
"wtest": "playwright test --project=webkit",
"dtest": "MCP_IN_DOCKER=1 playwright test --project=chromium-docker",
"build": "echo OK",
"npm-publish": "npm run lint && npm run test && npm publish",
"copy-config": "cp ../../../playwright/packages/playwright/src/mcp/config.d.ts . && perl -pi -e \"s|import type \\* as playwright from 'playwright-core';|import type * as playwright from 'playwright';|\" ./config.d.ts",
"roll": "npm run copy-config && npm run lint"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"dependencies": {
"playwright": "1.59.0-alpha-1769452054000",
"playwright-core": "1.59.0-alpha-1769452054000"
},
"bin": {
"playwright-mcp": "cli.js"
}
}

View File

@@ -16,7 +16,7 @@
import { defineConfig } from '@playwright/test';
import type { TestOptions } from './tests/fixtures.js';
import type { TestOptions } from './tests/fixtures';
export default defineConfig<TestOptions>({
testDir: './tests',
@@ -26,7 +26,6 @@ export default defineConfig<TestOptions>({
reporter: 'list',
projects: [
{ name: 'chrome' },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{
name: 'chromium-docker',
grep: /browser_navigate|browser_click/,
@@ -35,8 +34,5 @@ export default defineConfig<TestOptions>({
mcpMode: 'docker' as const
}
}] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
],
});

View File

@@ -0,0 +1,3 @@
# Where is the source?
Playwright MCP source code is located in the [Playwright monorepo](https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp). Please refer to the contributor's guide in [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('test snapshot tool list', async ({ client }) => {
const { tools } = await client.listTools();
@@ -36,37 +36,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_network_requests',
'browser_press_key',
'browser_resize',
'browser_snapshot',
'browser_tabs',
'browser_take_screenshot',
'browser_wait_for',
]));
});
test('test tool list proxy mode', async ({ startClient }) => {
const { client } = await startClient({
args: ['--connect-tool'],
});
const { tools } = await client.listTools();
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
'browser_click',
'browser_connect', // the extra tool
'browser_console_messages',
'browser_drag',
'browser_evaluate',
'browser_file_upload',
'browser_fill_form',
'browser_handle_dialog',
'browser_hover',
'browser_select_option',
'browser_type',
'browser_close',
'browser_install',
'browser_navigate_back',
'browser_navigate',
'browser_network_requests',
'browser_press_key',
'browser_resize',
'browser_run_code',
'browser_snapshot',
'browser_tabs',
'browser_take_screenshot',

View File

@@ -14,34 +14,36 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('browser_network_requests', async ({ client, server }) => {
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<button onclick="fetch('/json')">Click me</button>
<title>Title</title>
<button>Submit</button>
<script>
const button = document.querySelector('button');
button.addEventListener('click', () => {
button.focus(); // without manual focus, webkit focuses body
});
</script>
`, 'text/html');
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
await client.callTool({
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
arguments: { url: server.PREFIX },
})).toHaveResponse({
code: `await page.goto('${server.PREFIX}');`,
snapshot: expect.stringContaining(`- button \"Submit\" [ref=e2]`),
});
await client.callTool({
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me button',
element: 'Submit button',
ref: 'e2',
},
});
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
})).toHaveResponse({
result: expect.stringContaining(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`),
code: `await page.getByRole('button', { name: 'Submit' }).click();`,
snapshot: expect.stringContaining(`button "Submit" [active] [ref=e2]`),
});
});

View File

@@ -14,13 +14,14 @@
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
test('browser_navigate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_install',
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toHaveResponse({
tabs: expect.stringContaining(`No open tabs`),
code: `await page.goto('${server.HELLO_WORLD}');`,
snapshot: expect.stringContaining(`generic [active] [ref=e1]: Hello, world!`),
});
});

View File

@@ -15,7 +15,6 @@
*/
import fs from 'fs';
import url from 'url';
import path from 'path';
import { chromium } from 'playwright';
@@ -23,7 +22,7 @@ import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from './testserver/index.ts';
import { TestServer } from './testserver/index';
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
@@ -47,6 +46,7 @@ export type StartClient = (options?: {
config?: Config,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
extensionToken?: string,
}) => Promise<{ client: Client, stderr: () => string }>;
@@ -103,7 +103,7 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
};
});
}
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'));
const { transport, stderr } = await createTransport(args, mcpMode, testInfo.outputPath('ms-playwright'), options?.extensionToken);
let stderrBuffer = '';
stderr?.on('data', data => {
if (process.env.PWMCP_DEBUG)
@@ -182,12 +182,10 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
});
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string): Promise<{
async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'], profilesDir: string, extensionToken?: string): Promise<{
transport: Transport,
stderr: Stream | null,
}> {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
const transport = new StdioClientTransport({
@@ -202,7 +200,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
args: [path.join(__dirname, '../cli.js'), ...args],
cwd: path.dirname(test.info().config.configFile!),
stderr: 'pipe',
env: {
@@ -211,6 +209,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
PWMCP_PROFILES_DIR_FOR_TEST: profilesDir,
...(extensionToken ? { PLAYWRIGHT_MCP_EXTENSION_TOKEN: extensionToken } : {}),
},
});
return {
@@ -230,7 +229,7 @@ export const expect = baseExpect.extend({
expect(parsed).not.toEqual(expect.objectContaining(object));
else
expect(parsed).toEqual(expect.objectContaining(object));
} catch (e) {
} catch (e: any) {
return {
pass: isNot,
message: () => e.message,
@@ -251,10 +250,12 @@ function parseResponse(response: any) {
const text = response.content[0].text;
const sections = parseSections(text);
const error = sections.get('Error');
const result = sections.get('Result');
const code = sections.get('Ran Playwright code');
const tabs = sections.get('Open tabs');
const pageState = sections.get('Page state');
const snapshot = sections.get('Snapshot');
const consoleMessages = sections.get('New console messages');
const modalState = sections.get('Modal state');
const downloads = sections.get('Downloads');
@@ -263,10 +264,12 @@ function parseResponse(response: any) {
const attachments = response.content.slice(1);
return {
error,
result,
code: codeNoFrame,
tabs,
pageState,
snapshot,
consoleMessages,
modalState,
downloads,

View File

@@ -15,7 +15,7 @@
*/
import child_process from 'child_process';
import fs from 'fs/promises';
import { test, expect } from './fixtures.js';
import { test, expect } from './fixtures';
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
const file = testInfo.outputPath('main.cjs');

View File

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

View File

@@ -16,30 +16,36 @@
*/
// @ts-check
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zodToJsonSchema from 'zod-to-json-schema'
import { execSync } from 'node:child_process';
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process');
import { allTools } from '../lib/tools.js';
const { browserTools } = require('playwright/lib/mcp/browser/tools');
const capabilities = {
'core-navigation': 'Core automation',
'core': 'Core automation',
'core-tabs': 'Tab management',
'core-input': 'Core automation',
'core-install': 'Browser installation',
'vision': 'Coordinate-based (opt-in via --caps=vision)',
'pdf': 'PDF generation (opt-in via --caps=pdf)',
'verify': 'Verify (opt-in via --caps=verify)',
'testing': 'Test assertions (opt-in via --caps=testing)',
'tracing': 'Tracing (opt-in via --caps=tracing)',
};
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
/** @type {Record<string, any[]>} */
const toolsByCapability = {};
for (const [capability, title] of Object.entries(capabilities)) {
let tools = browserTools.filter(tool => tool.capability === capability && !tool.skillOnly);
tools = (toolsByCapability[title] || []).concat(tools);
toolsByCapability[title] = tools;
}
for (const [, tools] of Object.entries(toolsByCapability))
tools.sort((a, b) => a.schema.name.localeCompare(b.schema.name));
/**
* @param {import('../src/tools/tool.js').ToolSchema<any>} tool
* @param {any} tool
* @returns {string[]}
*/
function formatToolForReadme(tool) {
@@ -50,7 +56,7 @@ function formatToolForReadme(tool) {
lines.push(` - Title: ${tool.title}`);
lines.push(` - Description: ${tool.description}`);
const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
const inputSchema = /** @type {any} */ (tool.inputSchema ? tool.inputSchema.toJSONSchema() : {});
const requiredParams = inputSchema.required || [];
if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
lines.push(` - Parameters:`);
@@ -122,29 +128,102 @@ async function updateTools(content) {
*/
async function updateOptions(content) {
console.log('Listing options...');
const output = execSync('node cli.js --help');
execSync('node cli.js --help > help.txt');
const output = fs.readFileSync('help.txt');
fs.unlinkSync('help.txt');
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);
/**
* @type {{ name: string, value: string }[]}
*/
const options = [];
for (let line of lines) {
if (line.startsWith(' --')) {
const l = line.substring(' --'.length);
const gapIndex = l.indexOf(' ');
const name = l.substring(0, gapIndex).trim();
const value = l.substring(gapIndex).trim();
options.push({ name, value });
} else {
const value = line.trim();
options[options.length - 1].value += ' ' + value;
}
}
const table = [];
table.push(`| Option | Description |`);
table.push(`|--------|-------------|`);
for (const option of options) {
const prefix = option.name.split(' ')[0];
const envName = `PLAYWRIGHT_MCP_` + prefix.toUpperCase().replace(/-/g, '_');
table.push(`| --${option.name} | ${option.value}<br>*env* \`${envName}\` |`);
}
if (process.env.PRINT_ENV) {
const envTable = [];
envTable.push(`| Environment |`);
envTable.push(`|-------------|`);
for (const option of options) {
const prefix = option.name.split(' ')[0];
const envName = `PLAYWRIGHT_MCP_` + prefix.toUpperCase().replace(/-/g, '_');
envTable.push(`| \`${envName}\` ${option.value} |`);
}
console.log(envTable.join('\n'));
}
const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of options generated section -->`;
return updateSection(content, startMarker, endMarker, table);
}
/**
* @param {string} content
* @returns {Promise<string>}
*/
async function updateConfig(content) {
console.log('Updating config schema from config.d.ts...');
const configPath = path.join(__dirname, 'config.d.ts');
const configContent = await fs.promises.readFile(configPath, 'utf-8');
// Extract the Config type definition
const configTypeMatch = configContent.match(/export type Config = (\{[\s\S]*?\n\});/);
if (!configTypeMatch)
throw new Error('Config type not found in config.d.ts');
const configType = configTypeMatch[1]; // Use capture group to get just the object definition
const startMarker = `<!--- Config generated by ${path.basename(__filename)} -->`;
const endMarker = `<!--- End of config generated section -->`;
return updateSection(content, startMarker, endMarker, [
'```',
'> npx @playwright/mcp@latest --help',
...lines,
'```typescript',
configType,
'```',
]);
}
/**
* @param {string} filePath
*/
async function copyToPackage(filePath) {
await fs.promises.copyFile(path.join(__dirname, '../../', filePath), path.join(__dirname, filePath));
console.log(`${filePath} copied successfully`);
}
async function updateReadme() {
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
const readmePath = path.join(__dirname, '../../README.md');
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
const withTools = await updateTools(readmeContent);
const withOptions = await updateOptions(withTools);
await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
const withConfig = await updateConfig(withOptions);
await fs.promises.writeFile(readmePath, withConfig, 'utf-8');
console.log('README updated successfully');
await copyToPackage('README.md');
await copyToPackage('LICENSE');
}
updateReadme().catch(err => {

View File

@@ -1,7 +0,0 @@
[*]
./tools/
./mcp/
./utils/
[program.ts]
***

172
src/actions.d.ts vendored
View File

@@ -1,172 +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.
*/
type Point = { x: number, y: number };
export type ActionName =
'check' |
'click' |
'closePage' |
'fill' |
'navigate' |
'openPage' |
'press' |
'select' |
'uncheck' |
'setInputFiles' |
'assertText' |
'assertValue' |
'assertChecked' |
'assertVisible' |
'assertSnapshot';
export type ActionBase = {
name: ActionName,
signals: Signal[],
ariaSnapshot?: string,
};
export type ActionWithSelector = ActionBase & {
selector: string,
ref?: string,
};
export type ClickAction = ActionWithSelector & {
name: 'click',
button: 'left' | 'middle' | 'right',
modifiers: number,
clickCount: number,
position?: Point,
};
export type CheckAction = ActionWithSelector & {
name: 'check',
};
export type UncheckAction = ActionWithSelector & {
name: 'uncheck',
};
export type FillAction = ActionWithSelector & {
name: 'fill',
text: string,
};
export type NavigateAction = ActionBase & {
name: 'navigate',
url: string,
};
export type OpenPageAction = ActionBase & {
name: 'openPage',
url: string,
};
export type ClosesPageAction = ActionBase & {
name: 'closePage',
};
export type PressAction = ActionWithSelector & {
name: 'press',
key: string,
modifiers: number,
};
export type SelectAction = ActionWithSelector & {
name: 'select',
options: string[],
};
export type SetInputFilesAction = ActionWithSelector & {
name: 'setInputFiles',
files: string[],
};
export type AssertTextAction = ActionWithSelector & {
name: 'assertText',
text: string,
substring: boolean,
};
export type AssertValueAction = ActionWithSelector & {
name: 'assertValue',
value: string,
};
export type AssertCheckedAction = ActionWithSelector & {
name: 'assertChecked',
checked: boolean,
};
export type AssertVisibleAction = ActionWithSelector & {
name: 'assertVisible',
};
export type AssertSnapshotAction = ActionWithSelector & {
name: 'assertSnapshot',
ariaSnapshot: string,
};
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
// Signals.
export type BaseSignal = {
};
export type NavigationSignal = BaseSignal & {
name: 'navigation',
url: string,
};
export type PopupSignal = BaseSignal & {
name: 'popup',
popupAlias: string,
};
export type DownloadSignal = BaseSignal & {
name: 'download',
downloadAlias: string,
};
export type DialogSignal = BaseSignal & {
name: 'dialog',
dialogAlias: string,
};
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
export type FrameDescription = {
pageGuid: string;
pageAlias: string;
framePath: string[];
};
export type ActionInContext = {
frame: FrameDescription;
description?: string;
action: Action;
startTime: number;
endTime?: number;
};
export type SignalInContext = {
frame: FrameDescription;
signal: Signal;
timestamp: number;
};

View File

@@ -1,249 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import net from 'net';
import path from 'path';
import * as playwright from 'playwright';
// @ts-ignore
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './utils/log.js';
import { createHash } from './utils/guid.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
export function contextFactory(config: FullConfig): BrowserContextFactory {
if (config.browser.remoteEndpoint)
return new RemoteContextFactory(config);
if (config.browser.cdpEndpoint)
return new CdpContextFactory(config);
if (config.browser.isolated)
return new IsolatedContextFactory(config);
return new PersistentContextFactory(config);
}
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
export interface BrowserContextFactory {
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
}
class BaseContextFactory implements BrowserContextFactory {
readonly config: FullConfig;
private _logName: string;
protected _browserPromise: Promise<playwright.Browser> | undefined;
constructor(name: string, config: FullConfig) {
this._logName = name;
this.config = config;
}
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
if (this._browserPromise)
return this._browserPromise;
testDebug(`obtain browser (${this._logName})`);
this._browserPromise = this._doObtainBrowser(clientInfo);
void this._browserPromise.then(browser => {
browser.on('disconnected', () => {
this._browserPromise = undefined;
});
}).catch(() => {
this._browserPromise = undefined;
});
return this._browserPromise;
}
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
throw new Error('Not implemented');
}
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this._logName})`);
const browser = await this._obtainBrowser(clientInfo);
const browserContext = await this._doCreateContext(browser);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
}
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
throw new Error('Not implemented');
}
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
testDebug(`close browser context (${this._logName})`);
if (browser.contexts().length === 1)
this._browserPromise = undefined;
await browserContext.close().catch(logUnhandledError);
if (browser.contexts().length === 0) {
testDebug(`close browser (${this._logName})`);
await browser.close().catch(logUnhandledError);
}
}
}
class IsolatedContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('isolated', config);
}
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
await injectCdpPort(this.config.browser);
const browserType = playwright[this.config.browser.browserName];
return browserType.launch({
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
...this.config.browser.launchOptions,
handleSIGINT: false,
handleSIGTERM: false,
}).catch(error => {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
});
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(this.config.browser.contextOptions);
}
}
class CdpContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('cdp', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
}
}
class RemoteContextFactory extends BaseContextFactory {
constructor(config: FullConfig) {
super('remote', config);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const url = new URL(this.config.browser.remoteEndpoint!);
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
return playwright[this.config.browser.browserName].connect(String(url));
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext();
}
}
class PersistentContextFactory implements BrowserContextFactory {
readonly config: FullConfig;
readonly name = 'persistent';
readonly description = 'Create a new persistent browser context';
private _userDataDirs = new Set<string>();
constructor(config: FullConfig) {
this.config = config;
}
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir);
const browserType = playwright[this.config.browser.browserName];
for (let i = 0; i < 5; i++) {
try {
const browserContext = await browserType.launchPersistentContext(userDataDir, {
tracesDir,
...this.config.browser.launchOptions,
...this.config.browser.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
const close = () => this._closeBrowserContext(browserContext, userDataDir);
return { browserContext, close };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
// User data directory is already in use, try again.
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
throw error;
}
}
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
}
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
testDebug('close browser context (persistent)');
testDebug('release user data dir', userDataDir);
await browserContext.close().catch(() => {});
this._userDataDirs.delete(userDataDir);
testDebug('close browser context complete (persistent)');
}
private async _createUserDataDir(rootPath: string | undefined) {
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
}
async function injectCdpPort(browserConfig: FullConfig['browser']) {
if (browserConfig.browserName === 'chromium')
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
}
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
if (!config.saveTrace)
return undefined;
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
return tracesDir;
}

View File

@@ -1,88 +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 { fileURLToPath } from 'url';
import { FullConfig } from './config.js';
import { Context } from './context.js';
import { logUnhandledError } from './utils/log.js';
import { Response } from './response.js';
import { SessionLog } from './sessionLog.js';
import { filteredTools } from './tools.js';
import { toMcpTool } from './mcp/tool.js';
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type * as mcpServer from './mcp/server.js';
import type { ServerBackend } from './mcp/server.js';
export class BrowserServerBackend implements ServerBackend {
private _tools: Tool[];
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
constructor(config: FullConfig, factory: BrowserContextFactory) {
this._config = config;
this._browserContextFactory = factory;
this._tools = filteredTools(config);
}
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
let rootPath: string | undefined;
if (roots.length > 0) {
const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined;
rootPath = url ? fileURLToPath(url) : undefined;
}
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
this._context = new Context({
tools: this._tools,
config: this._config,
browserContextFactory: this._browserContextFactory,
sessionLog: this._sessionLog,
clientInfo: { ...clientVersion, rootPath },
});
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
const tool = this._tools.find(tool => tool.schema.name === name)!;
if (!tool)
throw new Error(`Tool "${name}" not found`);
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
const context = this._context!;
const response = new Response(context, name, parsedArguments);
context.setRunningTool(name);
try {
await tool.handle(context, parsedArguments, response);
await response.finish();
this._sessionLog?.logResponse(response);
} catch (error: any) {
response.addError(String(error));
} finally {
context.setRunningTool(undefined);
}
return response.serialize();
}
serverClosed() {
void this._context?.dispose().catch(logUnhandledError);
}
}

View File

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

View File

@@ -1,276 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import * as playwright from 'playwright';
import { logUnhandledError } from './utils/log.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
import type { Tool } from './tools/tool.js';
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
import type * as actions from './actions.js';
import type { SessionLog } from './sessionLog.js';
const testDebug = debug('pw:mcp:test');
type ContextOptions = {
tools: Tool[];
config: FullConfig;
browserContextFactory: BrowserContextFactory;
sessionLog: SessionLog | undefined;
clientInfo: ClientInfo;
};
export class Context {
readonly tools: Tool[];
readonly config: FullConfig;
readonly sessionLog: SessionLog | undefined;
readonly options: ContextOptions;
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
private _clientInfo: ClientInfo;
private static _allContexts: Set<Context> = new Set();
private _closeBrowserContextPromise: Promise<void> | undefined;
private _runningToolName: string | undefined;
private _abortController = new AbortController();
constructor(options: ContextOptions) {
this.tools = options.tools;
this.config = options.config;
this.sessionLog = options.sessionLog;
this.options = options;
this._browserContextFactory = options.browserContextFactory;
this._clientInfo = options.clientInfo;
testDebug('create context');
Context._allContexts.add(this);
}
static async disposeAll() {
await Promise.all([...Context._allContexts].map(context => context.dispose()));
}
tabs(): Tab[] {
return this._tabs;
}
currentTab(): Tab | undefined {
return this._currentTab;
}
currentTabOrDie(): Tab {
if (!this._currentTab)
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
return this._currentTab;
}
async newTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
}
async selectTab(index: number) {
const tab = this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
return tab;
}
async ensureTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
if (!this._currentTab)
await browserContext.newPage();
return this._currentTab!;
}
async closeTab(index: number | undefined): Promise<string> {
const tab = index === undefined ? this._currentTab : this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
const url = tab.page.url();
await tab.page.close();
return url;
}
async outputFile(name: string): Promise<string> {
return outputFile(this.config, this._clientInfo.rootPath, name);
}
private _onPageCreated(page: playwright.Page) {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
if (!this._currentTab)
this._currentTab = tab;
}
private _onPageClosed(tab: Tab) {
const index = this._tabs.indexOf(tab);
if (index === -1)
return;
this._tabs.splice(index, 1);
if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
if (!this._tabs.length)
void this.closeBrowserContext();
}
async closeBrowserContext() {
if (!this._closeBrowserContextPromise)
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
await this._closeBrowserContextPromise;
this._closeBrowserContextPromise = undefined;
}
isRunningTool() {
return this._runningToolName !== undefined;
}
setRunningTool(name: string | undefined) {
this._runningToolName = name;
}
private async _closeBrowserContextImpl() {
if (!this._browserContextPromise)
return;
testDebug('close context');
const promise = this._browserContextPromise;
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, close }) => {
if (this.config.saveTrace)
await browserContext.tracing.stop();
await close();
});
}
async dispose() {
this._abortController.abort('MCP context disposed');
await this.closeBrowserContext();
Context._allContexts.delete(this);
}
private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));
for (const origin of this.config.network.allowedOrigins)
await context.route(`*://${origin}/**`, route => route.continue());
}
if (this.config.network?.blockedOrigins?.length) {
for (const origin of this.config.network.blockedOrigins)
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
}
}
private _ensureBrowserContext() {
if (!this._browserContextPromise) {
this._browserContextPromise = this._setupBrowserContext();
this._browserContextPromise.catch(() => {
this._browserContextPromise = undefined;
});
}
return this._browserContextPromise;
}
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
if (this._closeBrowserContextPromise)
throw new Error('Another browser context is being closed.');
// TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
if (this.sessionLog)
await InputRecorder.create(this, browserContext);
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
}
return result;
}
}
export class InputRecorder {
private _context: Context;
private _browserContext: playwright.BrowserContext;
private constructor(context: Context, browserContext: playwright.BrowserContext) {
this._context = context;
this._browserContext = browserContext;
}
static async create(context: Context, browserContext: playwright.BrowserContext) {
const recorder = new InputRecorder(context, browserContext);
await recorder._initialize();
return recorder;
}
private async _initialize() {
const sessionLog = this._context.sessionLog!;
await (this._browserContext as any)._enableRecorder({
mode: 'recording',
recorderMode: 'api',
}, {
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, false);
},
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
if (this._context.isRunningTool())
return;
const tab = Tab.forPage(page);
if (tab)
sessionLog.logUserAction(data.action, tab, code, true);
},
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
if (this._context.isRunningTool())
return;
if (data.signal.name !== 'navigation')
return;
const tab = Tab.forPage(page);
const navigateAction: actions.Action = {
name: 'navigate',
url: data.signal.url,
signals: [],
};
if (tab)
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
},
});
}
}

View File

@@ -1,3 +0,0 @@
[*]
../mcp/
../utils/

View File

@@ -1,420 +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.
*/
/**
* WebSocket server that bridges Playwright MCP and Chrome Extension
*
* Endpoints:
* - /cdp/guid - Full CDP interface for Playwright MCP
* - /extension/guid - Extension connection for chrome.debugger forwarding
*/
import { spawn } from 'child_process';
import http from 'http';
import debug from 'debug';
import { WebSocket, WebSocketServer } from 'ws';
import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../mcp/manualPromise.js';
import * as protocol from './protocol.js';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
import type { ExtensionCommand, ExtensionEvents } from './protocol.js';
// @ts-ignore
const { registry } = await import('playwright-core/lib/server/registry/index');
const debugLogger = debug('pw:mcp:relay');
type CDPCommand = {
id: number;
sessionId?: string;
method: string;
params?: any;
};
type CDPResponse = {
id?: number;
sessionId?: string;
method?: string;
params?: any;
result?: any;
error?: { code?: number; message: string };
};
export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
private _playwrightConnection: WebSocket | null = null;
private _extensionConnection: ExtensionConnection | null = null;
private _connectedTabInfo: {
targetInfo: any;
// Page sessionId that should be used by this connection.
sessionId: string;
} | undefined;
private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string, userDataDir?: string, executablePath?: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
this._extensionPath = `/extension/${uuid}`;
this._resetExtensionConnection();
this._wss = new WebSocketServer({ server });
this._wss.on('connection', this._onConnection.bind(this));
}
cdpEndpoint() {
return `${this._wsHost}${this._cdpPath}`;
}
extensionEndpoint() {
return `${this._wsHost}${this._extensionPath}`;
}
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined) {
debugLogger('Ensuring extension connection for MCP context');
if (this._extensionConnection)
return;
this._connectBrowser(clientInfo, toolName);
debugLogger('Waiting for incoming extension connection');
await Promise.race([
this._extensionConnectionPromise,
new Promise((_, reject) => setTimeout(() => {
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
]);
debugLogger('Extension connection established');
}
private _connectBrowser(clientInfo: ClientInfo, toolName: string | undefined) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
const client = {
name: clientInfo.name,
version: clientInfo.version,
};
url.searchParams.set('client', JSON.stringify(client));
url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
if (toolName)
url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
const href = url.toString();
let executablePath = this._executablePath;
if (!executablePath) {
const executableInfo = registry.findExecutable(this._browserChannel);
if (!executableInfo)
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
executablePath = executableInfo.executablePath();
if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
}
const args: string[] = [];
if (this._userDataDir)
args.push(`--user-data-dir=${this._userDataDir}`);
args.push(href);
spawn(executablePath, args, {
windowsHide: true,
detached: true,
shell: false,
stdio: 'ignore',
});
}
stop(): void {
this.closeConnections('Server stopped');
this._wss.close();
}
closeConnections(reason: string) {
this._closePlaywrightConnection(reason);
this._closeExtensionConnection(reason);
}
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
const url = new URL(`http://localhost${request.url}`);
debugLogger(`New connection to ${url.pathname}`);
if (url.pathname === this._cdpPath) {
this._handlePlaywrightConnection(ws);
} else if (url.pathname === this._extensionPath) {
this._handleExtensionConnection(ws);
} else {
debugLogger(`Invalid path: ${url.pathname}`);
ws.close(4004, 'Invalid path');
}
}
private _handlePlaywrightConnection(ws: WebSocket): void {
if (this._playwrightConnection) {
debugLogger('Rejecting second Playwright connection');
ws.close(1000, 'Another CDP client already connected');
return;
}
this._playwrightConnection = ws;
ws.on('message', async data => {
try {
const message = JSON.parse(data.toString());
await this._handlePlaywrightMessage(message);
} catch (error: any) {
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
}
});
ws.on('close', () => {
if (this._playwrightConnection !== ws)
return;
this._playwrightConnection = null;
this._closeExtensionConnection('Playwright client disconnected');
debugLogger('Playwright WebSocket closed');
});
ws.on('error', error => {
debugLogger('Playwright WebSocket error:', error);
});
debugLogger('Playwright MCP connected');
}
private _closeExtensionConnection(reason: string) {
this._extensionConnection?.close(reason);
this._extensionConnectionPromise.reject(new Error(reason));
this._resetExtensionConnection();
}
private _resetExtensionConnection() {
this._connectedTabInfo = undefined;
this._extensionConnection = null;
this._extensionConnectionPromise = new ManualPromise();
void this._extensionConnectionPromise.catch(logUnhandledError);
}
private _closePlaywrightConnection(reason: string) {
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
this._playwrightConnection.close(1000, reason);
this._playwrightConnection = null;
}
private _handleExtensionConnection(ws: WebSocket): void {
if (this._extensionConnection) {
ws.close(1000, 'Another extension connection already established');
return;
}
this._extensionConnection = new ExtensionConnection(ws);
this._extensionConnection.onclose = (c, reason) => {
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
if (this._extensionConnection !== c)
return;
this._resetExtensionConnection();
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
};
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
this._extensionConnectionPromise.resolve();
}
private _handleExtensionMessage<M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) {
switch (method) {
case 'forwardCDPEvent':
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
this._sendToPlaywright({
sessionId,
method: params.method,
params: params.params
});
break;
}
}
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
const { id, sessionId, method, params } = message;
try {
const result = await this._handleCDPCommand(method, params, sessionId);
this._sendToPlaywright({ id, sessionId, result });
} catch (e) {
debugLogger('Error in the extension:', e);
this._sendToPlaywright({
id,
sessionId,
error: { message: (e as Error).message }
});
}
}
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
switch (method) {
case 'Browser.getVersion': {
return {
protocolVersion: '1.3',
product: 'Chrome/Extension-Bridge',
userAgent: 'CDP-Bridge-Server/1.0.0',
};
}
case 'Browser.setDownloadBehavior': {
return { };
}
case 'Target.setAutoAttach': {
// Forward child session handling.
if (sessionId)
break;
// Simulate auto-attach behavior with real target info
const { targetInfo } = await this._extensionConnection!.send('attachToTab', { });
this._connectedTabInfo = {
targetInfo,
sessionId: `pw-tab-${this._nextSessionId++}`,
};
debugLogger('Simulating auto-attach');
this._sendToPlaywright({
method: 'Target.attachedToTarget',
params: {
sessionId: this._connectedTabInfo.sessionId,
targetInfo: {
...this._connectedTabInfo.targetInfo,
attached: true,
},
waitingForDebugger: false
}
});
return { };
}
case 'Target.getTargetInfo': {
return this._connectedTabInfo?.targetInfo;
}
}
return await this._forwardToExtension(method, params, sessionId);
}
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
// Top level sessionId is only passed between the relay and the client.
if (this._connectedTabInfo?.sessionId === sessionId)
sessionId = undefined;
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
}
private _sendToPlaywright(message: CDPResponse): void {
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
this._playwrightConnection?.send(JSON.stringify(message));
}
}
type ExtensionResponse = {
id?: number;
method?: string;
params?: any;
result?: any;
error?: string;
};
class ExtensionConnection {
private readonly _ws: WebSocket;
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
private _lastId = 0;
onmessage?: <M extends keyof ExtensionEvents>(method: M, params: ExtensionEvents[M]['params']) => void;
onclose?: (self: ExtensionConnection, reason: string) => void;
constructor(ws: WebSocket) {
this._ws = ws;
this._ws.on('message', this._onMessage.bind(this));
this._ws.on('close', this._onClose.bind(this));
this._ws.on('error', this._onError.bind(this));
}
async send<M extends keyof ExtensionCommand>(method: M, params: ExtensionCommand[M]['params']): Promise<any> {
if (this._ws.readyState !== WebSocket.OPEN)
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
const id = ++this._lastId;
this._ws.send(JSON.stringify({ id, method, params }));
const error = new Error(`Protocol error: ${method}`);
return new Promise((resolve, reject) => {
this._callbacks.set(id, { resolve, reject, error });
});
}
close(message: string) {
debugLogger('closing extension connection:', message);
if (this._ws.readyState === WebSocket.OPEN)
this._ws.close(1000, message);
}
private _onMessage(event: websocket.RawData) {
const eventData = event.toString();
let parsedJson;
try {
parsedJson = JSON.parse(eventData);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
this._ws.close();
return;
}
try {
this._handleParsedMessage(parsedJson);
} catch (e: any) {
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
this._ws.close();
}
}
private _handleParsedMessage(object: ExtensionResponse) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
if (object.error) {
const error = callback.error;
error.message = object.error;
callback.reject(error);
} else {
callback.resolve(object.result);
}
} else if (object.id) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method! as keyof ExtensionEvents, object.params);
}
}
private _onClose(event: websocket.CloseEvent) {
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
this._dispose();
this.onclose?.(this, event.reason);
}
private _onError(event: websocket.ErrorEvent) {
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
this._dispose();
}
private _dispose() {
for (const callback of this._callbacks.values())
callback.reject(new Error('WebSocket closed'));
this._callbacks.clear();
}
}

View File

@@ -1,65 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../mcp/http.js';
import { CDPRelayServer } from './cdpRelay.js';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
const debugLogger = debug('pw:mcp:relay');
export class ExtensionContextFactory implements BrowserContextFactory {
private _browserChannel: string;
private _userDataDir?: string;
private _executablePath?: string;
constructor(browserChannel: string, userDataDir: string | undefined, executablePath: string | undefined) {
this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
this._executablePath = executablePath;
}
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
return {
browserContext: browser.contexts()[0],
close: async () => {
debugLogger('close() called for browser context');
await browser.close();
}
};
}
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal);
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
}
private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({});
if (abortSignal.aborted) {
httpServer.close();
throw new Error(abortSignal.reason);
}
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
return cdpRelayServer;
}
}

View File

@@ -1,42 +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.
*/
// Whenever the commands/events change, the version must be updated. The latest
// extension version should be compatible with the old MCP clients.
export const VERSION = 1;
export type ExtensionCommand = {
'attachToTab': {
params: {};
};
'forwardCDPCommand': {
params: {
method: string,
sessionId?: string
params?: any,
};
};
};
export type ExtensionEvents = {
'forwardCDPEvent': {
params: {
method: string,
sessionId?: string
params?: any,
};
};
};

View File

@@ -1,51 +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 { BrowserServerBackend } from './browserServerBackend.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import * as mcpServer from './mcp/server.js';
import { packageJSON } from './utils/package.js';
import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {
name = 'custom';
description = 'Connect to a browser using a custom context getter';
private readonly _contextGetter: () => Promise<BrowserContext>;
constructor(contextGetter: () => Promise<BrowserContext>) {
this._contextGetter = contextGetter;
}
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
const browserContext = await this._contextGetter();
return {
browserContext,
close: () => browserContext.close()
};
}
}

View File

@@ -1,108 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
export type LLMToolCall = {
name: string;
arguments: any;
id: string;
};
export type LLMTool = {
name: string;
description: string;
inputSchema: any;
};
export type LLMMessage =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
export type LLMConversation = {
messages: LLMMessage[];
tools: LLMTool[];
};
export interface LLMDelegate {
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
checkDoneToolCall(toolCall: LLMToolCall): string | null;
}
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
const { tools } = await client.listTools();
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
const conversation = delegate.createConversation(taskContent, tools, oneShot);
for (let iteration = 0; iteration < 5; ++iteration) {
debug('history')('Making API call for iteration', iteration);
const toolCalls = await delegate.makeApiCall(conversation);
if (toolCalls.length === 0)
throw new Error('Call the "done" tool when the task is complete.');
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
for (const toolCall of toolCalls) {
const doneResult = delegate.checkDoneToolCall(toolCall);
if (doneResult !== null)
return conversation.messages;
const { name, arguments: args, id } = toolCall;
try {
debug('tool')(name, args);
const response = await client.callTool({
name,
arguments: args,
});
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
debug('tool')(responseContent);
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
toolResults.push({
toolCallId: id,
content: text,
});
} catch (error) {
debug('tool')(error);
toolResults.push({
toolCallId: id,
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
isError: true,
});
// Skip remaining tool calls for this iteration
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
toolResults.push({
toolCallId: remainingToolCall.id,
content: `This tool call is skipped due to previous error.`,
isError: true,
});
}
break;
}
}
delegate.addToolResults(conversation, toolResults);
if (oneShot)
return conversation.messages;
}
throw new Error('Failed to perform step, max attempts reached');
}

View File

@@ -1,177 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type Anthropic from '@anthropic-ai/sdk';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'claude-sonnet-4-20250514';
export class ClaudeDelegate implements LLMDelegate {
private _anthropic: Anthropic | undefined;
async anthropic(): Promise<Anthropic> {
if (!this._anthropic) {
const anthropic = await import('@anthropic-ai/sdk');
this._anthropic = new anthropic.Anthropic();
}
return this._anthropic;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const llmTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
llmTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: llmTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to Claude format
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
claudeMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const content: Anthropic.Messages.ContentBlock[] = [];
// Add text content
if (message.content) {
content.push({
type: 'text',
text: message.content,
citations: []
});
}
// Add tool calls
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments
});
}
}
claudeMessages.push({
role: 'assistant',
content
});
} else if (message.role === 'tool') {
// Tool results are added differently - we need to find if there's already a user message with tool results
const lastMessage = claudeMessages[claudeMessages.length - 1];
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: message.toolCallId,
content: message.content,
is_error: message.isError,
};
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
// Add to existing tool results message
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
} else {
// Create new tool results message
claudeMessages.push({
role: 'user',
content: [toolResult]
});
}
}
}
// Convert generic tools to Claude format
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
}));
const anthropic = await this.anthropic();
const response = await anthropic.messages.create({
model,
max_tokens: 10000,
messages: claudeMessages,
tools: claudeTools,
});
// Extract tool calls and add assistant message to generic conversation
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
name: toolCall.name,
arguments: toolCall.input as any,
id: toolCall.id,
}));
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: textContent,
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
});
return llmToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return (toolCall.arguments as { result: string }).result;
return null;
}
}

View File

@@ -1,168 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type OpenAI from 'openai';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'gpt-4.1';
export class OpenAIDelegate implements LLMDelegate {
private _openai: OpenAI | undefined;
async openai(): Promise<OpenAI> {
if (!this._openai) {
const oai = await import('openai');
this._openai = new oai.OpenAI();
}
return this._openai;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const genericTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
genericTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: genericTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to OpenAI format
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
openaiMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
toolCalls.push({
id: toolCall.id,
type: 'function',
function: {
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments)
}
});
}
}
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
role: 'assistant'
};
if (message.content)
assistantMessage.content = message.content;
if (toolCalls.length > 0)
assistantMessage.tool_calls = toolCalls;
openaiMessages.push(assistantMessage);
} else if (message.role === 'tool') {
openaiMessages.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: message.content,
});
}
}
// Convert generic tools to OpenAI format
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}));
const openai = await this.openai();
const response = await openai.chat.completions.create({
model,
messages: openaiMessages,
tools: openaiTools,
tool_choice: 'auto'
});
const message = response.choices[0].message;
// Extract tool calls and add assistant message to generic conversation
const toolCalls = message.tool_calls || [];
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
const functionCall = toolCall.function;
return {
name: functionCall.name,
arguments: JSON.parse(functionCall.arguments),
id: toolCall.id,
};
});
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: message.content || '',
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
});
return genericToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return toolCall.arguments.result;
return null;
}
}

View File

@@ -1,72 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-console */
import path from 'path';
import url from 'url';
import dotenv from 'dotenv';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { program } from 'commander';
import { OpenAIDelegate } from './loopOpenAI.js';
import { ClaudeDelegate } from './loopClaude.js';
import { runTask } from './loop.js';
import type { LLMDelegate } from './loop.js';
dotenv.config();
const __filename = url.fileURLToPath(import.meta.url);
async function run(delegate: LLMDelegate) {
const transport = new StdioClientTransport({
command: 'node',
args: [
path.resolve(__filename, '../../../cli.js'),
'--save-session',
'--output-dir', path.resolve(__filename, '../../../sessions')
],
stderr: 'inherit',
env: process.env as Record<string, string>,
});
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
for (const task of tasks) {
const messages = await runTask(delegate, client, task);
for (const message of messages)
console.log(`${message.role}: ${message.content}`);
}
await client.close();
}
const tasks = [
'Open https://playwright.dev/',
];
program
.option('--model <model>', 'model to use')
.action(async options => {
if (options.model === 'claude')
await run(new ClaudeDelegate());
else
await run(new OpenAIDelegate());
});
void program.parseAsync(process.argv);

View File

@@ -1,5 +0,0 @@
[*]
../
../loop/
../mcp/
../utils/

View File

@@ -1,78 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { contextFactory } from '../browserContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { Context as BrowserContext } from '../context.js';
import { runTask } from '../loop/loop.js';
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js';
export class Context {
readonly config: FullConfig;
private _client: Client;
private _delegate: LLMDelegate;
constructor(config: FullConfig, client: Client) {
this.config = config;
this._client = client;
if (process.env.OPENAI_API_KEY)
this._delegate = new OpenAIDelegate();
else if (process.env.ANTHROPIC_API_KEY)
this._delegate = new ClaudeDelegate();
else
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
}
static async create(config: FullConfig) {
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];
// Skip the first message, which is the user's task.
for (const message of messages.slice(1)) {
// Trim out all page snapshots.
if (!message.content.trim())
continue;
const index = oneShot ? -1 : message.content.indexOf('### Page state');
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
lines.push(`[${message.role}]:`, trimmedContent);
}
return {
content: [{ type: 'text', text: lines.join('\n') }],
};
}
async close() {
await BrowserContext.disposeAll();
}
}

View File

@@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import { Context } from './context.js';
import { perform } from './perform.js';
import { snapshot } from './snapshot.js';
import { toMcpTool } from '../mcp/tool.js';
import type { FullConfig } from '../config.js';
import type { ServerBackend } from '../mcp/server.js';
import type { Tool } from './tool.js';
export async function runLoopTools(config: FullConfig) {
dotenv.config();
const serverBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-loop',
version: packageJSON.version,
create: () => new LoopToolsServerBackend(config)
};
await mcpServer.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
private _config: FullConfig;
private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot];
constructor(config: FullConfig) {
this._config = config;
}
async initialize() {
this._context = await Context.create(this._config);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
const tool = this._tools.find(tool => tool.schema.name === name)!;
const parsedArguments = tool.schema.inputSchema.parse(args || {});
return await tool.handle(this._context!, parsedArguments);
}
serverClosed() {
void this._context!.close();
}
}

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
const performSchema = z.object({
task: z.string().describe('The task to perform with the browser'),
});
export const perform = defineTool({
schema: {
name: 'browser_perform',
title: 'Perform a task with the browser',
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
inputSchema: performSchema,
type: 'destructive',
},
handle: async (context, params) => {
return await context.runTask(params.task);
},
});

View File

@@ -1,32 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { defineTool } from './tool.js';
export const snapshot = defineTool({
schema: {
name: 'browser_snapshot',
title: 'Take a snapshot of the browser',
description: 'Take a snapshot of the browser to read what is on the page.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params) => {
return await context.runTask('Capture browser snapshot', true);
},
});

View File

@@ -1,30 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { z } from 'zod';
import type * as mcpServer from '../mcp/server.js';
import type { Context } from './context.js';
import type { ToolSchema } from '../mcp/tool.js';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -1 +0,0 @@
[*]

View File

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

View File

@@ -1,155 +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 assert from 'assert';
import net from 'net';
import http from 'http';
import crypto from 'crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import * as mcpServer from './server.js';
import type { ServerBackendFactory } from './server.js';
const testDebug = debug('pw:mcp:test');
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
const { host, port } = config;
const httpServer = http.createServer();
decorateServer(httpServer);
await new Promise<void>((resolve, reject) => {
httpServer.on('error', reject);
abortSignal?.addEventListener('abort', () => {
httpServer.close();
reject(new Error('Aborted'));
});
httpServer.listen(port, host, () => {
resolve();
httpServer.removeListener('error', reject);
});
});
return httpServer;
}
export function httpAddressToString(address: string | net.AddressInfo | null): string {
assert(address, 'Could not bind server socket');
if (typeof address === 'string')
return address;
const resolvedPort = address.port;
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
resolvedHost = 'localhost';
return `http://${resolvedHost}:${resolvedPort}`;
}
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
const sseSessions = new Map();
const streamableSessions = new Map();
httpServer.on('request', async (req, res) => {
const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/sse'))
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
else
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
});
}
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
return res.end('Missing sessionId');
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
return res.end('Session not found');
}
return await transport.handlePostMessage(req, res);
} else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
testDebug(`create SSE session: ${transport.sessionId}`);
await mcpServer.connect(serverBackendFactory, transport, false);
res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId);
});
return;
}
res.statusCode = 405;
res.end('Method not allowed');
}
async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
res.end('Session not found');
return;
}
return await transport.handleRequest(req, res);
}
if (req.method === 'POST') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: async sessionId => {
testDebug(`create http session: ${transport.sessionId}`);
await mcpServer.connect(serverBackendFactory, transport, true);
sessions.set(sessionId, transport);
}
});
transport.onclose = () => {
if (!transport.sessionId)
return;
sessions.delete(transport.sessionId);
testDebug(`delete http session: ${transport.sessionId}`);
};
await transport.handleRequest(req, res);
return;
}
res.statusCode = 400;
res.end('Invalid request');
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

@@ -1,92 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
export class InProcessTransport implements Transport {
private _server: Server;
private _serverTransport: InProcessServerTransport;
private _connected: boolean = false;
constructor(server: Server) {
this._server = server;
this._serverTransport = new InProcessServerTransport(this);
}
async start(): Promise<void> {
if (this._connected)
throw new Error('InprocessTransport already started!');
await this._server.connect(this._serverTransport);
this._connected = true;
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (!this._connected)
throw new Error('Transport not connected');
this._serverTransport._receiveFromClient(message);
}
async close(): Promise<void> {
if (this._connected) {
this._connected = false;
this.onclose?.();
this._serverTransport.onclose?.();
}
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
this.onmessage?.(message, extra);
}
}
class InProcessServerTransport implements Transport {
private _clientTransport: InProcessTransport;
constructor(clientTransport: InProcessTransport) {
this._clientTransport = clientTransport;
}
async start(): Promise<void> {
}
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
this._clientTransport._receiveFromServer(message);
}
async close(): Promise<void> {
this.onclose?.();
}
onclose?: (() => void) | undefined;
onerror?: ((error: Error) => void) | undefined;
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
sessionId?: string | undefined;
setProtocolVersion?: ((version: string) => void) | undefined;
_receiveFromClient(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}

View File

@@ -1,127 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export class ManualPromise<T = void> extends Promise<T> {
private _resolve!: (t: T) => void;
private _reject!: (e: Error) => void;
private _isDone: boolean;
constructor() {
let resolve: (t: T) => void;
let reject: (e: Error) => void;
super((f, r) => {
resolve = f;
reject = r;
});
this._isDone = false;
this._resolve = resolve!;
this._reject = reject!;
}
isDone() {
return this._isDone;
}
resolve(t: T) {
this._isDone = true;
this._resolve(t);
}
reject(e: Error) {
this._isDone = true;
this._reject(e);
}
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'ManualPromise';
}
}
export class LongStandingScope {
private _terminateError: Error | undefined;
private _closeError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
reject(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())
p.resolve(error);
}
close(error: Error) {
this._isClosed = true;
this._closeError = error;
for (const [p, frames] of this._terminatePromises)
p.resolve(cloneError(error, frames));
}
isClosed() {
return this._isClosed;
}
static async raceMultiple<T>(scopes: LongStandingScope[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {
return this._race([promise], true, defaultValue);
}
private async _race(promises: Promise<any>[], safe: boolean, defaultValue?: any): Promise<any> {
const terminatePromise = new ManualPromise<Error>();
const frames = captureRawStack();
if (this._terminateError)
terminatePromise.resolve(this._terminateError);
if (this._closeError)
terminatePromise.resolve(cloneError(this._closeError, frames));
this._terminatePromises.set(terminatePromise, frames);
try {
return await Promise.race([
terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)),
...promises
]);
} finally {
this._terminatePromises.delete(terminatePromise);
}
}
}
function cloneError(error: Error, frames: string[]) {
const clone = new Error();
clone.name = error.name;
clone.message = error.message;
clone.stack = [error.name + ':' + error.message, ...frames].join('\n');
return clone;
}
function captureRawStack(): string[] {
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 50;
const error = new Error();
const stack = error.stack || '';
Error.stackTraceLimit = stackTraceLimit;
return stack.split('\n');
}

View File

@@ -1,239 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import { z } from 'zod';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { defineToolSchema } from './tool.js';
import * as mcpServer from './server.js';
import * as mcpHttp from './http.js';
import { wrapInProcess } from './server.js';
import { ManualPromise } from './manualPromise.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
const mdbDebug = debug('pw:mcp:mdb');
const errorsDebug = debug('pw:mcp:errors');
export class MDBBackend implements mcpServer.ServerBackend {
private _stack: { client: Client, toolNames: string[], resultPromise: ManualPromise<mcpServer.CallToolResult> | undefined }[] = [];
private _interruptPromise: ManualPromise<mcpServer.CallToolResult> | undefined;
private _topLevelBackend: mcpServer.ServerBackend;
private _initialized = false;
constructor(topLevelBackend: mcpServer.ServerBackend) {
this._topLevelBackend = topLevelBackend;
}
async initialize(server: mcpServer.Server): Promise<void> {
if (this._initialized)
return;
this._initialized = true;
const transport = await wrapInProcess(this._topLevelBackend);
await this._pushClient(transport);
}
async listTools(): Promise<mcpServer.Tool[]> {
const response = await this._client().listTools();
return response.tools;
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
if (name === pushToolsSchema.name)
return await this._pushTools(pushToolsSchema.inputSchema.parse(args || {}));
const interruptPromise = new ManualPromise<mcpServer.CallToolResult>();
this._interruptPromise = interruptPromise;
let [entry] = this._stack;
// Pop the client while the tool is not found.
while (entry && !entry.toolNames.includes(name)) {
mdbDebug('popping client from stack for ', name);
this._stack.shift();
await entry.client.close();
entry = this._stack[0];
}
if (!entry)
throw new Error(`Tool ${name} not found in the tool stack`);
const resultPromise = new ManualPromise<mcpServer.CallToolResult>();
entry.resultPromise = resultPromise;
this._client().callTool({
name,
arguments: args,
}).then(result => {
resultPromise.resolve(result as mcpServer.CallToolResult);
}).catch(e => {
mdbDebug('error in client call', e);
if (this._stack.length < 2)
throw e;
this._stack.shift();
const prevEntry = this._stack[0];
void prevEntry.resultPromise!.then(result => resultPromise.resolve(result));
});
const result = await Promise.race([interruptPromise, resultPromise]);
if (interruptPromise.isDone())
mdbDebug('client call intercepted', result);
else
mdbDebug('client call result', result);
return result;
}
private _client(): Client {
const [entry] = this._stack;
if (!entry)
throw new Error('No debugging backend available');
return entry.client;
}
private async _pushTools(params: { mcpUrl: string, introMessage?: string }): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing tools to the stack', params.mcpUrl);
const transport = new StreamableHTTPClientTransport(new URL(params.mcpUrl));
await this._pushClient(transport, params.introMessage);
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
private async _pushClient(transport: Transport, introMessage?: string): Promise<mcpServer.CallToolResult> {
mdbDebug('pushing client to the stack');
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
await client.connect(transport);
mdbDebug('connected to the new client');
const { tools } = await client.listTools();
this._stack.unshift({ client, toolNames: tools.map(tool => tool.name), resultPromise: undefined });
mdbDebug('new tools added to the stack:', tools.map(tool => tool.name));
mdbDebug('interrupting current call:', !!this._interruptPromise);
this._interruptPromise?.resolve({
content: [{
type: 'text',
text: introMessage || '',
}],
});
this._interruptPromise = undefined;
return { content: [{ type: 'text', text: 'Tools pushed' }] };
}
}
const pushToolsSchema = defineToolSchema({
name: 'mdb_push_tools',
title: 'Push MCP tools to the tools stack',
description: 'Push MCP tools to the tools stack',
inputSchema: z.object({
mcpUrl: z.string(),
introMessage: z.string().optional(),
}),
type: 'readOnly',
});
export type ServerBackendOnPause = mcpServer.ServerBackend & {
requestSelfDestruct?: () => void;
};
export async function runMainBackend(backendFactory: mcpServer.ServerBackendFactory, options?: { port?: number }): Promise<string | undefined> {
const mdbBackend = new MDBBackend(backendFactory.create());
// Start HTTP unconditionally.
const factory: mcpServer.ServerBackendFactory = {
...backendFactory,
create: () => mdbBackend
};
const url = await startAsHttp(factory, { port: options?.port || 0 });
process.env.PLAYWRIGHT_DEBUGGER_MCP = url;
if (options?.port !== undefined)
return url;
// Start stdio conditionally.
await mcpServer.connect(factory, new StdioServerTransport(), false);
}
export async function runOnPauseBackendLoop(mdbUrl: string, backend: ServerBackendOnPause, introMessage: string) {
const wrappedBackend = new OnceTimeServerBackendWrapper(backend);
const factory = {
name: 'on-pause-backend',
nameInConfig: 'on-pause-backend',
version: '0.0.0',
create: () => wrappedBackend,
};
const httpServer = await mcpHttp.startHttpServer({ port: 0 });
await mcpHttp.installHttpTransport(httpServer, factory);
const url = mcpHttp.httpAddressToString(httpServer.address());
const client = new Client({ name: 'Internal client', version: '0.0.0' });
client.setRequestHandler(PingRequestSchema, () => ({}));
const transport = new StreamableHTTPClientTransport(new URL(mdbUrl));
await client.connect(transport);
const pushToolsResult = await client.callTool({
name: pushToolsSchema.name,
arguments: {
mcpUrl: url,
introMessage,
},
});
if (pushToolsResult.isError)
errorsDebug('Failed to push tools', pushToolsResult.content);
await transport.terminateSession();
await client.close();
await wrappedBackend.waitForClosed();
httpServer.close();
}
async function startAsHttp(backendFactory: mcpServer.ServerBackendFactory, options: { port: number }) {
const httpServer = await mcpHttp.startHttpServer(options);
await mcpHttp.installHttpTransport(httpServer, backendFactory);
return mcpHttp.httpAddressToString(httpServer.address());
}
class OnceTimeServerBackendWrapper implements mcpServer.ServerBackend {
private _backend: ServerBackendOnPause;
private _selfDestructPromise = new ManualPromise<void>();
constructor(backend: ServerBackendOnPause) {
this._backend = backend;
this._backend.requestSelfDestruct = () => this._selfDestructPromise.resolve();
}
async initialize(server: mcpServer.Server, clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
await this._backend.initialize?.(server, clientVersion, roots);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._backend.listTools();
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
return this._backend.callTool(name, args);
}
serverClosed(server: mcpServer.Server) {
this._backend.serverClosed?.(server);
this._selfDestructPromise.resolve();
}
async waitForClosed() {
await this._selfDestructPromise;
}
}

View File

@@ -1,128 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import type { ServerBackend, ClientVersion, Root, Server } from './server.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
export type MCPProvider = {
name: string;
description: string;
connect(): Promise<Transport>;
};
const errorsDebug = debug('pw:mcp:errors');
export class ProxyBackend implements ServerBackend {
private _mcpProviders: MCPProvider[];
private _currentClient: Client | undefined;
private _contextSwitchTool: Tool;
private _roots: Root[] = [];
constructor(mcpProviders: MCPProvider[]) {
this._mcpProviders = mcpProviders;
this._contextSwitchTool = this._defineContextSwitchTool();
}
async initialize(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void> {
this._roots = roots;
await this._setCurrentClient(this._mcpProviders[0]);
}
async listTools(): Promise<Tool[]> {
const response = await this._currentClient!.listTools();
if (this._mcpProviders.length === 1)
return response.tools;
return [
...response.tools,
this._contextSwitchTool,
];
}
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
if (name === this._contextSwitchTool.name)
return this._callContextSwitchTool(args);
return await this._currentClient!.callTool({
name,
arguments: args,
}) as CallToolResult;
}
serverClosed?(): void {
void this._currentClient?.close().catch(errorsDebug);
}
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
try {
const factory = this._mcpProviders.find(factory => factory.name === params.name);
if (!factory)
throw new Error('Unknown connection method: ' + params.name);
await this._setCurrentClient(factory);
return {
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
};
} catch (error) {
return {
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
isError: true,
};
}
}
private _defineContextSwitchTool(): Tool {
return {
name: 'browser_connect',
description: [
'Connect to a browser using one of the available methods:',
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
].join('\n'),
inputSchema: zodToJsonSchema(z.object({
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
}), { strictUnions: true }) as Tool['inputSchema'],
annotations: {
title: 'Connect to a browser context',
readOnlyHint: true,
openWorldHint: false,
},
};
}
private async _setCurrentClient(factory: MCPProvider) {
await this._currentClient?.close();
this._currentClient = undefined;
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
client.registerCapabilities({
roots: {
listRoots: true,
},
});
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
client.setRequestHandler(PingRequestSchema, () => ({}));
const transport = await factory.connect();
await client.connect(transport);
this._currentClient = client;
}
}

View File

@@ -1,158 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
import { InProcessTransport } from './inProcessTransport.js';
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
const serverDebug = debug('pw:mcp:server');
const errorsDebug = debug('pw:mcp:errors');
export type ClientVersion = { name: string, version: string };
export interface ServerBackend {
initialize?(server: Server, clientVersion: ClientVersion, roots: Root[]): Promise<void>;
listTools(): Promise<Tool[]>;
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
serverClosed?(server: Server): void;
}
export type ServerBackendFactory = {
name: string;
nameInConfig: string;
version: string;
create: () => ServerBackend;
};
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
await server.connect(transport);
}
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
const server = createServer('Internal', '0.0.0', backend, false);
return new InProcessTransport(server);
}
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
let initializedPromiseResolve = () => {};
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
}
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
serverDebug('listTools');
await initializedPromise;
const tools = await backend.listTools();
return { tools };
});
let heartbeatRunning = false;
server.setRequestHandler(CallToolRequestSchema, async request => {
serverDebug('callTool', request);
await initializedPromise;
if (runHeartbeat && !heartbeatRunning) {
heartbeatRunning = true;
startHeartbeat(server);
}
try {
return await backend.callTool(request.params.name, request.params.arguments || {});
} catch (error) {
return {
content: [{ type: 'text', text: '### Result\n' + String(error) }],
isError: true,
};
}
});
addServerListener(server, 'initialized', async () => {
try {
const capabilities = server.getClientCapabilities();
let clientRoots: Root[] = [];
if (capabilities?.roots) {
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
clientRoots = roots;
}
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
await backend.initialize?.(server, clientVersion, clientRoots);
initializedPromiseResolve();
} catch (e) {
errorsDebug(e);
}
});
addServerListener(server, 'close', () => backend.serverClosed?.(server));
return server;
}
const startHeartbeat = (server: Server) => {
const beat = () => {
Promise.race([
server.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
]).then(() => {
setTimeout(beat, 3000);
}).catch(() => {
void server.close();
});
};
beat();
};
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
const oldListener = server[`on${event}`];
server[`on${event}`] = () => {
oldListener?.();
listener();
};
}
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
if (options.port === undefined) {
await connect(serverBackendFactory, new StdioServerTransport(), false);
return;
}
const httpServer = await startHttpServer(options);
await installHttpTransport(httpServer, serverBackendFactory);
const url = httpAddressToString(httpServer.address());
const mcpConfig: any = { mcpServers: { } };
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
url: `${url}/mcp`
};
const message = [
`Listening on ${url}`,
'Put this in your client config:',
JSON.stringify(mcpConfig, undefined, 2),
'For legacy SSE transport support, you can use the /sse endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.error(message);
}

View File

@@ -1,46 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { zodToJsonSchema } from 'zod-to-json-schema';
import type { z } from 'zod';
import type * as mcpServer from './server.js';
export type ToolSchema<Input extends z.Schema> = {
name: string;
title: string;
description: string;
inputSchema: Input;
type: 'readOnly' | 'destructive';
};
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
return {
name: tool.name,
description: tool.description,
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
annotations: {
title: tool.title,
readOnlyHint: tool.type === 'readOnly',
destructiveHint: tool.type === 'destructive',
openWorldHint: true,
},
};
}
export function defineToolSchema<Input extends z.Schema>(tool: ToolSchema<Input>): ToolSchema<Input> {
return tool;
}

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