19 Commits

Author SHA1 Message Date
Pavel Feldman
675b083db3 chore: mark v0.0.28 (#503) 2025-06-01 14:30:42 -07:00
Pavel Feldman
0b74cdaaf8 chore: sort out signal handling (#506) 2025-06-01 14:11:42 -07:00
Pavel Feldman
f31ef598bc test: verify the log in close/navigate test (#505) 2025-06-01 12:49:30 -07:00
Pavel Feldman
656779531c chore: respect server settings from config (#502) 2025-05-30 18:17:51 -07:00
Pavel Feldman
eec177d3ac chore: reuse browser in server mode (#495) 2025-05-30 15:15:37 -07:00
Pavel Feldman
54ed7c3200 chore: refactor server, prepare for browser reuse (#490) 2025-05-28 16:55:47 -07:00
nabepa
3cd74a824a docs: fixed typo in README.md (#487) 2025-05-27 20:33:36 -07:00
Pavel Feldman
177b008328 chore: mark v0.0.27 (#470) 2025-05-27 16:47:54 -07:00
Pavel Feldman
9429463951 chore: roll Playwright to 5/27 (#485) 2025-05-27 16:47:22 -07:00
Simon Knott
45f493da6c chore: make library test run under older Node versions (#479) 2025-05-27 13:19:25 -07:00
Pavel Feldman
9e5ffd2ccf fix(cursor): allow enforcing images for cursor --image-responses=allow (#478)
Fixes https://github.com/microsoft/playwright-mcp/issues/449
2025-05-27 10:25:09 +02:00
Simon Knott
1051ea810a fix: import from cjs (#476)
Closes https://github.com/microsoft/playwright-mcp/issues/456
2025-05-26 14:18:03 -07:00
Pavel Feldman
f20ae22ec6 chore: roll Playwright, remove localOutputDir (#471) 2025-05-24 11:44:57 -07:00
Simon Knott
13cd1b4bd9 fix: respect browserName in config (#461)
Resolves https://github.com/microsoft/playwright-mcp/issues/458
2025-05-23 15:13:34 -07:00
Pavel Feldman
c318f13895 chore: mark v0.0.26 (#441) 2025-05-17 08:20:37 -07:00
Pavel Feldman
1318e39fac chore: fix operation over cdp (#440)
Ref https://github.com/microsoft/playwright-mcp/issues/439
2025-05-17 08:20:22 -07:00
Pavel Feldman
c2b7fb29de chore: start trace server (#427) 2025-05-14 20:15:09 -07:00
Pavel Feldman
aa6ac51f92 feat(trace): allow saving trajectory as trace (#426) 2025-05-14 18:08:44 -07:00
Pavel Feldman
fea50e6840 chore: introduce resolved config (#425) 2025-05-14 16:01:08 -07:00
36 changed files with 1061 additions and 497 deletions

View File

@@ -137,7 +137,10 @@ Playwright MCP server supports following arguments. They can be provided in the
--ignore-https-errors ignore https errors --ignore-https-errors ignore https errors
--isolated keep the browser profile in memory, do not save --isolated keep the browser profile in memory, do not save
it to disk. it to disk.
--no-image-responses do not send image responses to the client. --image-responses <mode> whether to send image responses to the client.
Can be "allow", "omit", or "auto". Defaults to
"auto", which sends images if the client can
display them.
--no-sandbox disable the sandbox for all process types that --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.
@@ -146,6 +149,8 @@ Playwright MCP server supports following arguments. They can be provided in the
example ".com,chromium.org,.domain.com" example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example --proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080" "http://myproxy:3128" or "socks5://myproxy:8080"
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--storage-state <path> path to the storage state file for isolated --storage-state <path> path to the storage state file for isolated
sessions. sessions.
--user-agent <ua string> specify user agent string --user-agent <ua string> specify user agent string
@@ -194,7 +199,7 @@ state [here](https://playwright.dev/docs/auth).
"args": [ "args": [
"@playwright/mcp@latest", "@playwright/mcp@latest",
"--isolated", "--isolated",
"--storage-state={path/to/storage.json} "--storage-state={path/to/storage.json}"
] ]
} }
} }

9
config.d.ts vendored
View File

@@ -94,6 +94,11 @@ export type Config = {
*/ */
vision?: boolean; vision?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/
saveTrace?: boolean;
/** /**
* The directory to save output files. * The directory to save output files.
*/ */
@@ -112,7 +117,7 @@ export type Config = {
}; };
/** /**
* Do not send image responses to the client. * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/ */
noImageResponses?: boolean; imageResponses?: 'allow' | 'omit' | 'auto';
}; };

View File

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

3
index.d.ts vendored
View File

@@ -18,6 +18,7 @@
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Config } from './config'; import type { Config } from './config';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { BrowserContext } from 'playwright';
export type Connection = { export type Connection = {
server: Server; server: Server;
@@ -25,5 +26,5 @@ export type Connection = {
close(): Promise<void>; close(): Promise<void>;
}; };
export declare function createConnection(config?: Config): Promise<Connection>; export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
export {}; export {};

View File

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

138
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.25", "version": "0.0.28",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.25", "version": "0.0.28",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746832516000", "debug": "^4.4.1",
"playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"bin": { "bin": {
@@ -20,8 +21,9 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746832516000", "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",
@@ -286,13 +288,13 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0-alpha-2025-05-27",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==", "integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.53.0-alpha-1746832516000" "playwright": "1.53.0-alpha-2025-05-27"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -354,6 +356,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -375,6 +387,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.10", "version": "22.13.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
@@ -853,29 +872,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/body-parser/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/body-parser/node_modules/qs": { "node_modules/body-parser/node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1156,12 +1152,12 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@@ -1826,6 +1822,29 @@
"express": "^4.11 || 5 || ^5.0.0-beta.1" "express": "^4.11 || 5 || ^5.0.0-beta.1"
} }
}, },
"node_modules/express/node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/express/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1930,29 +1949,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/finalhandler/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/find-root": { "node_modules/find-root": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -3003,9 +2999,9 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare": { "node_modules/natural-compare": {
@@ -3298,12 +3294,12 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0-alpha-2025-05-27",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==", "integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.53.0-alpha-1746832516000" "playwright-core": "1.53.0-alpha-2025-05-27"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3316,9 +3312,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0-alpha-2025-05-27",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==", "integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -3713,12 +3709,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.25", "version": "0.0.28",
"description": "Playwright Tools for MCP", "description": "Playwright Tools for MCP",
"type": "module", "type": "module",
"repository": { "repository": {
@@ -37,14 +37,16 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746832516000", "debug": "^4.4.1",
"playwright": "1.53.0-alpha-2025-05-27",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746832516000", "@playwright/test": "1.53.0-alpha-2025-05-27",
"@stylistic/eslint-plugin": "^3.0.1", "@stylistic/eslint-plugin": "^3.0.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.26.1",

View File

@@ -29,7 +29,14 @@ export default defineConfig<TestOptions>({
{ name: 'chrome' }, { name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } }, { name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } }, { name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [], ...process.env.MCP_IN_DOCKER ? [{
name: 'chromium-docker',
grep: /browser_navigate|browser_click/,
use: {
mcpBrowser: 'chromium',
mcpMode: 'docker' as const
}
}] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } }, { name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } }, { name: 'webkit', use: { mcpBrowser: 'webkit' } },
], ],

View File

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

View File

@@ -38,12 +38,13 @@ export type CLIOptions = {
host?: string; host?: string;
ignoreHttpsErrors?: boolean; ignoreHttpsErrors?: boolean;
isolated?: boolean; isolated?: boolean;
imageResponses: boolean; imageResponses?: 'allow' | 'omit' | 'auto';
sandbox: boolean; sandbox: boolean;
outputDir?: string; outputDir?: string;
port?: number; port?: number;
proxyBypass?: string; proxyBypass?: string;
proxyServer?: string; proxyServer?: string;
saveTrace?: boolean;
storageState?: string; storageState?: string;
userAgent?: string; userAgent?: string;
userDataDir?: string; userDataDir?: string;
@@ -51,7 +52,7 @@ export type CLIOptions = {
vision?: boolean; vision?: boolean;
}; };
const defaultConfig: Config = { const defaultConfig: FullConfig = {
browser: { browser: {
browserName: 'chromium', browserName: 'chromium',
launchOptions: { launchOptions: {
@@ -67,16 +68,41 @@ const defaultConfig: Config = {
allowedOrigins: undefined, allowedOrigins: undefined,
blockedOrigins: undefined, blockedOrigins: undefined,
}, },
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
}; };
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> { type BrowserUserConfig = NonNullable<Config['browser']>;
const config = await loadConfig(cliOptions.config);
export type FullConfig = Config & {
browser: Omit<BrowserUserConfig, 'browserName'> & {
browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
server: NonNullable<Config['server']>,
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
return mergeConfig(defaultConfig, config);
}
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions); const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides)); const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
if (result.browser.browserName === 'chromium')
(result.browser.launchOptions as any).cdpPort = await findFreePort();
return result;
} }
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> { export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
let browserName: 'chromium' | 'firefox' | 'webkit'; let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
let channel: string | undefined; let channel: string | undefined;
switch (cliOptions.browser) { switch (cliOptions.browser) {
case 'chrome': case 'chrome':
@@ -97,9 +123,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
case 'webkit': case 'webkit':
browserName = 'webkit'; browserName = 'webkit';
break; break;
default:
browserName = 'chromium';
channel = 'chrome';
} }
// Launch options // Launch options
@@ -109,13 +132,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
headless: cliOptions.headless, headless: cliOptions.headless,
}; };
if (browserName === 'chromium') {
(launchOptions as any).cdpPort = await findFreePort();
if (!cliOptions.sandbox) {
// --no-sandbox was passed, disable the sandbox // --no-sandbox was passed, disable the sandbox
if (!cliOptions.sandbox)
launchOptions.chromiumSandbox = false; launchOptions.chromiumSandbox = false;
}
}
if (cliOptions.proxyServer) { if (cliOptions.proxyServer) {
launchOptions.proxy = { launchOptions.proxy = {
@@ -169,14 +188,11 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
allowedOrigins: cliOptions.allowedOrigins, allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins, blockedOrigins: cliOptions.blockedOrigins,
}, },
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir, outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
}; };
if (!cliOptions.imageResponses) {
// --no-image-responses was passed, disable image responses
result.noImageResponses = true;
}
return result; return result;
} }
@@ -202,11 +218,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
} }
} }
export async function outputFile(config: Config, name: string): Promise<string> { export async function outputFile(config: FullConfig, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir(); await fs.promises.mkdir(config.outputDir, { recursive: true });
await fs.promises.mkdir(result, { recursive: true });
const fileName = sanitizeForFilePath(name); const fileName = sanitizeForFilePath(name);
return path.join(result, fileName); return path.join(config.outputDir, fileName);
} }
function pickDefined<T extends object>(obj: T | undefined): Partial<T> { function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
@@ -215,10 +230,10 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
) as Partial<T>; ) as Partial<T>;
} }
function mergeConfig(base: Config, overrides: Config): Config { function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: Config['browser'] = { const browser: FullConfig['browser'] = {
...pickDefined(base.browser), browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
...pickDefined(overrides.browser), isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: { launchOptions: {
...pickDefined(base.browser?.launchOptions), ...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions),
@@ -228,6 +243,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.browser?.contextOptions), ...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions), ...pickDefined(overrides.browser?.contextOptions),
}, },
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
}; };
if (browser.browserName !== 'chromium' && browser.launchOptions) if (browser.browserName !== 'chromium' && browser.launchOptions)
@@ -241,5 +259,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.network), ...pickDefined(base.network),
...pickDefined(overrides.network), ...pickDefined(overrides.network),
}, },
}; server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig;
} }

