4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
41ba5ba0eb Merge remote-tracking branch 'origin/main' into copilot/fix-759
# Conflicts:
#	src/browserContextFactory.ts
2025-08-02 01:27:58 +00:00
copilot-swe-agent[bot]
d4667f865d Address review feedback: move permissions to contextOptions, remove redundant tests and files
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-08-01 23:59:38 +00:00
copilot-swe-agent[bot]
ce81f556a5 Implement clipboard permissions support feature
Co-authored-by: pavelfeldman <883973+pavelfeldman@users.noreply.github.com>
2025-08-01 18:56:45 +00:00
copilot-swe-agent[bot]
fc127d5895 Initial plan 2025-08-01 18:32:15 +00:00
33 changed files with 418 additions and 1105 deletions

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20 - name: Use Node.js 18
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '18'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -32,10 +32,11 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20 - name: Use Node.js 18
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' # https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -54,10 +55,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 20 - name: Use Node.js 18
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '18'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -82,42 +83,3 @@ jobs:
npm run test -- --project=chromium-docker npm run test -- --project=chromium-docker
env: env:
MCP_IN_DOCKER: 1 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
uses: actions/setup-node@v4
with:
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: extension
path: ./extension/dist
retention-days: 7
- name: Install and build MCP server
run: |
cd ..
npm ci
npm run build
npx playwright install chromium
- name: Run tests
run: |
if [[ "$(uname)" == "Linux" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
else
npm run test
fi
shell: bash

1
.gitignore vendored
View File

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

View File

@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
#### Click the button to install: #### 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) [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually: #### Or install manually:
@@ -169,6 +169,8 @@ Playwright MCP server supports following arguments. They can be provided in the
--no-sandbox disable the sandbox for all process types that --no-sandbox disable the sandbox for all process types that
are normally sandboxed. are normally sandboxed.
--output-dir <path> path to the directory for output files. --output-dir <path> path to the directory for output files.
--permissions <permissions> comma-separated list of permissions to grant, for
example "clipboard-read,clipboard-write"
--port <port> port to listen on for SSE transport. --port <port> port to listen on for SSE transport.
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for --proxy-bypass <bypass> comma-separated domains to bypass proxy, for
example ".com,chromium.org,.domain.com" example ".com,chromium.org,.domain.com"

View File

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

View File

@@ -16,8 +16,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^5.0.0", "vite": "^5.0.0"
"vite-plugin-static-copy": "^3.1.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1091,46 +1090,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -1183,31 +1142,6 @@
} }
] ]
}, },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1290,34 +1224,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1341,72 +1247,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1437,19 +1277,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1501,48 +1328,12 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true "dev": true
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-map": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz",
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true "dev": true
}, },
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1605,19 +1396,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.46.1", "version": "4.46.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.1.tgz",
@@ -1684,64 +1462,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -1755,16 +1475,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -1854,26 +1564,6 @@
} }
} }
}, },
"node_modules/vite-plugin-static-copy": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz",
"integrity": "sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"fs-extra": "^11.3.0",
"p-map": "^7.0.3",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.14"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -19,8 +19,7 @@
"scripts": { "scripts": {
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build", "build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch", "watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
"test": "playwright test", "clean": "rm -rf lib"
"clean": "rm -rf dist"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
@@ -30,7 +29,6 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^5.0.0", "vite": "^5.0.0"
"vite-plugin-static-copy": "^3.1.1"
} }
} }

View File

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

View File

