24 Commits

Author SHA1 Message Date
Dmitry Gozman
0df6d7a441 chore: roll playwright to Jun 10th, v1.53 (#542)
Co-authored-by: Simon Knott <simonknott@microsoft.com>
2025-06-11 15:53:14 +01:00
Dmitry Gozman
4ea7041ba9 chore: mark v0.0.29 (#541) 2025-06-11 12:00:52 +01:00
Dan O'Brien
7dae68de78 docs: add instructions for MCP server in Qodo Gen (#530) 2025-06-08 10:38:24 -07:00
Peter Goldstein
60495ed9b0 docs: include Cursor One-Click in README.md (#531) 2025-06-08 10:37:48 -07:00
cranemont
0aaef661b1 docs(readme): fix connection method call in programmatic usage example (#532) 2025-06-08 10:36:27 -07:00
Max Schmitt
abbe7858a2 test: add PWMCP_DEBUG env switch (#523) 2025-06-05 10:40:03 -07:00
Simon Knott
767af21e02 chore: fix Connection type (#517)
The external `Connection` type regressed in
https://github.com/microsoft/playwright-mcp/pull/490/files#diff-a6be0583428e46844273df76939f02077073da3075716fc57d291a5f2463eaf5,
where the `connect()` function was removed but not from the types. I've
changed the code so we import from there, similar to how we do it for
`config.d.ts`, so this shouldn't happen again.
2025-06-05 08:47:04 +02:00
Pavel Feldman
27c498e0e7 chore: rename browser agent to server (#521) 2025-06-04 16:43:11 -07:00
Pavel Feldman
0fb9646c4d chore: experimental agent mode (#516) 2025-06-04 09:14:50 -07:00
Simon Knott
9728527900 chore: typo (#513) 2025-06-03 11:10:47 -07:00
Pavel Feldman
675b083db3 chore: mark v0.0.28 (#503) 2025-06-01 14:30:42 -07:00
Pavel Feldman
0b74cdaaf8 chore: sort out signal handling (#506) 2025-06-01 14:11:42 -07:00
Pavel Feldman
f31ef598bc test: verify the log in close/navigate test (#505) 2025-06-01 12:49:30 -07:00
Pavel Feldman
656779531c chore: respect server settings from config (#502) 2025-05-30 18:17:51 -07:00
Pavel Feldman
eec177d3ac chore: reuse browser in server mode (#495) 2025-05-30 15:15:37 -07:00
Pavel Feldman
54ed7c3200 chore: refactor server, prepare for browser reuse (#490) 2025-05-28 16:55:47 -07:00
nabepa
3cd74a824a docs: fixed typo in README.md (#487) 2025-05-27 20:33:36 -07:00
Pavel Feldman
177b008328 chore: mark v0.0.27 (#470) 2025-05-27 16:47:54 -07:00
Pavel Feldman
9429463951 chore: roll Playwright to 5/27 (#485) 2025-05-27 16:47:22 -07:00
Simon Knott
45f493da6c chore: make library test run under older Node versions (#479) 2025-05-27 13:19:25 -07:00
Pavel Feldman
9e5ffd2ccf fix(cursor): allow enforcing images for cursor --image-responses=allow (#478)
Fixes https://github.com/microsoft/playwright-mcp/issues/449
2025-05-27 10:25:09 +02:00
Simon Knott
1051ea810a fix: import from cjs (#476)
Closes https://github.com/microsoft/playwright-mcp/issues/456
2025-05-26 14:18:03 -07:00
Pavel Feldman
f20ae22ec6 chore: roll Playwright, remove localOutputDir (#471) 2025-05-24 11:44:57 -07:00
Simon Knott
13cd1b4bd9 fix: respect browserName in config (#461)
Resolves https://github.com/microsoft/playwright-mcp/issues/458
2025-05-23 15:13:34 -07:00
40 changed files with 1564 additions and 604 deletions

View File

@@ -52,6 +52,12 @@ After installation, the Playwright MCP server will be available for use with you
<details> <details>
<summary><b>Install in Cursor</b></summary> <summary><b>Install in Cursor</b></summary>
#### Click the button to install:
[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
#### Or install manually:
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`. Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
```js ```js
@@ -106,6 +112,27 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
``` ```
</details> </details>
<details>
<summary><b>Install in Qodo Gen</b></summary>
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}
}
```
Click <code>Save</code>.
</details>
### Configuration ### Configuration
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list: Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
@@ -124,6 +151,7 @@ Playwright MCP server supports following arguments. They can be provided in the
--block-service-workers block service workers --block-service-workers block service workers
--browser <browser> browser or chrome channel to use, possible --browser <browser> browser or chrome channel to use, possible
values: chrome, firefox, webkit, msedge. values: chrome, firefox, webkit, msedge.
--browser-agent <endpoint> Use browser agent (experimental).
--caps <caps> comma-separated list of capabilities to enable, --caps <caps> comma-separated list of capabilities to enable,
possible values: tabs, pdf, history, wait, files, possible values: tabs, pdf, history, wait, files,
install. Default is all. install. Default is all.
@@ -137,7 +165,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.
@@ -196,7 +227,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}"
] ]
} }
} }
@@ -351,7 +382,7 @@ http.createServer(async (req, res) => {
// Creates a headless Playwright MCP server with SSE transport // Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ browser: { launchOptions: { headless: true } } }); const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res); const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport); await connection.sever.connect(transport);
// ... // ...
}); });

9
config.d.ts vendored
View File

@@ -23,6 +23,11 @@ export type Config = {
* The browser to use. * The browser to use.
*/ */
browser?: { browser?: {
/**
* Use browser agent (experimental).
*/
browserAgent?: string;
/** /**
* The type of browser to use. * The type of browser to use.
*/ */
@@ -117,7 +122,7 @@ export type Config = {
}; };
/** /**
* Do not send image responses to the client. * Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
*/ */
noImageResponses?: boolean; imageResponses?: 'allow' | 'omit' | 'auto';
}; };

View File

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

7
index.d.ts vendored
View File

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

View File

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

342
package-lock.json generated
View File

@@ -1,17 +1,19 @@
{ {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.26", "version": "0.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@playwright/mcp", "name": "@playwright/mcp",
"version": "0.0.26", "version": "0.0.29",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746832516000", "debug": "^4.4.1",
"mime": "^4.0.7",
"playwright": "1.53.0",
"zod-to-json-schema": "^3.24.4" "zod-to-json-schema": "^3.24.4"
}, },
"bin": { "bin": {
@@ -20,8 +22,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",
"@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 +289,12 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==", "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.53.0-alpha-1746832516000" "playwright": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -354,6 +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",
@@ -834,16 +853,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"debug": "^4.4.0", "debug": "^4.4.0",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"iconv-lite": "^0.5.2", "iconv-lite": "^0.6.3",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.0", "qs": "^6.14.0",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
@@ -853,44 +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": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1156,12 +1137,12 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@@ -1224,16 +1205,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -1769,46 +1740,45 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "5.0.1", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.0.1", "body-parser": "^2.2.0",
"content-disposition": "^1.0.0", "content-disposition": "^1.0.0",
"content-type": "~1.0.4", "content-type": "^1.0.5",
"cookie": "0.7.1", "cookie": "^0.7.1",
"cookie-signature": "^1.2.1", "cookie-signature": "^1.2.1",
"debug": "4.3.6", "debug": "^4.4.0",
"depd": "2.0.0", "encodeurl": "^2.0.0",
"encodeurl": "~2.0.0", "escape-html": "^1.0.3",
"escape-html": "~1.0.3", "etag": "^1.8.1",
"etag": "~1.8.1", "finalhandler": "^2.1.0",
"finalhandler": "^2.0.0", "fresh": "^2.0.0",
"fresh": "2.0.0", "http-errors": "^2.0.0",
"http-errors": "2.0.0",
"merge-descriptors": "^2.0.0", "merge-descriptors": "^2.0.0",
"methods": "~1.1.2",
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
"on-finished": "2.4.1", "on-finished": "^2.4.1",
"once": "1.4.0", "once": "^1.4.0",
"parseurl": "~1.3.3", "parseurl": "^1.3.3",
"proxy-addr": "~2.0.7", "proxy-addr": "^2.0.7",
"qs": "6.13.0", "qs": "^6.14.0",
"range-parser": "~1.2.1", "range-parser": "^1.2.1",
"router": "^2.0.0", "router": "^2.2.0",
"safe-buffer": "5.2.1",
"send": "^1.1.0", "send": "^1.1.0",
"serve-static": "^2.1.0", "serve-static": "^2.2.0",
"setprototypeof": "1.2.0", "statuses": "^2.0.1",
"statuses": "2.0.1", "type-is": "^2.0.1",
"type-is": "^2.0.0", "vary": "^1.1.2"
"utils-merge": "1.0.1",
"vary": "~1.1.2"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
@@ -1930,29 +1900,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/finalhandler/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/find-root": { "node_modules/find-root": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
@@ -2037,7 +1984,6 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -2312,12 +2258,12 @@
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.5.2", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2928,15 +2874,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/metric-lcs": { "node_modules/metric-lcs": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
@@ -2958,6 +2895,21 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2968,12 +2920,12 @@
} }
}, },
"node_modules/mime-types": { "node_modules/mime-types": {
"version": "3.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "^1.53.0" "mime-db": "^1.54.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@@ -3003,9 +2955,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 +3250,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==", "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.53.0-alpha-1746832516000" "playwright-core": "1.53.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -3316,10 +3267,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.53.0-alpha-1746832516000", "version": "1.53.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==", "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
"license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@@ -3371,12 +3321,12 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.0.6" "side-channel": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
@@ -3430,18 +3380,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3529,11 +3467,13 @@
} }
}, },
"node_modules/router": { "node_modules/router": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0", "is-promise": "^4.0.0",
"parseurl": "^1.3.3", "parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0" "path-to-regexp": "^8.0.0"
@@ -3661,19 +3601,18 @@
} }
}, },
"node_modules/send": { "node_modules/send": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.5", "debug": "^4.3.5",
"destroy": "^1.2.0",
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"etag": "^1.8.1", "etag": "^1.8.1",
"fresh": "^0.5.2", "fresh": "^2.0.0",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"mime-types": "^2.1.35", "mime-types": "^3.0.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"range-parser": "^1.2.1", "range-parser": "^1.2.1",
@@ -3683,52 +3622,16 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/send/node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"encodeurl": "^2.0.0", "encodeurl": "^2.0.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"parseurl": "^1.3.3", "parseurl": "^1.3.3",
"send": "^1.0.0" "send": "^1.2.0"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -4061,9 +3964,9 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -4211,15 +4114,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

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

197
src/browserServer.ts Normal file
View File

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

View File

@@ -15,7 +15,6 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import net from 'net';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { devices } from 'playwright'; import { devices } from 'playwright';
@@ -29,6 +28,7 @@ export type CLIOptions = {
blockedOrigins?: string[]; blockedOrigins?: string[];
blockServiceWorkers?: boolean; blockServiceWorkers?: boolean;
browser?: string; browser?: string;
browserAgent?: string;
caps?: string; caps?: string;
cdpEndpoint?: string; cdpEndpoint?: string;
config?: string; config?: string;
@@ -38,7 +38,7 @@ 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;
@@ -68,19 +68,21 @@ const defaultConfig: FullConfig = {
allowedOrigins: undefined, allowedOrigins: undefined,
blockedOrigins: undefined, blockedOrigins: undefined,
}, },
server: {},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())), outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
}; };
type BrowserUserConfig = NonNullable<Config['browser']>; type BrowserUserConfig = NonNullable<Config['browser']>;
export type FullConfig = Config & { export type FullConfig = Config & {
browser: BrowserUserConfig & { browser: Omit<BrowserUserConfig, 'browserName'> & {
browserName: NonNullable<BrowserUserConfig['browserName']>; browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>; launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>; contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
}, },
network: NonNullable<Config['network']>, network: NonNullable<Config['network']>,
outputDir: string; outputDir: string;
server: NonNullable<Config['server']>,
}; };
export async function resolveConfig(config: Config): Promise<FullConfig> { export async function resolveConfig(config: Config): Promise<FullConfig> {
@@ -98,7 +100,7 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
} }
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':
@@ -119,9 +121,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
@@ -131,13 +130,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
headless: cliOptions.headless, headless: cliOptions.headless,
}; };
if (browserName === 'chromium') { // --no-sandbox was passed, disable the sandbox
(launchOptions as any).cdpPort = await findFreePort(); if (!cliOptions.sandbox)
if (!cliOptions.sandbox) { launchOptions.chromiumSandbox = false;
// --no-sandbox was passed, disable the sandbox
launchOptions.chromiumSandbox = false;
}
}
if (cliOptions.proxyServer) { if (cliOptions.proxyServer) {
launchOptions.proxy = { launchOptions.proxy = {
@@ -174,6 +169,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
const result: Config = { const result: Config = {
browser: { browser: {
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
browserName, browserName,
isolated: cliOptions.isolated, isolated: cliOptions.isolated,
userDataDir: cliOptions.userDataDir, userDataDir: cliOptions.userDataDir,
@@ -193,27 +189,12 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
}, },
saveTrace: cliOptions.saveTrace, 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;
} }
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
async function loadConfig(configFile: string | undefined): Promise<Config> { async function loadConfig(configFile: string | undefined): Promise<Config> {
if (!configFile) if (!configFile)
return {}; return {};
@@ -239,6 +220,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
function mergeConfig(base: FullConfig, overrides: Config): FullConfig { function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: FullConfig['browser'] = { const browser: FullConfig['browser'] = {
...pickDefined(base.browser),
...pickDefined(overrides.browser),
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium', browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false, isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: { launchOptions: {
@@ -250,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
...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)
@@ -265,6 +245,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
network: { network: {
...pickDefined(base.network), ...pickDefined(base.network),
...pickDefined(overrides.network), ...pickDefined(overrides.network),
} },
server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
},
} as FullConfig; } 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 { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { FullConfig } from './config.js'; import { FullConfig } from './config.js';
export async function createConnection(config: FullConfig): 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: FullConfig): 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,11 +14,7 @@
* 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 { callOnPageNoTrace, waitForCompletion } from './tools/utils.js'; import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
@@ -29,29 +25,39 @@ 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 { FullConfig } from './config.js'; import type { FullConfig } 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: FullConfig; 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: FullConfig) { 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[] {
@@ -83,7 +89,7 @@ export class Context {
currentTabOrDie(): Tab { currentTabOrDie(): Tab {
if (!this._currentTab) if (!this._currentTab)
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.'); throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
return this._currentTab; return this._currentTab;
} }
@@ -288,15 +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 }) => {
if (this.config.saveTrace) if (this.config.saveTrace)
await browserContext.tracing.stop(); await browserContext.tracing.stop();
await browserContext.close().then(async () => { await close();
await browser?.close();
}).catch(() => {});
}); });
} }
@@ -324,8 +330,10 @@ ${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);
@@ -338,75 +346,6 @@ ${code.join('\n')}
sources: false, sources: false,
}); });
} }
return { browser, browserContext }; return result;
}
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: FullConfig['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: FullConfig['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: FullConfig['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;
}
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

37
src/fileUtils.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'node:os';
import path from 'node:path';
import type { FullConfig } from './config.js';
export function cacheDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
return path.join(cacheDirectory, 'ms-playwright');
}
export async function userDataDir(browserConfig: FullConfig['browser']) {
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
}