View File

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

View File

@@ -14,44 +14,50 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'node:fs'; import debug from 'debug';
import url from 'node:url';
import os from 'node:os';
import path from 'node:path';
import * as playwright from 'playwright'; import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js'; import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js'; import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js'; import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js'; import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js'; import type { FullConfig } from './config.js';
import { outputFile } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js';
type PendingAction = { type PendingAction = {
dialogShown: ManualPromise<void>; dialogShown: ManualPromise<void>;
}; };
type BrowserContextAndBrowser = { const testDebug = debug('pw:mcp:test');
browser?: playwright.Browser;
browserContext: playwright.BrowserContext;
};
export class Context { export class Context {
readonly tools: Tool[]; readonly tools: Tool[];
readonly config: Config; readonly config: FullConfig;
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined; private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = []; private _tabs: Tab[] = [];
private _currentTab: Tab | undefined; private _currentTab: Tab | undefined;
private _modalStates: (ModalState & { tab: Tab })[] = []; private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined; private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = []; private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
clientVersion: { name: string; version: string; } | undefined;
constructor(tools: Tool[], config: Config) { constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
this.tools = tools; this.tools = tools;
this.config = config; this.config = config;
this._browserContextFactory = browserContextFactory;
testDebug('create context');
}
clientSupportsImages(): boolean {
if (this.config.imageResponses === 'allow')
return true;
if (this.config.imageResponses === 'omit')
return false;
return !this.clientVersion?.name.includes('cursor');
} }
modalStates(): ModalState[] { modalStates(): ModalState[] {
@@ -112,7 +118,7 @@ export class Context {
const lines: string[] = ['### Open tabs']; const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) { for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i]; const tab = this._tabs[i];
const title = await tab.page.title(); const title = await tab.title();
const url = tab.page.url(); const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : ''; const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`); lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
@@ -149,7 +155,7 @@ export class Context {
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined; let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try { try {
if (waitForNetwork) if (waitForNetwork)
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined; actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else else
actionResult = await racingAction?.() ?? undefined; actionResult = await racingAction?.() ?? undefined;
} finally { } finally {
@@ -193,7 +199,7 @@ ${code.join('\n')}
result.push( result.push(
`- Page URL: ${tab.page.url()}`, `- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.page.title()}` `- Page Title: ${await tab.title()}`
); );
if (captureSnapshot && tab.hasSnapshot()) if (captureSnapshot && tab.hasSnapshot())
@@ -213,10 +219,14 @@ ${code.join('\n')}
} }
async waitForTimeout(time: number) { async waitForTimeout(time: number) {
if (this._currentTab && !this._javaScriptBlocked()) if (!this._currentTab || this._javaScriptBlocked()) {
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
else
await new Promise(f => setTimeout(f, time)); await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
} }
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> { private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
@@ -284,13 +294,15 @@ ${code.join('\n')}
if (!this._browserContextPromise) if (!this._browserContextPromise)
return; return;
testDebug('close context');
const promise = this._browserContextPromise; const promise = this._browserContextPromise;
this._browserContextPromise = undefined; this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => { await promise.then(async ({ browserContext, close }) => {
await browserContext.close().then(async () => { if (this.config.saveTrace)
await browser?.close(); await browserContext.tracing.stop();
}).catch(() => {}); await close();
}); });
} }
@@ -318,85 +330,22 @@ ${code.join('\n')}
return this._browserContextPromise; return this._browserContextPromise;
} }
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> { private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
const { browser, browserContext } = await this._createBrowserContext(); // TODO: move to the browser context factory to make it based on isolation mode.
const result = await this._browserContextFactory.createContext();
const { browserContext } = result;
await this._setupRequestInterception(browserContext); await this._setupRequestInterception(browserContext);
for (const page of browserContext.pages()) for (const page of browserContext.pages())
this._onPageCreated(page); this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page)); browserContext.on('page', page => this._onPageCreated(page));
return { browser, browserContext }; if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
} }
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (this.config.browser.browserName)
url.searchParams.set('browser', this.config.browser.browserName);
if (this.config.browser.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
return { browser, browserContext };
}
return this.config.browser?.isolated ?
await createIsolatedContext(this.config.browser) :
await launchPersistentContext(this.config.browser);
}
}
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const browserType = playwright[browserName];
const browser = await browserType.launch(browserConfig?.launchOptions);
const browserContext = await browser.newContext(browserConfig?.contextOptions);
return { browser, browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName];
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
return { browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
async function createUserDataDir(browserConfig: Config['browser']) {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result; return result;
} }
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
} }
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -15,9 +15,31 @@
*/ */
import { Connection, createConnection as createConnectionImpl } from './connection.js'; import { Connection, createConnection as createConnectionImpl } from './connection.js';
import { resolveConfig } from './config.js';
import { contextFactory } from './browserContextFactory.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory.js';
export async function createConnection(config: Config = {}): Promise<Connection> { export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
return createConnectionImpl(config); const config = await resolveConfig(userConfig);
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
return createConnectionImpl(config, factory);
}
class SimpleBrowserContextFactory implements BrowserContextFactory {
private readonly _contextGetter: () => Promise<BrowserContext>;
constructor(contextGetter: () => Promise<BrowserContext>) {
this._contextGetter = contextGetter;
}
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
const browserContext = await this._contextGetter();
return {
browserContext,
close: () => browserContext.close()
};
}
} }

22
src/package.ts Normal file
View File

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

View File

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

View File

@@ -15,12 +15,13 @@
*/ */
import { program } from 'commander'; import { program } from 'commander';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { startHttpTransport, startStdioTransport } from './transport.js'; import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveConfig } from './config.js'; import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import type { Connection } from './connection.js'; import { packageJSON } from './package.js';
import { packageJSON } from './context.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
@@ -38,43 +39,39 @@ program
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') .option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
.option('--ignore-https-errors', 'ignore https errors') .option('--ignore-https-errors', 'ignore https errors')
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.') .option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
.option('--no-image-responses', 'do not send image responses to the client.') .option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.') .option('--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('--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"')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.') .option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string') .option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.') .option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"') .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => { .action(async options => {
const config = await resolveConfig(options); const config = await resolveCLIConfig(options);
const connectionList: Connection[] = []; const server = new Server(config);
setupExitWatchdog(connectionList); server.setupExitWatchdog();
if (options.port) if (config.server.port !== undefined)
startHttpTransport(config, +options.port, options.host, connectionList); startHttpTransport(server);
else else
await startStdioTransport(config, connectionList); await startStdioTransport(server);
});
function setupExitWatchdog(connectionList: Connection[]) { if (config.saveTrace) {
const handleExit = async () => { const server = await startTraceViewerServer();
setTimeout(() => process.exit(0), 15000); const urlPrefix = server.urlPrefix('human-readable');
for (const connection of connectionList) const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
await connection.close(); // eslint-disable-next-line no-console
process.exit(0); console.error('\nTrace viewer listening on ' + url);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
} }
});
function semicolonSeparatedList(value: string): string[] { function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim()); return value.split(';').map(v => v.trim());
} }
program.parse(process.argv); void program.parseAsync(process.argv);

59
src/server.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createConnection } from './connection.js';
import { contextFactory } from './browserContextFactory.js';
import type { FullConfig } from './config.js';
import type { Connection } from './connection.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { BrowserContextFactory } from './browserContextFactory.js';
export class Server {
readonly config: FullConfig;
private _connectionList: Connection[] = [];
private _browserConfig: FullConfig['browser'];
private _contextFactory: BrowserContextFactory;
constructor(config: FullConfig) {
this.config = config;
this._browserConfig = config.browser;
this._contextFactory = contextFactory(this._browserConfig);
}
async createConnection(transport: Transport): Promise<Connection> {
const connection = createConnection(this.config, this._contextFactory);
this._connectionList.push(connection);
await connection.server.connect(transport);
return connection;
}
setupExitWatchdog() {
let isExiting = false;
const handleExit = async () => {
if (isExiting)
return;
isExiting = true;
setTimeout(() => process.exit(0), 15000);
await Promise.all(this._connectionList.map(connection => connection.close()));
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
}

View File

@@ -19,6 +19,7 @@ import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js'; import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js'; import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab { export class Tab {
readonly context: Context; readonly context: Context;
@@ -61,10 +62,18 @@ export class Tab {
this._onPageClose(this); this._onPageClose(this);
} }
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) { async navigate(url: string) {
this._clearCollectedArtifacts(); this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {}); const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try { try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' }); await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) { } catch (_e: unknown) {
@@ -85,7 +94,7 @@ export class Tab {
} }
// Cap load event to 5 seconds, the page is operational at this point. // Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); await this.waitForLoadState('load', { timeout: 5000 });
} }
hasSnapshot(): boolean { hasSnapshot(): boolean {

View File

@@ -57,14 +57,14 @@ const screenshot = defineTool({
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`, `// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
]; ];
const locator = params.ref ? snapshot.refLocator(params.ref) : null; const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
if (locator) if (locator)
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`); code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
else else
code.push(`await page.screenshot(${javascript.formatObject(options)});`); code.push(`await page.screenshot(${javascript.formatObject(options)});`);
const includeBase64 = !context.config.noImageResponses; const includeBase64 = context.clientSupportsImages();
const action = async () => { const action = async () => {
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options); const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
return { return {

View File

@@ -58,7 +58,7 @@ const click = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const tab = context.currentTabOrDie(); const tab = context.currentTabOrDie();
const locator = tab.snapshotOrDie().refLocator(params.ref); const locator = tab.snapshotOrDie().refLocator(params);
const code = [ const code = [
`// Click ${params.element}`, `// Click ${params.element}`,
@@ -91,8 +91,8 @@ const drag = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const startLocator = snapshot.refLocator(params.startRef); const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
const endLocator = snapshot.refLocator(params.endRef); const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
const code = [ const code = [
`// Drag ${params.startElement} to ${params.endElement}`, `// Drag ${params.startElement} to ${params.endElement}`,
@@ -120,7 +120,7 @@ const hover = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code = [ const code = [
`// Hover over ${params.element}`, `// Hover over ${params.element}`,
@@ -154,7 +154,7 @@ const type = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code: string[] = []; const code: string[] = [];
const steps: (() => Promise<void>)[] = []; const steps: (() => Promise<void>)[] = [];
@@ -200,7 +200,7 @@ const selectOption = defineTool({
handle: async (context, params) => { handle: async (context, params) => {
const snapshot = context.currentTabOrDie().snapshotOrDie(); const snapshot = context.currentTabOrDie().snapshotOrDie();
const locator = snapshot.refLocator(params.ref); const locator = snapshot.refLocator(params);
const code = [ const code = [
`// Select options [${params.values.join(', ')}] in ${params.element}`, `// Select options [${params.values.join(', ')}] in ${params.element}`,

View File

@@ -16,8 +16,9 @@
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> { export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>(); const requests = new Set<playwright.Request>();
let frameNavigated = false; let frameNavigated = false;
let waitCallback: () => void = () => {}; let waitCallback: () => void = () => {};
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
frameNavigated = true; frameNavigated = true;
dispose(); dispose();
clearTimeout(timeout); clearTimeout(timeout);
void frame.waitForLoadState('load').then(() => { void tab.waitForLoadState('load').then(waitCallback);
waitCallback();
});
}; };
const onTimeout = () => { const onTimeout = () => {
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
waitCallback(); waitCallback();
}; };
page.on('request', requestListener); tab.page.on('request', requestListener);
page.on('requestfinished', requestFinishedListener); tab.page.on('requestfinished', requestFinishedListener);
page.on('framenavigated', frameNavigateListener); tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000); const timeout = setTimeout(onTimeout, 10000);
const dispose = () => { const dispose = () => {
page.off('request', requestListener); tab.page.off('request', requestListener);
page.off('requestfinished', requestFinishedListener); tab.page.off('requestfinished', requestFinishedListener);
page.off('framenavigated', frameNavigateListener); tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout); clearTimeout(timeout);
}; };
@@ -81,3 +80,7 @@ export function sanitizeForFilePath(s: string) {
export async function generateLocator(locator: playwright.Locator): Promise<string> { export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString(); return (locator as any)._generateLocatorString();
} }
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
}

View File

@@ -18,22 +18,20 @@ import http from 'node:http';
import assert from 'node:assert'; import assert from 'node:assert';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import debug from 'debug';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createConnection } from './connection.js'; import type { Server } from './server.js';
import type { Config } from '../config.js'; export async function startStdioTransport(server: Server) {
import type { Connection } from './connection.js'; await server.createConnection(new StdioServerTransport());
export async function startStdioTransport(config: Config, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
} }
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) { const testDebug = debug('pw:mcp:test');
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
if (req.method === 'POST') { if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId'); const sessionId = url.searchParams.get('sessionId');
if (!sessionId) { if (!sessionId) {
@@ -51,15 +49,13 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res); const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport); sessions.set(transport.sessionId, transport);
const connection = await createConnection(config); testDebug(`create SSE session: ${transport.sessionId}`);
await connection.connect(transport); const connection = await server.createConnection(transport);
connectionList.push(connection);
res.on('close', () => { res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
connection.close().catch(e => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(e); void connection.close().catch(e => console.error(e));
});
}); });
return; return;
} }
@@ -68,7 +64,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed'); res.end('Method not allowed');
} }
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) { async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) { if (sessionId) {
const transport = sessions.get(sessionId); const transport = sessions.get(sessionId);
@@ -91,12 +87,8 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
if (transport.sessionId) if (transport.sessionId)
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
}; };
const connection = await createConnection(config); await server.createConnection(transport);
connectionList.push(connection); await transport.handleRequest(req, res);
await Promise.all([
connection.connect(transport),
transport.handleRequest(req, res),
]);
return; return;
} }
@@ -104,17 +96,18 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
res.end('Invalid request'); res.end('Invalid request');
} }
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) { export function startHttpTransport(server: Server) {
const sseSessions = new Map<string, SSEServerTransport>(); const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => { const httpServer = http.createServer(async (req, res) => {
const url = new URL(`http://localhost${req.url}`); const url = new URL(`http://localhost${req.url}`);
if (url.pathname.startsWith('/mcp')) if (url.pathname.startsWith('/mcp'))
await handleStreamable(config, req, res, streamableSessions, connectionList); await handleStreamable(server, req, res, streamableSessions);
else else
await handleSSE(config, req, res, url, sseSessions, connectionList); await handleSSE(server, req, res, url, sseSessions);
}); });
httpServer.listen(port, hostname, () => { const { host, port } = server.config.server;
httpServer.listen(port, host, () => {
const address = httpServer.address(); const address = httpServer.address();
assert(address, 'Could not bind server socket'); assert(address, 'Could not bind server socket');
let url: string; let url: string;
@@ -140,6 +133,6 @@ export function startHttpTransport(config: Config, port: number, hostname: strin
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.', 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n'); ].join('\n');
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(message); console.error(message);
}); });
} }