@@ -19,67 +19,42 @@ import { RelayConnection, debugLog } from './relayConnection.js';
type PageMessage = { type PageMessage = {
type: 'connectToMCPRelay'; type: 'connectToMCPRelay';
mcpRelayUrl: string; mcpRelayUrl: string;
} | {
type: 'getTabs';
} | {
type: 'connectToTab';
tabId: number; tabId: number;
windowId: number; windowId: number;
mcpRelayUrl: string;
} | { } | {
type: 'getConnectionStatus'; type: 'getTabs';
} | {
type: 'disconnect';
}; };
class TabShareExtension { class TabShareExtension {
private _activeConnection: RelayConnection | undefined; private _activeConnection: RelayConnection | undefined;
private _connectedTabId: number | null = null; private _connectedTabId: number | null = null;
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
constructor() { constructor() {
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this)); chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this)); chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
} }
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031 // Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
switch (message.type) { switch (message.type) {
case 'connectToMCPRelay': case 'connectToMCPRelay':
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then( this._connectTab(message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'getTabs':
this._getTabs().then(
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
(error: any) => sendResponse({ success: false, error: error.message }));
return true;
case 'connectToTab':
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
() => sendResponse({ success: true }), () => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; // Return true to indicate that the response will be sent asynchronously return true; // Return true to indicate that the response will be sent asynchronously
case 'getConnectionStatus': case 'getTabs':
sendResponse({ this._getTabs().then(
connectedTabId: this._connectedTabId tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
});
return false;
case 'disconnect':
this._disconnect().then(
() => sendResponse({ success: true }),
(error: any) => sendResponse({ success: false, error: error.message })); (error: any) => sendResponse({ success: false, error: error.message }));
return true; return true;
} }
return false; return false;
} }
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> { private async _connectTab(tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try { try {
debugLog(`Connecting to relay at ${mcpRelayUrl}`); debugLog(`Connecting tab ${tabId} to bridge at ${mcpRelayUrl}`);
const socket = new WebSocket(mcpRelayUrl); const socket = new WebSocket(mcpRelayUrl);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve(); socket.onopen = () => resolve();
@@ -87,41 +62,17 @@ class TabShareExtension {
setTimeout(() => reject(new Error('Connection timeout')), 5000); setTimeout(() => reject(new Error('Connection timeout')), 5000);
}); });
const connection = new RelayConnection(socket); const connection = new RelayConnection(socket, tabId);
connection.onclose = () => { const connectionClosed = (m: string) => {
debugLog('Connection closed'); debugLog(m);
this._pendingTabSelection.delete(selectorTabId); if (this._activeConnection === connection) {
// TODO: show error in the selector tab? this._activeConnection = undefined;
}; void this._setConnectedTabId(null);
this._pendingTabSelection.set(selectorTabId, { connection }); }
debugLog(`Connected to MCP relay`);
} catch (error: any) {
debugLog(`Failed to connect to MCP relay:`, error.message);
throw error;
}
}
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
try {
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
try {
this._activeConnection?.close('Another connection is requested');
} catch (error: any) {
debugLog(`Error closing active connection:`, error);
}
await this._setConnectedTabId(null);
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
if (!this._activeConnection)
throw new Error('No active MCP relay connection');
this._pendingTabSelection.delete(selectorTabId);
this._activeConnection.setTabId(tabId);
this._activeConnection.onclose = () => {
debugLog('MCP connection closed');
this._activeConnection = undefined;
void this._setConnectedTabId(null);
}; };
socket.onclose = () => connectionClosed('WebSocket closed');
socket.onerror = error => connectionClosed(`WebSocket error: ${error}`);
this._activeConnection = connection;
await Promise.all([ await Promise.all([
this._setConnectedTabId(tabId), this._setConnectedTabId(tabId),
@@ -140,29 +91,18 @@ class TabShareExtension {
const oldTabId = this._connectedTabId; const oldTabId = this._connectedTabId;
this._connectedTabId = tabId; this._connectedTabId = tabId;
if (oldTabId && oldTabId !== tabId) if (oldTabId && oldTabId !== tabId)
await this._updateBadge(oldTabId, { text: '' }); await this._updateBadge(oldTabId, { text: '', color: null });
if (tabId) if (tabId)
await this._updateBadge(tabId, { text: '', color: '#4CAF50', title: 'Connected to MCP client' }); await this._updateBadge(tabId, { text: '', color: '#4CAF50' });
} }
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> { private async _updateBadge(tabId: number, { text, color }: { text: string; color: string | null }): Promise<void> {
try { await chrome.action.setBadgeText({ tabId, text });
await chrome.action.setBadgeText({ tabId, text }); if (color)
await chrome.action.setTitle({ tabId, title: title || '' }); await chrome.action.setBadgeBackgroundColor({ tabId, color });
if (color)
await chrome.action.setBadgeBackgroundColor({ tabId, color });
} catch (error: any) {
// Ignore errors as the tab may be closed already.
}
} }
private async _onTabRemoved(tabId: number): Promise<void> { private async _onTabRemoved(tabId: number): Promise<void> {
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
if (pendingConnection) {
this._pendingTabSelection.delete(tabId);
pendingConnection.close('Browser tab closed');
return;
}
if (this._connectedTabId !== tabId) if (this._connectedTabId !== tabId)
return; return;
this._activeConnection?.close('Browser tab closed'); this._activeConnection?.close('Browser tab closed');
@@ -170,50 +110,15 @@ class TabShareExtension {
this._connectedTabId = null; this._connectedTabId = null;
} }
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) { private async _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): Promise<void> {
for (const [tabId, pending] of this._pendingTabSelection) {
if (tabId === activeInfo.tabId) {
if (pending.timerId) {
clearTimeout(pending.timerId);
pending.timerId = undefined;
}
continue;
}
if (!pending.timerId) {
pending.timerId = setTimeout(() => {
const existed = this._pendingTabSelection.delete(tabId);
if (existed) {
pending.connection.close('Tab has been inactive for 5 seconds');
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
}
}, 5000);
return;
}
}
}
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
if (changeInfo.status === 'complete' && this._connectedTabId === tabId) if (changeInfo.status === 'complete' && this._connectedTabId === tabId)
void this._setConnectedTabId(tabId); await this._setConnectedTabId(tabId);
} }
private async _getTabs(): Promise<chrome.tabs.Tab[]> { private async _getTabs(): Promise<chrome.tabs.Tab[]> {
const tabs = await chrome.tabs.query({}); const tabs = await chrome.tabs.query({});
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme))); return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
} }
private async _onActionClicked(): Promise<void> {
await chrome.tabs.create({
url: chrome.runtime.getURL('status.html'),
active: true
});
}
private async _disconnect(): Promise<void> {
this._activeConnection?.close('User disconnected');
this._activeConnection = undefined;
await this._setConnectedTabId(null);
}
} }
new TabShareExtension(); new TabShareExtension();

View File