232
src/httpServer.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs';
import path from 'path';
import http from 'http';
import net from 'net';
import mime from 'mime';
import { ManualPromise } from './manualPromise.js';
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
export type Transport = {
sendEvent?: (method: string, params: any) => void;
close?: () => void;
onconnect: () => void;
dispatch: (method: string, params: any) => Promise<any>;
onclose: () => void;
};
export class HttpServer {
private _server: http.Server;
private _urlPrefixPrecise: string = '';
private _urlPrefixHumanReadable: string = '';
private _port: number = 0;
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._server = http.createServer(this._onRequest.bind(this));
decorateServer(this._server);
}
server() {
return this._server;
}
routePrefix(prefix: string, handler: ServerRouteHandler) {
this._routes.push({ prefix, handler });
}
routePath(path: string, handler: ServerRouteHandler) {
this._routes.push({ exact: path, handler });
}
port(): number {
return this._port;
}
private async _tryStart(port: number | undefined, host: string) {
const errorPromise = new ManualPromise();
const errorListener = (error: Error) => errorPromise.reject(error);
this._server.on('error', errorListener);
try {
this._server.listen(port, host);
await Promise.race([
new Promise(cb => this._server!.once('listening', cb)),
errorPromise,
]);
} finally {
this._server.removeListener('error', errorListener);
}
}
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
const host = options.host || 'localhost';
if (options.preferredPort) {
try {
await this._tryStart(options.preferredPort, host);
} catch (e: any) {
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
throw e;
await this._tryStart(undefined, host);
}
} else {
await this._tryStart(options.port, host);
}
const address = this._server.address();
if (typeof address === 'string') {
this._urlPrefixPrecise = address;
this._urlPrefixHumanReadable = address;
} else {
this._port = address!.port;
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
}
}
async stop() {
await new Promise(cb => this._server!.close(cb));
}
urlPrefix(purpose: 'human-readable' | 'precise'): string {
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
}
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
try {
for (const [name, value] of Object.entries(headers || {}))
response.setHeader(name, value);
if (request.headers.range)
this._serveRangeFile(request, response, absoluteFilePath);
else
this._serveFile(response, absoluteFilePath);
return true;
} catch (e) {
return false;
}
}
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
const content = fs.readFileSync(absoluteFilePath);
response.statusCode = 200;
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Length', content.byteLength);
response.end(content);
}
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
const range = request.headers.range;
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
response.statusCode = 400;
return response.end('Bad request');
}
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
let start: number;
let end: number;
const size = fs.statSync(absoluteFilePath).size;
if (startStr !== '' && endStr === '') {
// No end specified: use the whole file
start = +startStr;
end = size - 1;
} else if (startStr === '' && endStr !== '') {
// No start specified: calculate start manually
start = size - +endStr;
end = size - 1;
} else {
start = +startStr;
end = +endStr;
}
// Handle unavailable range request
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
response.writeHead(416, {
'Content-Range': `bytes */${size}`
});
return response.end();
}
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
response.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
});
const readable = fs.createReadStream(absoluteFilePath, { start, end });
readable.pipe(response);
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
}
request.on('error', () => response.end());
try {
if (!request.url) {
response.end();
return;
}
const url = new URL('http://localhost' + request.url);
for (const route of this._routes) {
if (route.exact && url.pathname === route.exact) {
route.handler(request, response);
return;
}
if (route.prefix && url.pathname.startsWith(route.prefix)) {
route.handler(request, response);
return;
}
}
response.statusCode = 404;
response.end();
} catch (e) {
response.end();
}
}
}
function decorateServer(server: net.Server) {
const sockets = new Set<net.Socket>();
server.on('connection', socket => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
const close = server.close;
server.close = (callback?: (err?: Error) => void) => {
for (const socket of sockets)
socket.destroy();
sockets.clear();
return close.call(server, callback);
};
}

View File

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

@@ -40,9 +40,6 @@ export class PageSnapshot {
} }
private async _build() { private async _build() {
// FIXME: Rountrip evaluate to ensure _snapshotForAI works.
// This probably broke once we moved off locator snapshots
await this._page.evaluate(() => 1);
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI()); const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [ this._text = [
`- Page Snapshot`, `- Page Snapshot`,
@@ -52,7 +49,7 @@ export class PageSnapshot {
].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,14 +15,13 @@
*/ */
import { program } from 'commander'; import { program } from 'commander';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveCLIConfig } from './config.js';
// @ts-ignore // @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server'; import { startTraceViewerServer } from 'playwright-core/lib/server';
import type { Connection } from './connection.js'; import { startHttpTransport, startStdioTransport } from './transport.js';
import { packageJSON } from './context.js'; import { resolveCLIConfig } from './config.js';
import { Server } from './server.js';
import { packageJSON } from './package.js';
program program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
@@ -31,6 +30,7 @@ program
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers') .option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.') .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--config <path>', 'path to the configuration file.') .option('--config <path>', 'path to the configuration file.')
@@ -40,7 +40,7 @@ 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.')
@@ -54,13 +54,13 @@ program
.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 resolveCLIConfig(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);
if (config.saveTrace) { if (config.saveTrace) {
const server = await startTraceViewerServer(); const server = await startTraceViewerServer();
@@ -71,21 +71,8 @@ program
} }
}); });
function setupExitWatchdog(connectionList: Connection[]) {
const handleExit = async () => {
setTimeout(() => process.exit(0), 15000);
for (const connection of connectionList)
await connection.close();
process.exit(0);
};
process.stdin.on('close', handleExit);
process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit);
}
function semicolonSeparatedList(value: string): string[] { function semicolonSeparatedList(value: string): string[] {
return value.split(';').map(v => v.trim()); return value.split(';').map(v => v.trim());
} }
program.parse(process.argv); void program.parseAsync(process.argv);