View File

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

View File

@@ -16,16 +16,21 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient, server }) => { test('cdp server', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] }); await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
}); });
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => { test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] }); const browserContext = await cdpServer.start();
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD);
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_click', name: 'browser_click',
@@ -43,18 +48,17 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
// <internal code to capture accessibility snapshot> // <internal code to capture accessibility snapshot>
\`\`\` \`\`\`
- Page URL: data:text/html,hello world - Page URL: ${server.HELLO_WORLD}
- Page Title: - Page Title: Title
- Page Snapshot - Page Snapshot
\`\`\`yaml \`\`\`yaml
- generic [ref=e1]: hello world - generic [ref=e1]: Hello, world!
\`\`\` \`\`\`
`); `);
}); });
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => { test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const port = 3200 + test.info().parallelIndex; const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
@@ -65,7 +69,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpEndpoi
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`); })).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port); await cdpServer.start();
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },

View File

@@ -19,7 +19,7 @@ import fs from 'node:fs';
import { Config } from '../config.js'; import { Config } from '../config.js';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, localOutputPath, server }) => { test('config user data dir', async ({ startClient, server }, testInfo) => {
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>
<body>Hello, world!</body> <body>Hello, world!</body>
@@ -27,13 +27,13 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
const config: Config = { const config: Config = {
browser: { browser: {
userDataDir: localOutputPath('user-data-dir'), userDataDir: testInfo.outputPath('user-data-dir'),
}, },
}; };
const configPath = localOutputPath('config.json'); const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const client = await startClient({ args: ['--config', configPath] }); const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -42,3 +42,22 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
const files = await fs.promises.readdir(config.browser!.userDataDir!); const files = await fs.promises.readdir(config.browser!.userDataDir!);
expect(files.length).toBeGreaterThan(0); expect(files.length).toBeGreaterThan(0);
}); });
test.describe(() => {
test.use({ mcpBrowser: '' });
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
const config: Config = {
browser: {
browserName: 'firefox',
},
};
const configPath = testInfo.outputPath('config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
const { client } = await startClient({ args: ['--config', configPath] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
})).toContainTextContent(`Firefox`);
});
});