@@ -41,18 +41,11 @@ export class RelayConnection {
private _ws: WebSocket; private _ws: WebSocket;
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void; private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void; private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
private _tabPromise: Promise<void>;
private _tabPromiseResolve!: () => void;
private _closed = false;
onclose?: () => void; constructor(ws: WebSocket, tabId: number) {
this._debuggee = { tabId };
constructor(ws: WebSocket) {
this._debuggee = { };
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
this._ws = ws; this._ws = ws;
this._ws.onmessage = this._onMessage.bind(this); this._ws.onmessage = this._onMessage.bind(this);
this._ws.onclose = () => this._onClose();
// Store listeners for cleanup // Store listeners for cleanup
this._eventListener = this._onDebuggerEvent.bind(this); this._eventListener = this._onDebuggerEvent.bind(this);
this._detachListener = this._onDebuggerDetach.bind(this); this._detachListener = this._onDebuggerDetach.bind(this);
@@ -60,27 +53,10 @@ export class RelayConnection {
chrome.debugger.onDetach.addListener(this._detachListener); chrome.debugger.onDetach.addListener(this._detachListener);
} }
// Either setTabId or close is called after creating the connection.
setTabId(tabId: number): void {
this._debuggee = { tabId };
this._tabPromiseResolve();
}
close(message: string): void { close(message: string): void {
this._ws.close(1000, message);
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
// CDP events to the closed connection.
this._onClose();
}
private _onClose() {
if (this._closed)
return;
this._closed = true;
chrome.debugger.onEvent.removeListener(this._eventListener); chrome.debugger.onEvent.removeListener(this._eventListener);
chrome.debugger.onDetach.removeListener(this._detachListener); chrome.debugger.onDetach.removeListener(this._detachListener);
chrome.debugger.detach(this._debuggee).catch(() => {}); this._ws.close(1000, message);
this.onclose?.();
} }
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void { private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
@@ -135,8 +111,9 @@ export class RelayConnection {
} }
private async _handleCommand(message: ProtocolCommand): Promise<any> { private async _handleCommand(message: ProtocolCommand): Promise<any> {
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'attachToTab') { if (message.method === 'attachToTab') {
await this._tabPromise;
debugLog('Attaching debugger to tab:', this._debuggee); debugLog('Attaching debugger to tab:', this._debuggee);
await chrome.debugger.attach(this._debuggee, '1.3'); await chrome.debugger.attach(this._debuggee, '1.3');
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo'); const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
@@ -144,8 +121,6 @@ export class RelayConnection {
targetInfo: result?.targetInfo, targetInfo: result?.targetInfo,
}; };
} }
if (!this._debuggee.tabId)
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
if (message.method === 'forwardCDPCommand') { if (message.method === 'forwardCDPCommand') {
const { sessionId, method, params } = message.params; const { sessionId, method, params } = message.params;
debugLog('CDP command:', method, params); debugLog('CDP command:', method, params);
@@ -172,7 +147,6 @@ export class RelayConnection {
} }
private _sendMessage(message: any): void { private _sendMessage(message: any): void {
if (this._ws.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify(message));
this._ws.send(JSON.stringify(message));
} }
} }

View File

@@ -25,9 +25,10 @@ body {
background-color: #ffffff; background-color: #ffffff;
color: #1f2328; color: #1f2328;
margin: 0; margin: 0;
padding: 16px; padding: 24px;
min-height: 100vh; min-height: 100vh;
font-size: 14px; font-size: 14px;
line-height: 1.5;
} }
.content-wrapper { .content-wrapper {
@@ -35,55 +36,50 @@ body {
margin: 0 auto; margin: 0 auto;
} }
/* Status Banner */ .main-title {
.status-container { font-size: 32px;
display: flex; font-weight: 600;
align-items: center; margin-bottom: 8px;
justify-content: space-between; color: #1f2328;
margin-bottom: 16px;
padding-right: 12px;
} }
/* Status Banner */
.status-banner { .status-banner {
padding: 12px; padding: 16px;
margin-bottom: 24px;
border-radius: 6px;
border: 1px solid;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
} }
.status-banner.connected { .status-banner.connected {
color: #1f2328; background-color: #dafbe1;
} border-color: #1a7f37;
color: #0d5a23;
.status-banner.connected::before {
content: "\2705";
margin-right: 8px;
} }
.status-banner.error { .status-banner.error {
color: #1f2328; background-color: #ffebe9;
border-color: #da3633;
color: #a40e26;
} }
.status-banner.error::before { .status-banner.connecting {
content: "\274C"; background-color: #fff8c5;
margin-right: 8px; border-color: #d1b500;
color: #7a5c00;
} }
/* Buttons */ /* Buttons */
.button-container { .button-container {
margin-bottom: 16px; margin-bottom: 24px;
display: flex;
justify-content: flex-end;
padding-right: 12px;
} }
.button { .button {
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
border: none; border: 1px solid;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -92,63 +88,46 @@ body {
justify-content: center; justify-content: center;
text-decoration: none; text-decoration: none;
margin-right: 8px; margin-right: 8px;
min-width: 90px;
} }
.button.primary { .button.primary {
background-color: #f8f9fa; background-color: #2da44e;
color: #3c4043; border-color: #2da44e;
border: 1px solid #dadce0; color: #ffffff;
} }
.button.primary:hover { .button.primary:hover {
background-color: #f1f3f4; background-color: #2c974b;
border-color: #dadce0;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
} }
.button.default { .button.default {
background-color: #f6f8fa; background-color: #f6f8fa;
border-color: #d1d9e0;
color: #24292f; color: #24292f;
} }
.button.default:hover { .button.default:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
} border-color: #c7d2da;
.button.reject {
background-color: #da3633;
color: #ffffff;
border: 1px solid #da3633;
}
.button.reject:hover {
background-color: #c73836;
border-color: #c73836;
} }
/* Tab selection */ /* Tab selection */
.tab-section-title { .tab-section-title {
padding-left: 12px; font-size: 20px;
font-size: 12px; font-weight: 600;
font-weight: 400; margin-bottom: 16px;
margin-bottom: 12px; color: #1f2328;
color: #656d76;
} }
.tab-item { .tab-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
margin-bottom: 8px; margin-bottom: 8px;
background-color: #ffffff; background-color: #ffffff;
cursor: pointer; cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s ease;
}
.tab-item:hover {
background-color: #f8f9fa;
} }
.tab-item.selected { .tab-item.selected {

View File

@@ -16,13 +16,20 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js'; interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
type StatusType = 'connected' | 'error' | 'connecting'; type StatusType = 'connected' | 'error' | 'connecting';
const ConnectApp: React.FC = () => { const ConnectApp: React.FC = () => {
const [tabs, setTabs] = useState<TabInfo[]>([]); const [tabs, setTabs] = useState<TabInfo[]>([]);
const [selectedTab, setSelectedTab] = useState<TabInfo | undefined>();
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null); const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
const [showButtons, setShowButtons] = useState(true); const [showButtons, setShowButtons] = useState(true);
const [showTabList, setShowTabList] = useState(true); const [showTabList, setShowTabList] = useState(true);
@@ -47,41 +54,42 @@ const ConnectApp: React.FC = () => {
setClientInfo(info); setClientInfo(info);
setStatus({ setStatus({
type: 'connecting', type: 'connecting',
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?` message: `MCP client "${info}" is trying to connect. Do you want to continue?`
}); });
} catch (e) { } catch (e) {
setStatus({ type: 'error', message: 'Failed to parse client version.' }); setStatus({ type: 'error', message: 'Failed to parse client version.' });
return; return;
} }
void connectToMCPRelay(relayUrl);
void loadTabs(); void loadTabs();
}, []); }, []);
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
if (!response.success)
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
}, []);
const loadTabs = useCallback(async () => { const loadTabs = useCallback(async () => {
const response = await chrome.runtime.sendMessage({ type: 'getTabs' }); const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
if (response.success) if (response.success) {
setTabs(response.tabs); setTabs(response.tabs);
else const currentTab = response.tabs.find((tab: TabInfo) => tab.id === response.currentTabId);
setSelectedTab(currentTab);
} else {
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error }); setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
}
}, []); }, []);
const handleConnectToTab = useCallback(async (tab: TabInfo) => { const handleContinue = useCallback(async () => {
setShowButtons(false); setShowButtons(false);
setShowTabList(false); setShowTabList(false);
if (!selectedTab) {
setStatus({ type: 'error', message: 'Tab not selected.' });
return;
}
try { try {
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'connectToTab', type: 'connectToMCPRelay',
mcpRelayUrl, mcpRelayUrl,
tabId: tab.id, tabId: selectedTab.id,
windowId: tab.windowId, windowId: selectedTab.windowId,
}); });
if (response?.success) { if (response?.success) {
@@ -98,7 +106,7 @@ const ConnectApp: React.FC = () => {
message: `MCP client "${clientInfo}" failed to connect: ${e}` message: `MCP client "${clientInfo}" failed to connect: ${e}`
}); });
} }
}, [clientInfo, mcpRelayUrl]); }, [selectedTab, clientInfo, mcpRelayUrl]);
const handleReject = useCallback(() => { const handleReject = useCallback(() => {
setShowButtons(false); setShowButtons(false);
@@ -106,46 +114,39 @@ const ConnectApp: React.FC = () => {
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' }); setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
}, []); }, []);
useEffect(() => {
const listener = (message: any) => {
if (message.type === 'connectionTimeout')
handleReject();
};
chrome.runtime.onMessage.addListener(listener);
return () => {
chrome.runtime.onMessage.removeListener(listener);
};
}, []);
return ( return (
<div className='app-container'> <div className='app-container'>
<div className='content-wrapper'> <div className='content-wrapper'>
{status && ( <h1 className='main-title'>
<div className='status-container'> Playwright MCP Extension
<StatusBanner type={status.type} message={status.message} /> </h1>
{showButtons && (
<Button variant='reject' onClick={handleReject}> {status && <StatusBanner type={status.type} message={status.message} />}
Reject
</Button> {showButtons && (
)} <div className='button-container'>
<Button variant='primary' onClick={handleContinue}>
Continue
</Button>
<Button variant='default' onClick={handleReject}>
Reject
</Button>
</div> </div>
)} )}
{showTabList && ( {showTabList && (
<div> <div>
<div className='tab-section-title'> <h2 className='tab-section-title'>
Select page to expose to MCP server: Select page to expose to MCP server:
</div> </h2>
<div> <div>
{tabs.map(tab => ( {tabs.map(tab => (
<TabItem <TabItem
key={tab.id} key={tab.id}
tab={tab} tab={tab}
button={ isSelected={selectedTab?.id === tab.id}
<Button variant='primary' onClick={() => handleConnectToTab(tab)}> onSelect={() => setSelectedTab(tab)}
Connect
</Button>
}
/> />
))} ))}
</div> </div>
@@ -160,6 +161,46 @@ const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, m
return <div className={`status-banner ${type}`}>{message}</div>; return <div className={`status-banner ${type}`}>{message}</div>;
}; };
const Button: React.FC<{ variant: 'primary' | 'default'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
const TabItem: React.FC<{ tab: TabInfo; isSelected: boolean; onSelect: () => void }> = ({
tab,
isSelected,
onSelect
}) => {
const className = `tab-item ${isSelected ? 'selected' : ''}`.trim();
return (
<div className={className} onClick={onSelect}>
<input
type='radio'
className='tab-radio'
checked={isSelected}
/>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>{tab.title || 'Untitled'}</div>
<div className='tab-url'>{tab.url}</div>
</div>
</div>
);
};
// Initialize the React app // Initialize the React app
const container = document.getElementById('root'); const container = document.getElementById('root');
if (container) { if (container) {

View File

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

View File

@@ -1,110 +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 React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
interface ConnectionStatus {
isConnected: boolean;
connectedTabId: number | null;
connectedTab?: TabInfo;
}
const StatusApp: React.FC = () => {
const [status, setStatus] = useState<ConnectionStatus>({
isConnected: false,
connectedTabId: null
});
useEffect(() => {
void loadStatus();
}, []);
const loadStatus = async () => {
// Get current connection status from background script
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
if (connectedTabId) {
const tab = await chrome.tabs.get(connectedTabId);
setStatus({
isConnected: true,
connectedTabId,
connectedTab: {
id: tab.id!,
windowId: tab.windowId!,
title: tab.title!,
url: tab.url!,
favIconUrl: tab.favIconUrl
}
});
} else {
setStatus({
isConnected: false,
connectedTabId: null
});
}
};
const openConnectedTab = async () => {
if (!status.connectedTabId)
return;
await chrome.tabs.update(status.connectedTabId, { active: true });
window.close();
};
const disconnect = async () => {
await chrome.runtime.sendMessage({ type: 'disconnect' });
window.close();
};
return (
<div className='app-container'>
<div className='content-wrapper'>
{status.isConnected && status.connectedTab ? (
<div>
<div className='tab-section-title'>
Page with connected MCP client:
</div>
<div>
<TabItem
tab={status.connectedTab}
button={
<Button variant='primary' onClick={disconnect}>
Disconnect
</Button>
}
onClick={openConnectedTab}
/>
</div>
</div>
) : (
<div className='status-banner'>
No MCP clients are currently connected.
</div>
)}
</div>
</div>
);
};
// Initialize the React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<StatusApp />);
}

View File

@@ -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 React from 'react';
export interface TabInfo {
id: number;
windowId: number;
title: string;
url: string;
favIconUrl?: string;
}
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
variant,
onClick,
children
}) => {
return (
<button className={`button ${variant}`} onClick={onClick}>
{children}
</button>
);
};
export interface TabItemProps {
tab: TabInfo;
onClick?: () => void;
button?: React.ReactNode;
}
export const TabItem: React.FC<TabItemProps> = ({
tab,
onClick,
button
}) => {
return (
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
<img
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
alt=''
className='tab-favicon'
/>
<div className='tab-content'>
<div className='tab-title'>
{tab.title || 'Untitled'}
</div>
<div className='tab-url'>{tab.url}</div>
</div>
{button}
</div>
);
};

View File

@@ -1,152 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import type { BrowserContext } from 'playwright';
type BrowserWithExtension = {
userDataDir: string;
launch: () => Promise<BrowserContext>;
};
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
browserWithExtension: async ({ mcpBrowser }, 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');
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
let browserContext: BrowserContext | undefined;
const userDataDir = testInfo.outputPath('extension-user-data-dir');
await use({
userDataDir,
launch: async () => {
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: [
`--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();
},
});
test('navigate with extension', async ({ browserWithExtension, startClient, server }) => {
const browserContext = await browserWithExtension.launch();
const { client } = await startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
method: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
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;
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
expect(await navigateResponse).toHaveResponse({
pageState: 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 startClient({
args: [`--connect-tool`],
config: {
browser: {
userDataDir: browserWithExtension.userDataDir,
}
},
});
expect(await client.callTool({
name: 'browser_connect',
arguments: {
method: 'extension'
}
})).toHaveResponse({
result: 'Successfully changed connection method.',
});
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);
});

View File

@@ -6,9 +6,8 @@
"strict": true, "strict": true,
"module": "ESNext", "module": "ESNext",
"rootDir": "src", "rootDir": "src",
"outDir": "./dist/lib", "outDir": "./lib",
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["chrome"],
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
}, },

View File

@@ -17,37 +17,23 @@
import { resolve } from 'path'; import { resolve } from 'path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(), base: '/lib/ui/',
viteStaticCopy({
targets: [
{
src: '../../icons/*',
dest: 'icons'
},
{
src: '../../manifest.json',
dest: '.'
}
]
})
],
root: resolve(__dirname, 'src/ui'),
build: { build: {
outDir: resolve(__dirname, 'dist/'), outDir: resolve(__dirname, 'lib/ui'),
emptyOutDir: false, emptyOutDir: true,
minify: false, minify: false,
rollupOptions: { rollupOptions: {
input: ['src/ui/connect.html', 'src/ui/status.html'], input: resolve(__dirname, 'connect.html'),
output: { output: {
manualChunks: undefined, manualChunks: undefined,
entryFileNames: 'lib/ui/[name].js', inlineDynamicImports: true,
chunkFileNames: 'lib/ui/[name].js', entryFileNames: '[name].js',
assetFileNames: 'lib/ui/[name].[ext]' chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]'
} }
} }
} }

32
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.33", "version": "0.0.32",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.33", "version": "0.0.32",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.16.0", "@modelcontextprotocol/sdk": "^1.16.0",
@@ -14,8 +14,8 @@
"debug": "^4.4.1", "debug": "^4.4.1",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"playwright": "1.55.0-alpha-2025-08-07", "playwright": "1.55.0-alpha-1753913825000",
"playwright-core": "1.55.0-alpha-2025-08-07", "playwright-core": "1.55.0-alpha-1753913825000",
"ws": "^8.18.1", "ws": "^8.18.1",
"zod": "^3.24.1", "zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
@@ -27,7 +27,7 @@
"@anthropic-ai/sdk": "^0.57.0", "@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-2025-08-07", "@playwright/test": "1.55.0-alpha-1753913825000",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
@@ -703,13 +703,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.55.0-alpha-2025-08-07", "version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-2025-08-07.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-N83L8JSSJ+E690HCbgzmXIcbRfM/rlh0uWZhbHbMp9q4qDPABSgvhm0HGiG345PV1ozoqcCI/mXLZPircsmPIA==", "integrity": "sha512-YM5YHU6nTYNVzXlKvQvtEdXzpubLvdfEiTxwWvbqGHL/iDK2kBJd3L0psIG6yClU1wy01O756TkOOQSEpzOu7g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.55.0-alpha-2025-08-07" "playwright": "1.55.0-alpha-1753913825000"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3745,12 +3745,12 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.55.0-alpha-2025-08-07", "version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-2025-08-07.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-rH8kdQOZzhjxC6FOL9zSEDwPl88ZqQq9QEvRDONWhzKwRQ/jOXlEZRxm8QRCBdrLqBMTGHx/YOaP7MIV//rtIA==", "integrity": "sha512-IDyZzTu3tRNIjcx7/6ZmU7VmZPFGaW4jNsizwqbjSoeLFZPTLx2y693qeVVF/8KwEjuiSU3hVTQEzWvnx7cf2Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.55.0-alpha-2025-08-07" "playwright-core": "1.55.0-alpha-1753913825000"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3763,9 +3763,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.55.0-alpha-2025-08-07", "version": "1.55.0-alpha-1753913825000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-2025-08-07.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0-alpha-1753913825000.tgz",
"integrity": "sha512-NUuC6R0/dLk1QKiYoJL8NUsQAC6Je0C2BpuIg5h4wcvBwJ5TFldslmik17Txg3TXBSqwgG76DAl4Q6UdHGn54Q==", "integrity": "sha512-FH5pHzLseQxD8+d2wGlRa/I32AzJ+ZzcdDNM1aiSw5+gmq+aOo3PBqXHvhsh7tj0h4l2Qf6z9qf4mMiwijVthw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.33", "version": "0.0.32",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -42,8 +42,8 @@
"debug": "^4.4.1", "debug": "^4.4.1",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"playwright": "1.55.0-alpha-2025-08-07", "playwright": "1.55.0-alpha-1753913825000",
"playwright-core": "1.55.0-alpha-2025-08-07", "playwright-core": "1.55.0-alpha-1753913825000",
"ws": "^8.18.1", "ws": "^8.18.1",
"zod": "^3.24.1", "zod": "^3.24.1",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
@@ -52,7 +52,7 @@
"@anthropic-ai/sdk": "^0.57.0", "@anthropic-ai/sdk": "^0.57.0",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.55.0-alpha-2025-08-07", "@playwright/test": "1.55.0-alpha-1753913825000",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",

View File

@@ -138,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
} }
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0]; return this.config.browser.isolated ? await browser.newContext(this.config.browser.contextOptions) : browser.contexts()[0];
} }
} }
@@ -156,7 +156,7 @@ class RemoteContextFactory extends BaseContextFactory {
} }
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
return browser.newContext(); return browser.newContext(this.config.browser.contextOptions);
} }
} }