59
src/server.ts Normal file
View File

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

View File

@@ -83,11 +83,10 @@ export class Tab {
|| e.message.includes('Download is starting'); // firefox + webkit || e.message.includes('Download is starting'); // firefox + webkit
if (!mightBeDownload) if (!mightBeDownload)
throw e; throw e;
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
const download = await Promise.race([ const download = await Promise.race([
downloadEvent, downloadEvent,
new Promise(resolve => setTimeout(resolve, 500)), new Promise(resolve => setTimeout(resolve, 1000)),
]); ]);
if (!download) if (!download)
throw e; throw e;

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

@@ -78,9 +78,9 @@ 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)._frame._wrapApiCall(() => (locator as any)._generateLocatorString(), true); return (locator as any)._generateLocatorString();
} }
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> { export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), true); 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 { Connection } from './connection.js'; export async function startStdioTransport(server: Server) {
import type { FullConfig } from './config.js'; await server.createConnection(new StdioServerTransport());
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
} }
async function handleSSE(config: FullConfig, 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: FullConfig, req: http.IncomingMessage, res: htt
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
const transport = new SSEServerTransport('/sse', res); const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport); sessions.set(transport.sessionId, transport);
const connection = await createConnection(config); testDebug(`create SSE session: ${transport.sessionId}`);
await connection.connect(transport); const connection = await server.createConnection(transport);
connectionList.push(connection);
res.on('close', () => { res.on('close', () => {
testDebug(`delete SSE session: ${transport.sessionId}`);
sessions.delete(transport.sessionId); sessions.delete(transport.sessionId);
connection.close().catch(e => { // eslint-disable-next-line no-console
// eslint-disable-next-line no-console void connection.close().catch(e => console.error(e));
console.error(e);
});
}); });
return; return;
} }
@@ -68,7 +64,7 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
res.end('Method not allowed'); res.end('Method not allowed');
} }
async function handleStreamable(config: FullConfig, 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: FullConfig, req: http.IncomingMessage, r
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: FullConfig, req: http.IncomingMessage, r
res.end('Invalid request'); res.end('Invalid request');
} }
export function startHttpTransport(config: FullConfig, 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;

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpServer, startClient, server }) => { test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start(); await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); 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 },
@@ -27,7 +27,7 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => { test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start(); const browserContext = await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [page] = browserContext.pages(); const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD); await page.goto(server.HELLO_WORLD);
@@ -38,7 +38,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
element: 'Hello, world!', element: 'Hello, world!',
ref: 'f0', ref: 'f0',
}, },
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`); })).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_snapshot', name: 'browser_snapshot',
@@ -58,7 +58,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
}); });
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => { test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
server.setContent('/', ` server.setContent('/', `
<title>Title</title> <title>Title</title>

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,13 +121,12 @@ 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, localOutputPath, mcpBrowser, server }) => { test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({ config: { outputDir: testInfo.outputPath('output') },
args: ['--output-dir', outputDir],
}); });
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');

View File

@@ -40,13 +40,12 @@ type CDPServer = {
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;
cdpServer: CDPServer; cdpServer: CDPServer;
server: TestServer; server: TestServer;
httpsServer: TestServer; httpsServer: TestServer;
mcpHeadless: boolean; mcpHeadless: boolean;
localOutputPath: (filePath: string) => string;
}; };
type WorkerFixtures = { type WorkerFixtures = {
@@ -56,20 +55,24 @@ 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) => {
const userDataDir = testInfo.outputPath('user-data-dir'); const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
const configDir = path.dirname(test.info().config.configFile!); const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined; let client: Client | undefined;
await use(async options => { await use(async options => {
const args = ['--user-data-dir', path.relative(configDir, userDataDir)]; const args: string[] = [];
if (userDataDir)
args.push('--user-data-dir', userDataDir);
if (process.env.CI && process.platform === 'linux') if (process.env.CI && process.platform === 'linux')
args.push('--no-sandbox'); args.push('--no-sandbox');
if (mcpHeadless) if (mcpHeadless)
@@ -86,9 +89,15 @@ 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 => {
if (process.env.PWMCP_DEBUG)
process.stderr.write(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();
@@ -129,13 +138,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);
@@ -176,7 +178,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',
},
}); });
} }
@@ -233,3 +241,7 @@ export const expect = baseExpect.extend({
}; };
}, },
}); });
export function formatOutput(output: string): string[] {
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
}

View File

@@ -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,10 +30,9 @@ 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 ({ startClient, mcpBrowser, server, localOutputPath }) => { test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({ config: { outputDir: testInfo.outputPath('output') },
config: { outputDir },
}); });
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.');
@@ -49,10 +48,10 @@ test('save as pdf', async ({ startClient, mcpBrowser, server, localOutputPath })
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 },
}); });

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,10 +18,9 @@ import fs from 'fs';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ startClient, server, localOutputPath }) => { test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({ config: { outputDir: testInfo.outputPath('output') },
args: ['--output-dir', outputDir],
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
@@ -45,10 +44,9 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server, localOu
}); });
}); });
test('browser_take_screenshot (element)', async ({ startClient, server, localOutputPath }) => { test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const { client } = await startClient({
const client = await startClient({ config: { outputDir: testInfo.outputPath('output') },
args: ['--output-dir', outputDir],
}); });
expect(await client.callTool({ expect(await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
@@ -76,10 +74,10 @@ test('browser_take_screenshot (element)', async ({ startClient, server, localOut
}); });
}); });
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',
@@ -97,10 +95,10 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
}); });
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({
@@ -138,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({
@@ -174,11 +172,12 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
expect(files[0]).toMatch(/^output\.jpeg$/); expect(files[0]).toMatch(/^output\.jpeg$/);
}); });
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server, localOutputPath }) => { 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: {
outputDir: localOutputPath('output'), outputDir,
noImageResponses: true, imageResponses: 'omit',
}, },
}); });
@@ -203,9 +202,10 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server,
}); });
}); });
test('browser_take_screenshot (cursor)', async ({ startClient, server, localOutputPath }) => { test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
clientName: 'cursor:vscode', clientName: 'cursor:vscode',
config: { outputDir }, config: { outputDir },
}); });

View File

@@ -14,96 +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');
await use(async (options?: { args?: string[], noPort?: boolean }) => {
if (cp)
throw new Error('Process already running');
cp = spawn('node', [
path.join(path.dirname(__filename), '../cli.js'),
...(options?.noPort ? [] : ['--port=0']),
'--user-data-dir=' + userDataDir,
...(mcpHeadless ? ['--headless'] : []),
...(options?.args || []),
], {
stdio: 'pipe',
env: {
...process.env,
DEBUG: 'pw:mcp:test',
DEBUG_COLORS: '0',
DEBUG_HIDE_DATE: '1',
},
});
let stderr = ''; let stderr = '';
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => { const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
stderr += data.toString(); stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/); 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, localOutputPath }) => {
const userDataDir = localOutputPath('user-data-dir');
const sessions = new Map<string, SSEServerTransport>();
const mcpServer = http.createServer(async (req, res) => {
if (req.method === 'GET') {
const connection = await createConnection({
browser: {
userDataDir,
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

@@ -141,7 +141,7 @@ test('reuse first tab when navigating', async ({ startClient, cdpServer, server
const browserContext = await cdpServer.start(); const browserContext = await cdpServer.start();
const pages = browserContext.pages(); const pages = browserContext.pages();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] }); const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
await client.callTool({ await client.callTool({
name: 'browser_navigate', name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD }, arguments: { url: server.HELLO_WORLD },

View File

@@ -19,9 +19,10 @@ import path from 'path';
import { test, expect } from './fixtures.js'; import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server, localOutputPath }) => { test('check that trace is saved', async ({ startClient, server }, testInfo) => {
const outputDir = localOutputPath('output'); const outputDir = testInfo.outputPath('output');
const client = await startClient({
const { client } = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`], args: ['--save-trace', `--output-dir=${outputDir}`],
}); });