View File

@@ -17,7 +17,7 @@
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('--device should work', async ({ startClient, server }) => { test('--device should work', async ({ startClient, server }) => {
const client = await startClient({ const { client } = await startClient({
args: ['--device', 'iPhone 15'], args: ['--device', 'iPhone 15'],
}); });

View File

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

View File

@@ -22,26 +22,30 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { TestServer } from './testserver/index.ts'; import { TestServer } from './testserver/index.ts';
import type { Config } from '../config'; import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
export type TestOptions = { export type TestOptions = {
mcpBrowser: string | undefined; mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined; mcpMode: 'docker' | undefined;
}; };
type CDPServer = {
endpoint: string;
start: () => Promise<BrowserContext>;
};
type TestFixtures = { type TestFixtures = {
client: Client; client: Client;
visionClient: Client; visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>; startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
wsEndpoint: string; wsEndpoint: string;
cdpEndpoint: (port?: number) => Promise<string>; cdpServer: CDPServer;
server: TestServer; server: TestServer;
httpsServer: TestServer; httpsServer: TestServer;
mcpHeadless: boolean; mcpHeadless: boolean;
localOutputPath: (filePath: string) => string;
}; };
type WorkerFixtures = { type WorkerFixtures = {
@@ -51,11 +55,13 @@ type WorkerFixtures = {
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
client: async ({ startClient }, use) => { client: async ({ startClient }, use) => {
await use(await startClient()); const { client } = await startClient();
await use(client);
}, },
visionClient: async ({ startClient }, use) => { visionClient: async ({ startClient }, use) => {
await use(await startClient({ args: ['--vision'] })); const { client } = await startClient({ args: ['--vision'] });
await use(client);
}, },
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
@@ -81,9 +87,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }); client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode); const transport = createTransport(args, mcpMode);
let stderr = '';
transport.stderr?.on('data', data => {
stderr += data.toString();
});
await client.connect(transport); await client.connect(transport);
await client.ping(); await client.ping();
return client; return { client, stderr: () => stderr };
}); });
await client?.close(); await client?.close();
@@ -95,39 +105,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await browserServer.close(); await browserServer.close();
}, },
cdpEndpoint: async ({ }, use, testInfo) => { cdpServer: async ({ mcpBrowser }, use, testInfo) => {
let browserProcess: ChildProcessWithoutNullStreams | undefined; test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
await use(async port => { let browserContext: BrowserContext | undefined;
if (!port) const port = 3200 + test.info().parallelIndex;
port = 3200 + test.info().parallelIndex; await use({
if (browserProcess) endpoint: `http://localhost:${port}`,
return `http://localhost:${port}`; start: async () => {
browserProcess = spawn(chromium.executablePath(), [ browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`, channel: mcpBrowser,
headless: true,
args: [
`--remote-debugging-port=${port}`, `--remote-debugging-port=${port}`,
`--no-first-run`, ],
`--no-sandbox`,
`--headless`,
'--use-mock-keychain',
`data:text/html,hello world`,
], {
stdio: 'pipe',
}); });
await new Promise<void>(resolve => { return browserContext;
browserProcess!.stderr.on('data', data => { }
if (data.toString().includes('DevTools listening on '))
resolve();
});
});
return `http://localhost:${port}`;
});
await new Promise<void>(resolve => {
if (!browserProcess)
return resolve();
browserProcess.on('exit', () => resolve());
browserProcess.kill();
}); });
await browserContext?.close();
}, },
mcpHeadless: async ({ headless }, use) => { mcpHeadless: async ({ headless }, use) => {
@@ -138,13 +134,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpMode: [undefined, { option: true }], mcpMode: [undefined, { option: true }],
localOutputPath: async ({ mcpMode }, use, testInfo) => {
await use(filePath => {
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
return testInfo.outputPath(filePath);
});
},
_workerServers: [async ({}, use, workerInfo) => { _workerServers: [async ({}, use, workerInfo) => {
const port = 8907 + workerInfo.workerIndex * 4; const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port); const server = await TestServer.create(port);
@@ -185,7 +174,13 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
command: 'node', command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args], args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'), cwd: path.join(path.dirname(__filename), '..'),
env: process.env as Record<string, string>, stderr: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
}); });
} }
@@ -242,3 +237,7 @@ export const expect = baseExpect.extend({
}; };
}, },
}); });
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
}