View File

@@ -55,9 +55,7 @@ export class BrowserServerBackend implements ServerBackend {
async initialize(server: mcpServer.Server): Promise<void> { async initialize(server: mcpServer.Server): Promise<void> {
const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities; const capabilities = server.getClientCapabilities() as mcpServer.ClientCapabilities;
let rootPath: string | undefined; let rootPath: string | undefined;
if (capabilities.roots && ( if (capabilities.roots) {
server.getClientVersion()?.name === 'Visual Studio Code' ||
server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
const { roots } = await server.listRoots(); const { roots } = await server.listRoots();
const firstRootUri = roots[0]?.uri; const firstRootUri = roots[0]?.uri;
const url = firstRootUri ? new URL(firstRootUri) : undefined; const url = firstRootUri ? new URL(firstRootUri) : undefined;

View File

@@ -40,6 +40,7 @@ export type CLIOptions = {
imageResponses?: 'allow' | 'omit'; imageResponses?: 'allow' | 'omit';
sandbox?: boolean; sandbox?: boolean;
outputDir?: string; outputDir?: string;
permissions?: string[];
port?: number; port?: number;
proxyBypass?: string; proxyBypass?: string;
proxyServer?: string; proxyServer?: string;
@@ -170,6 +171,9 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
if (cliOptions.blockServiceWorkers) if (cliOptions.blockServiceWorkers)
contextOptions.serviceWorkers = 'block'; contextOptions.serviceWorkers = 'block';
if (cliOptions.permissions)
contextOptions.permissions = cliOptions.permissions;
const result: Config = { const result: Config = {
browser: { browser: {
browserName, browserName,
@@ -216,6 +220,7 @@ function configFromEnv(): Config {
options.imageResponses = 'omit'; options.imageResponses = 'omit';
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX); options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR); options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.permissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_PERMISSIONS);
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT); options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER); options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);

View File

@@ -56,7 +56,6 @@ type CDPResponse = {
export class CDPRelayServer { export class CDPRelayServer {
private _wsHost: string; private _wsHost: string;
private _browserChannel: string; private _browserChannel: string;
private _userDataDir?: string;
private _cdpPath: string; private _cdpPath: string;
private _extensionPath: string; private _extensionPath: string;
private _wss: WebSocketServer; private _wss: WebSocketServer;
@@ -70,10 +69,9 @@ export class CDPRelayServer {
private _nextSessionId: number = 1; private _nextSessionId: number = 1;
private _extensionConnectionPromise!: ManualPromise<void>; private _extensionConnectionPromise!: ManualPromise<void>;
constructor(server: http.Server, browserChannel: string, userDataDir?: string) { constructor(server: http.Server, browserChannel: string) {
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws'); this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
this._browserChannel = browserChannel; this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`; this._cdpPath = `/cdp/${uuid}`;
@@ -108,7 +106,7 @@ export class CDPRelayServer {
private _connectBrowser(clientInfo: ClientInfo) { private _connectBrowser(clientInfo: ClientInfo) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`; const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
// Need to specify "key" in the manifest.json to make the id stable when loading from file. // 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'); const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/lib/ui/connect.html');
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint); url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
url.searchParams.set('client', JSON.stringify(clientInfo)); url.searchParams.set('client', JSON.stringify(clientInfo));
const href = url.toString(); const href = url.toString();
@@ -119,12 +117,7 @@ export class CDPRelayServer {
if (!executablePath) if (!executablePath)
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`); throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
const args: string[] = []; spawn(executablePath, [href], {
if (this._userDataDir)
args.push(`--user-data-dir=${this._userDataDir}`);
args.push(href);
spawn(executablePath, args, {
windowsHide: true, windowsHide: true,
detached: true, detached: true,
shell: false, shell: false,

View File

@@ -28,39 +28,51 @@ export class ExtensionContextFactory implements BrowserContextFactory {
description = 'Connect to a browser using the Playwright MCP extension'; description = 'Connect to a browser using the Playwright MCP extension';
private _browserChannel: string; private _browserChannel: string;
private _userDataDir?: string; private _relayPromise: Promise<CDPRelayServer> | undefined;
private _browserPromise: Promise<playwright.Browser> | undefined;
constructor(browserChannel: string, userDataDir: string | undefined) { constructor(browserChannel: string) {
this._browserChannel = browserChannel; this._browserChannel = browserChannel;
this._userDataDir = userDataDir;
} }
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const browser = await this._obtainBrowser(clientInfo, abortSignal); // First call will establish the connection to the extension.
if (!this._browserPromise)
this._browserPromise = this._obtainBrowser(clientInfo, abortSignal);
const browser = await this._browserPromise;
return { return {
browserContext: browser.contexts()[0], browserContext: browser.contexts()[0],
close: async () => { close: async () => {
debugLogger('close() called for browser context'); debugLogger('close() called for browser context');
await browser.close(); await browser.close();
this._browserPromise = undefined;
} }
}; };
} }
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> { private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
const relay = await this._startRelay(abortSignal); if (!this._relayPromise)
this._relayPromise = this._startRelay(abortSignal);
const relay = await this._relayPromise;
abortSignal.throwIfAborted();
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal); await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint()); const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
browser.on('disconnected', () => {
this._browserPromise = undefined;
debugLogger('Browser disconnected');
});
return browser;
} }
private async _startRelay(abortSignal: AbortSignal) { private async _startRelay(abortSignal: AbortSignal) {
const httpServer = await startHttpServer({}); const httpServer = await startHttpServer({});
if (abortSignal.aborted) { const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel);
httpServer.close();
throw new Error(abortSignal.reason);
}
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`); debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
if (abortSignal.aborted)
cdpRelayServer.stop();
else
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
return cdpRelayServer; return cdpRelayServer;
} }
} }

View File

@@ -21,11 +21,11 @@ import * as mcpTransport from '../mcp/transport.js';
import type { FullConfig } from '../config.js'; import type { FullConfig } from '../config.js';
export async function runWithExtension(config: FullConfig) { export async function runWithExtension(config: FullConfig) {
const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir); const contextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]); const serverBackendFactory = () => new BrowserServerBackend(config, [contextFactory]);
await mcpTransport.start(serverBackendFactory, config.server); await mcpTransport.start(serverBackendFactory, config.server);
} }
export function createExtensionContextFactory(config: FullConfig) { export function createExtensionContextFactory(config: FullConfig) {
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir); return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome');
} }

View File

@@ -46,6 +46,7 @@ program
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".') .option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.') .option('--output-dir <path>', 'path to the directory for output files.')
.option('--permissions <permissions>', 'comma-separated list of permissions to grant, for example "clipboard-read,clipboard-write"', commaSeparatedList)
.option('--port <port>', 'port to listen on for SSE transport.') .option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')

View File

@@ -75,15 +75,10 @@ const screenshot = defineTabTool({
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`); response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
response.addImage({
// https://github.com/microsoft/playwright-mcp/issues/817 contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
// Never return large images to LLM, saving them to the file system is enough. data: buffer
if (!params.fullPage) { });
response.addImage({
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
data: buffer
});
}
} }
}); });

View File

@@ -39,9 +39,7 @@ const wait = defineTool({
const code: string[] = []; const code: string[] = [];
if (params.time) { if (params.time) {
const timeCode = `await new Promise(f => setTimeout(f, ${params.time!} * 1000));`; code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
code.push(timeCode);
response.addCode(timeCode);
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000))); await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
} }
@@ -50,16 +48,12 @@ const wait = defineTool({
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined; const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) { if (goneLocator) {
const goneCode = `await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`; code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
code.push(goneCode);
response.addCode(goneCode);
await goneLocator.waitFor({ state: 'hidden' }); await goneLocator.waitFor({ state: 'hidden' });
} }
if (locator) { if (locator) {
const locatorCode = `await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`; code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
code.push(locatorCode);
response.addCode(locatorCode);
await locator.waitFor({ state: 'visible' }); await locator.waitFor({ state: 'visible' });
} }

View File

@@ -191,7 +191,7 @@ async function createTransport(args: string[], mcpMode: TestOptions['mcpMode'],
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: 'node', command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args], args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.dirname(test.info().config.configFile!), cwd: path.join(path.dirname(__filename), '..'),
stderr: 'pipe', stderr: 'pipe',
env: { env: {
...process.env, ...process.env,

170
tests/permissions.spec.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
test('clipboard permissions support via CLI argument', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Test Page</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard write operation without user permission prompt
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("test clipboard content")'
}
});
expect(writeResponse.isError).toBeFalsy();
// Test clipboard read operation without user permission prompt
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"test clipboard content"'
});
});
test('clipboard permissions support via config file contextOptions', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Config Test Page</h1>
</body>
</html>
`, 'text/html');
const config = {
browser: {
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write']
}
}
};
const { client } = await startClient({ config });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Verify permissions are granted via config
const permissionsResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(permissionsResponse.isError).toBeFalsy();
expect(permissionsResponse).toHaveResponse({
result: '"granted"'
});
// Test clipboard operations work with config file
const writeResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.writeText("config test content")'
}
});
expect(writeResponse.isError).toBeFalsy();
const readResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.clipboard.readText()'
}
});
expect(readResponse.isError).toBeFalsy();
expect(readResponse).toHaveResponse({
result: '"config test content"'
});
});
test('multiple permissions can be granted', async ({ startClient, server }) => {
server.setContent('/', `
<html>
<body>
<h1>Multiple Permissions Test</h1>
</body>
</html>
`, 'text/html');
const { client } = await startClient({ args: ['--permissions', 'clipboard-read,clipboard-write,geolocation'] });
// Navigate to server page
const navigateResponse = await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(navigateResponse.isError).toBeFalsy();
// Test that multiple permissions can be granted
const clipboardPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "clipboard-write" }).then(result => result.state)'
}
});
expect(clipboardPermissionResponse.isError).toBeFalsy();
expect(clipboardPermissionResponse).toHaveResponse({
result: '"granted"'
});
const geolocationPermissionResponse = await client.callTool({
name: 'browser_evaluate',
arguments: {
function: '() => navigator.permissions.query({ name: "geolocation" }).then(result => result.state)'
}
});
expect(geolocationPermissionResponse.isError).toBeFalsy();
expect(geolocationPermissionResponse).toHaveResponse({
result: '"granted"'
});
});

View File

@@ -25,7 +25,6 @@ const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/exi
test('should use separate user data by root path', async ({ startClient, server }, testInfo) => { test('should use separate user data by root path', async ({ startClient, server }, testInfo) => {
const { client } = await startClient({ const { client } = await startClient({
clientName: 'Visual Studio Code', // Simulate VS Code client, roots only work with it
roots: [ roots: [
{ {
name: 'test', name: 'test',
@@ -49,7 +48,6 @@ test('check that trace is saved in workspace', async ({ startClient, server, mcp
const rootPath = testInfo.outputPath('workspace'); const rootPath = testInfo.outputPath('workspace');
const { client } = await startClient({ const { client } = await startClient({
args: ['--save-trace'], args: ['--save-trace'],
clientName: 'Visual Studio Code - Insiders', // Simulate VS Code client, roots only work with it
roots: [ roots: [
{ {
name: 'workspace', name: 'workspace',

View File

@@ -264,7 +264,12 @@ test('browser_take_screenshot (fullPage: true)', async ({ startClient, server },
{ {
text: expect.stringContaining('fullPage: true'), text: expect.stringContaining('fullPage: true'),
type: 'text', type: 'text',
} },
{
data: expect.any(String),
mimeType: 'image/png',
type: 'image',
},
], ],
}); });
}); });

View File

@@ -48,7 +48,6 @@ test('browser_wait_for(text)', async ({ client, server }) => {
name: 'browser_wait_for', name: 'browser_wait_for',
arguments: { text: 'Text to appear' }, arguments: { text: 'Text to appear' },
})).toHaveResponse({ })).toHaveResponse({
code: `await page.getByText("Text to appear").first().waitFor({ state: 'visible' });`,
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
}); });
}); });
@@ -85,23 +84,6 @@ test('browser_wait_for(textGone)', async ({ client, server }) => {
name: 'browser_wait_for', name: 'browser_wait_for',
arguments: { textGone: 'Text to disappear' }, arguments: { textGone: 'Text to disappear' },
})).toHaveResponse({ })).toHaveResponse({
code: `await page.getByText("Text to disappear").first().waitFor({ state: 'hidden' });`,
pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`), pageState: expect.stringContaining(`- generic [ref=e3]: Text to appear`),
}); });
}); });
test('browser_wait_for(time)', async ({ client, server }) => {
server.setContent('/', `<body><div>Hello World</div></body>`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { time: 1 },
})).toHaveResponse({
code: `await new Promise(f => setTimeout(f, 1 * 1000));`,
});
});