View File

@@ -16,9 +16,10 @@
import fs from 'fs'; import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect, formatOutput } from './fixtures.js';
test('test reopen browser', async ({ client, server }) => { test('test reopen browser', async ({ startClient, server }) => {
const { client, stderr } = await startClient();
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -32,10 +33,31 @@ test('test reopen browser', async ({ client, server }) => {
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`); })).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
if (process.platform === 'win32')
return;
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
'create context',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
'create browser context (persistent)',
'lock user data dir',
'close context',
'close browser context (persistent)',
'release user data dir',
'close browser context complete (persistent)',
]);
}); });
test('executable path', async ({ startClient, server }) => { test('executable path', async ({ startClient, server }) => {
const client = await startClient({ args: [`--executable-path=bogus`] }); const { client } = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },
@@ -53,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient(); const { client } = await startClient();
const response = await client.callTool({ const response = await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -66,7 +88,7 @@ test('persistent context', async ({ startClient, server }) => {
name: 'browser_close', name: 'browser_close',
}); });
const client2 = await startClient(); const { client: client2 } = await startClient();
const response2 = await client2.callTool({ const response2 = await client2.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -85,18 +107,18 @@ test('isolated context', async ({ startClient, server }) => {
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient({ args: [`--isolated`] }); const { client: client1 } = await startClient({ args: [`--isolated`] });
const response = await client.callTool({ const response = await client1.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
}); });
expect(response).toContainTextContent(`Storage: NO`); expect(response).toContainTextContent(`Storage: NO`);
await client.callTool({ await client1.callTool({
name: 'browser_close', name: 'browser_close',
}); });
const client2 = await startClient({ args: [`--isolated`] }); const { client: client2 } = await startClient({ args: [`--isolated`] });
const response2 = await client2.callTool({ const response2 = await client2.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.PREFIX }, arguments: { url: server.PREFIX },
@@ -104,8 +126,8 @@ test('isolated context', async ({ startClient, server }) => {
expect(response2).toContainTextContent(`Storage: NO`); expect(response2).toContainTextContent(`Storage: NO`);
}); });
test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => { test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
const storageStatePath = localOutputPath('storage-state.json'); const storageStatePath = testInfo.outputPath('storage-state.json');
await fs.promises.writeFile(storageStatePath, JSON.stringify({ await fs.promises.writeFile(storageStatePath, JSON.stringify({
origins: [ origins: [
{ {
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server, localO
</script> </script>
`, 'text/html'); `, 'text/html');
const client = await startClient({ args: [ const { client } = await startClient({ args: [
`--isolated`, `--isolated`,
`--storage-state=${storageStatePath}`, `--storage-state=${storageStatePath}`,
] }); ] });

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

@@ -0,0 +1,28 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './fixtures.js';
import fs from 'node:fs/promises';
import child_process from 'node:child_process';
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
const file = testInfo.outputPath('main.cjs');
await fs.writeFile(file, `
import('@playwright/mcp')
.then(playwrightMCP => playwrightMCP.createConnection())
.then(() => console.log('OK'));
`);
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,35 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
});