This is page 2 of 3. Use http://codebase.md/cloudflare/playwright-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── cf_ci.yml
│ ├── cf_publish_release_npm.yml
│ ├── ci.yml
│ └── publish.yml
├── .gitignore
├── .npmignore
├── cli.js
├── cloudflare
│ ├── .npmignore
│ ├── example
│ │ ├── .gitignore
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ ├── worker-configuration.d.ts
│ │ └── wrangler.toml
│ ├── index.d.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ └── package.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── config.d.ts
├── Dockerfile
├── docs
│ └── imgs
│ ├── claudemcp.gif
│ ├── playground-ai-screenshot.png
│ ├── todomvc-screenshot-1.png
│ ├── todomvc-screenshot-2.png
│ └── todomvc-screenshot-3.png
├── eslint.config.mjs
├── examples
│ └── generate-test.md
├── index.d.ts
├── index.js
├── LICENSE
├── package-lock.json
├── package.json
├── playwright.config.ts
├── README.md
├── SECURITY.md
├── src
│ ├── browserContextFactory.ts
│ ├── browserServer.ts
│ ├── config.ts
│ ├── connection.ts
│ ├── context.ts
│ ├── fileUtils.ts
│ ├── httpServer.ts
│ ├── index.ts
│ ├── javascript.ts
│ ├── manualPromise.ts
│ ├── package.ts
│ ├── pageSnapshot.ts
│ ├── program.ts
│ ├── server.ts
│ ├── tab.ts
│ ├── tools
│ │ ├── common.ts
│ │ ├── console.ts
│ │ ├── dialogs.ts
│ │ ├── files.ts
│ │ ├── install.ts
│ │ ├── keyboard.ts
│ │ ├── navigate.ts
│ │ ├── network.ts
│ │ ├── pdf.ts
│ │ ├── screenshot.ts
│ │ ├── snapshot.ts
│ │ ├── tabs.ts
│ │ ├── testing.ts
│ │ ├── tool.ts
│ │ ├── utils.ts
│ │ ├── vision.ts
│ │ └── wait.ts
│ ├── tools.ts
│ └── transport.ts
├── tests
│ ├── browser-server.spec.ts
│ ├── capabilities.spec.ts
│ ├── cdp.spec.ts
│ ├── config.spec.ts
│ ├── console.spec.ts
│ ├── core.spec.ts
│ ├── device.spec.ts
│ ├── dialogs.spec.ts
│ ├── files.spec.ts
│ ├── fixtures.ts
│ ├── headed.spec.ts
│ ├── iframes.spec.ts
│ ├── install.spec.ts
│ ├── launch.spec.ts
│ ├── library.spec.ts
│ ├── network.spec.ts
│ ├── pdf.spec.ts
│ ├── request-blocking.spec.ts
│ ├── screenshot.spec.ts
│ ├── sse.spec.ts
│ ├── tabs.spec.ts
│ ├── testserver
│ │ ├── cert.pem
│ │ ├── index.ts
│ │ ├── key.pem
│ │ └── san.cnf
│ ├── trace.spec.ts
│ ├── wait.spec.ts
│ └── webdriver.spec.ts
├── tsconfig.all.json
├── tsconfig.json
└── utils
├── copyright.js
├── generate-links.js
└── update-readme.js
```
# Files
--------------------------------------------------------------------------------
/tests/tabs.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test, expect } from './fixtures.js';
18 |
19 | import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
20 |
21 | async function createTab(client: Client, title: string, body: string) {
22 | return await client.callTool({
23 | name: 'browser_tab_new',
24 | arguments: {
25 | url: `data:text/html,<title>${title}</title><body>${body}</body>`,
26 | },
27 | });
28 | }
29 |
30 | test('list initial tabs', async ({ client }) => {
31 | expect(await client.callTool({
32 | name: 'browser_tab_list',
33 | })).toHaveTextContent(`### Open tabs
34 | - 1: (current) [] (about:blank)`);
35 | });
36 |
37 | test('list first tab', async ({ client }) => {
38 | await createTab(client, 'Tab one', 'Body one');
39 | expect(await client.callTool({
40 | name: 'browser_tab_list',
41 | })).toHaveTextContent(`### Open tabs
42 | - 1: [] (about:blank)
43 | - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
44 | });
45 |
46 | test('create new tab', async ({ client }) => {
47 | expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
48 | - Ran Playwright code:
49 | \`\`\`js
50 | // <internal code to open a new tab>
51 | \`\`\`
52 |
53 | ### Open tabs
54 | - 1: [] (about:blank)
55 | - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
56 |
57 | ### Current tab
58 | - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
59 | - Page Title: Tab one
60 | - Page Snapshot
61 | \`\`\`yaml
62 | - generic [active] [ref=e1]: Body one
63 | \`\`\``);
64 |
65 | expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
66 | - Ran Playwright code:
67 | \`\`\`js
68 | // <internal code to open a new tab>
69 | \`\`\`
70 |
71 | ### Open tabs
72 | - 1: [] (about:blank)
73 | - 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
74 | - 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
75 |
76 | ### Current tab
77 | - Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
78 | - Page Title: Tab two
79 | - Page Snapshot
80 | \`\`\`yaml
81 | - generic [active] [ref=e1]: Body two
82 | \`\`\``);
83 | });
84 |
85 | test('select tab', async ({ client }) => {
86 | await createTab(client, 'Tab one', 'Body one');
87 | await createTab(client, 'Tab two', 'Body two');
88 | expect(await client.callTool({
89 | name: 'browser_tab_select',
90 | arguments: {
91 | index: 2,
92 | },
93 | })).toHaveTextContent(`
94 | - Ran Playwright code:
95 | \`\`\`js
96 | // <internal code to select tab 2>
97 | \`\`\`
98 |
99 | ### Open tabs
100 | - 1: [] (about:blank)
101 | - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
102 | - 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
103 |
104 | ### Current tab
105 | - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
106 | - Page Title: Tab one
107 | - Page Snapshot
108 | \`\`\`yaml
109 | - generic [active] [ref=e1]: Body one
110 | \`\`\``);
111 | });
112 |
113 | test('close tab', async ({ client }) => {
114 | await createTab(client, 'Tab one', 'Body one');
115 | await createTab(client, 'Tab two', 'Body two');
116 | expect(await client.callTool({
117 | name: 'browser_tab_close',
118 | arguments: {
119 | index: 3,
120 | },
121 | })).toHaveTextContent(`
122 | - Ran Playwright code:
123 | \`\`\`js
124 | // <internal code to close tab 3>
125 | \`\`\`
126 |
127 | ### Open tabs
128 | - 1: [] (about:blank)
129 | - 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
130 |
131 | ### Current tab
132 | - Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
133 | - Page Title: Tab one
134 | - Page Snapshot
135 | \`\`\`yaml
136 | - generic [active] [ref=e1]: Body one
137 | \`\`\``);
138 | });
139 |
140 | test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
141 | const browserContext = await cdpServer.start();
142 | const pages = browserContext.pages();
143 |
144 | const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
145 | await client.callTool({
146 | name: 'browser_navigate',
147 | arguments: { url: server.HELLO_WORLD },
148 | });
149 |
150 | expect(pages.length).toBe(1);
151 | expect(await pages[0].title()).toBe('Title');
152 | });
153 |
```
--------------------------------------------------------------------------------
/tests/launch.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 |
19 | import { test, expect, formatOutput } from './fixtures.js';
20 |
21 | test('test reopen browser', async ({ startClient, server, mcpMode }) => {
22 | const { client, stderr } = await startClient();
23 | await client.callTool({
24 | name: 'browser_navigate',
25 | arguments: { url: server.HELLO_WORLD },
26 | });
27 |
28 | expect(await client.callTool({
29 | name: 'browser_close',
30 | })).toContainTextContent('No open pages available');
31 |
32 | expect(await client.callTool({
33 | name: 'browser_navigate',
34 | arguments: { url: server.HELLO_WORLD },
35 | })).toContainTextContent(`- generic [active] [ref=e1]: Hello, world!`);
36 |
37 | await client.close();
38 |
39 | if (process.platform === 'win32')
40 | return;
41 |
42 | await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
43 | 'create context',
44 | 'create browser context (persistent)',
45 | 'lock user data dir',
46 | 'close context',
47 | 'close browser context (persistent)',
48 | 'release user data dir',
49 | 'close browser context complete (persistent)',
50 | 'create browser context (persistent)',
51 | 'lock user data dir',
52 | 'close context',
53 | 'close browser context (persistent)',
54 | 'release user data dir',
55 | 'close browser context complete (persistent)',
56 | ]);
57 | });
58 |
59 | test('executable path', async ({ startClient, server }) => {
60 | const { client } = await startClient({ args: [`--executable-path=bogus`] });
61 | const response = await client.callTool({
62 | name: 'browser_navigate',
63 | arguments: { url: server.HELLO_WORLD },
64 | });
65 | expect(response).toContainTextContent(`executable doesn't exist`);
66 | });
67 |
68 | test('persistent context', async ({ startClient, server }) => {
69 | server.setContent('/', `
70 | <body>
71 | </body>
72 | <script>
73 | document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
74 | localStorage.setItem('test', 'test');
75 | </script>
76 | `, 'text/html');
77 |
78 | const { client } = await startClient();
79 | const response = await client.callTool({
80 | name: 'browser_navigate',
81 | arguments: { url: server.PREFIX },
82 | });
83 | expect(response).toContainTextContent(`Storage: NO`);
84 |
85 | await new Promise(resolve => setTimeout(resolve, 3000));
86 |
87 | await client.callTool({
88 | name: 'browser_close',
89 | });
90 |
91 | const { client: client2 } = await startClient();
92 | const response2 = await client2.callTool({
93 | name: 'browser_navigate',
94 | arguments: { url: server.PREFIX },
95 | });
96 |
97 | expect(response2).toContainTextContent(`Storage: YES`);
98 | });
99 |
100 | test('isolated context', async ({ startClient, server }) => {
101 | server.setContent('/', `
102 | <body>
103 | </body>
104 | <script>
105 | document.body.textContent = localStorage.getItem('test') ? 'Storage: YES' : 'Storage: NO';
106 | localStorage.setItem('test', 'test');
107 | </script>
108 | `, 'text/html');
109 |
110 | const { client: client1 } = await startClient({ args: [`--isolated`] });
111 | const response = await client1.callTool({
112 | name: 'browser_navigate',
113 | arguments: { url: server.PREFIX },
114 | });
115 | expect(response).toContainTextContent(`Storage: NO`);
116 |
117 | await client1.callTool({
118 | name: 'browser_close',
119 | });
120 |
121 | const { client: client2 } = await startClient({ args: [`--isolated`] });
122 | const response2 = await client2.callTool({
123 | name: 'browser_navigate',
124 | arguments: { url: server.PREFIX },
125 | });
126 | expect(response2).toContainTextContent(`Storage: NO`);
127 | });
128 |
129 | test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
130 | const storageStatePath = testInfo.outputPath('storage-state.json');
131 | await fs.promises.writeFile(storageStatePath, JSON.stringify({
132 | origins: [
133 | {
134 | origin: server.PREFIX,
135 | localStorage: [{ name: 'test', value: 'session-value' }],
136 | },
137 | ],
138 | }));
139 |
140 | server.setContent('/', `
141 | <body>
142 | </body>
143 | <script>
144 | document.body.textContent = 'Storage: ' + localStorage.getItem('test');
145 | </script>
146 | `, 'text/html');
147 |
148 | const { client } = await startClient({ args: [
149 | `--isolated`,
150 | `--storage-state=${storageStatePath}`,
151 | ] });
152 | const response = await client.callTool({
153 | name: 'browser_navigate',
154 | arguments: { url: server.PREFIX },
155 | });
156 | expect(response).toContainTextContent(`Storage: session-value`);
157 | });
158 |
```
--------------------------------------------------------------------------------
/src/program.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { program } from 'commander';
18 | // @ts-ignore
19 | import { startTraceViewerServer } from 'playwright-core/lib/server';
20 |
21 | import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
22 | import { resolveCLIConfig } from './config.js';
23 | import { Server } from './server.js';
24 | import { packageJSON } from './package.js';
25 |
26 | program
27 | .version('Version ' + packageJSON.version)
28 | .name(packageJSON.name)
29 | .option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
30 | .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)
31 | .option('--block-service-workers', 'block service workers')
32 | .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
33 | .option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
34 | .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
35 | .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
36 | .option('--config <path>', 'path to the configuration file.')
37 | .option('--device <device>', 'device to emulate, for example: "iPhone 15"')
38 | .option('--executable-path <path>', 'path to the browser executable.')
39 | .option('--headless', 'run browser in headless mode, headed by default')
40 | .option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
41 | .option('--ignore-https-errors', 'ignore https errors')
42 | .option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
43 | .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.')
44 | .option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
45 | .option('--output-dir <path>', 'path to the directory for output files.')
46 | .option('--port <port>', 'port to listen on for SSE transport.')
47 | .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
48 | .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
49 | .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
50 | .option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
51 | .option('--user-agent <ua string>', 'specify user agent string')
52 | .option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
53 | .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
54 | .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
55 | .action(async options => {
56 | const config = await resolveCLIConfig(options);
57 | const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
58 |
59 | const server = new Server(config);
60 | server.setupExitWatchdog();
61 |
62 | if (httpServer)
63 | startHttpTransport(httpServer, server);
64 | else
65 | await startStdioTransport(server);
66 |
67 | if (config.saveTrace) {
68 | const server = await startTraceViewerServer();
69 | const urlPrefix = server.urlPrefix('human-readable');
70 | const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
71 | // eslint-disable-next-line no-console
72 | console.error('\nTrace viewer listening on ' + url);
73 | }
74 | });
75 |
76 | function semicolonSeparatedList(value: string): string[] {
77 | return value.split(';').map(v => v.trim());
78 | }
79 |
80 | void program.parseAsync(process.argv);
81 |
```
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
```
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
18 | import tsParser from "@typescript-eslint/parser";
19 | import notice from "eslint-plugin-notice";
20 | import path from "path";
21 | import { fileURLToPath } from "url";
22 | import stylistic from "@stylistic/eslint-plugin";
23 | import importRules from "eslint-plugin-import";
24 |
25 | const __filename = fileURLToPath(import.meta.url);
26 | const __dirname = path.dirname(__filename);
27 |
28 | const plugins = {
29 | "@stylistic": stylistic,
30 | "@typescript-eslint": typescriptEslint,
31 | notice,
32 | import: importRules,
33 | };
34 |
35 | export const baseRules = {
36 | "import/extensions": ["error", "ignorePackages", {ts: "always"}],
37 | "@typescript-eslint/no-floating-promises": "error",
38 | "@typescript-eslint/no-unused-vars": [
39 | 2,
40 | { args: "none", caughtErrors: "none" },
41 | ],
42 |
43 | /**
44 | * Enforced rules
45 | */
46 | // syntax preferences
47 | "object-curly-spacing": ["error", "always"],
48 | quotes: [
49 | 2,
50 | "single",
51 | {
52 | avoidEscape: true,
53 | allowTemplateLiterals: true,
54 | },
55 | ],
56 | "jsx-quotes": [2, "prefer-single"],
57 | "no-extra-semi": 2,
58 | "@stylistic/semi": [2],
59 | "comma-style": [2, "last"],
60 | "wrap-iife": [2, "inside"],
61 | "spaced-comment": [
62 | 2,
63 | "always",
64 | {
65 | markers: ["*"],
66 | },
67 | ],
68 | eqeqeq: [2],
69 | "accessor-pairs": [
70 | 2,
71 | {
72 | getWithoutSet: false,
73 | setWithoutGet: false,
74 | },
75 | ],
76 | "brace-style": [2, "1tbs", { allowSingleLine: true }],
77 | curly: [2, "multi-or-nest", "consistent"],
78 | "new-parens": 2,
79 | "arrow-parens": [2, "as-needed"],
80 | "prefer-const": 2,
81 | "quote-props": [2, "consistent"],
82 | "nonblock-statement-body-position": [2, "below"],
83 |
84 | // anti-patterns
85 | "no-var": 2,
86 | "no-with": 2,
87 | "no-multi-str": 2,
88 | "no-caller": 2,
89 | "no-implied-eval": 2,
90 | "no-labels": 2,
91 | "no-new-object": 2,
92 | "no-octal-escape": 2,
93 | "no-self-compare": 2,
94 | "no-shadow-restricted-names": 2,
95 | "no-cond-assign": 2,
96 | "no-debugger": 2,
97 | "no-dupe-keys": 2,
98 | "no-duplicate-case": 2,
99 | "no-empty-character-class": 2,
100 | "no-unreachable": 2,
101 | "no-unsafe-negation": 2,
102 | radix: 2,
103 | "valid-typeof": 2,
104 | "no-implicit-globals": [2],
105 | "no-unused-expressions": [
106 | 2,
107 | { allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true },
108 | ],
109 | "no-proto": 2,
110 |
111 | // es2015 features
112 | "require-yield": 2,
113 | "template-curly-spacing": [2, "never"],
114 |
115 | // spacing details
116 | "space-infix-ops": 2,
117 | "space-in-parens": [2, "never"],
118 | "array-bracket-spacing": [2, "never"],
119 | "comma-spacing": [2, { before: false, after: true }],
120 | "keyword-spacing": [2, "always"],
121 | "space-before-function-paren": [
122 | 2,
123 | {
124 | anonymous: "never",
125 | named: "never",
126 | asyncArrow: "always",
127 | },
128 | ],
129 | "no-whitespace-before-property": 2,
130 | "keyword-spacing": [
131 | 2,
132 | {
133 | overrides: {
134 | if: { after: true },
135 | else: { after: true },
136 | for: { after: true },
137 | while: { after: true },
138 | do: { after: true },
139 | switch: { after: true },
140 | return: { after: true },
141 | },
142 | },
143 | ],
144 | "arrow-spacing": [
145 | 2,
146 | {
147 | after: true,
148 | before: true,
149 | },
150 | ],
151 | "@stylistic/func-call-spacing": 2,
152 | "@stylistic/type-annotation-spacing": 2,
153 |
154 | // file whitespace
155 | "no-multiple-empty-lines": [2, { max: 2, maxEOF: 0 }],
156 | "no-mixed-spaces-and-tabs": 2,
157 | "no-trailing-spaces": 2,
158 | "linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
159 | indent: [
160 | 2,
161 | 2,
162 | { SwitchCase: 1, CallExpression: { arguments: 2 }, MemberExpression: 2 },
163 | ],
164 | "key-spacing": [
165 | 2,
166 | {
167 | beforeColon: false,
168 | },
169 | ],
170 | "eol-last": 2,
171 |
172 | // copyright
173 | "notice/notice": [
174 | 2,
175 | {
176 | mustMatch: "Copyright",
177 | templateFile: path.join(__dirname, "utils", "copyright.js"),
178 | },
179 | ],
180 |
181 | // react
182 | "react/react-in-jsx-scope": 0,
183 | "no-console": 2,
184 | };
185 |
186 | const languageOptions = {
187 | parser: tsParser,
188 | ecmaVersion: 9,
189 | sourceType: "module",
190 | parserOptions: {
191 | project: path.join(fileURLToPath(import.meta.url), "..", "tsconfig.all.json"),
192 | }
193 | };
194 |
195 | export default [
196 | {
197 | ignores: ["**/*.js"],
198 | },
199 | {
200 | files: ["**/*.ts", "**/*.tsx"],
201 | plugins,
202 | languageOptions,
203 | rules: baseRules,
204 | },
205 | {
206 | files: ['cloudflare/**/*'],
207 | rules: {
208 | 'notice/notice': 'off',
209 | },
210 | },
211 | ];
212 |
```
--------------------------------------------------------------------------------
/src/transport.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import http from 'node:http';
18 | import assert from 'node:assert';
19 | import crypto from 'node:crypto';
20 |
21 | import debug from 'debug';
22 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
23 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
24 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25 |
26 | import type { AddressInfo } from 'node:net';
27 | import type { Server } from './server.js';
28 |
29 | export async function startStdioTransport(server: Server) {
30 | await server.createConnection(new StdioServerTransport());
31 | }
32 |
33 | const testDebug = debug('pw:mcp:test');
34 |
35 | async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
36 | if (req.method === 'POST') {
37 | const sessionId = url.searchParams.get('sessionId');
38 | if (!sessionId) {
39 | res.statusCode = 400;
40 | return res.end('Missing sessionId');
41 | }
42 |
43 | const transport = sessions.get(sessionId);
44 | if (!transport) {
45 | res.statusCode = 404;
46 | return res.end('Session not found');
47 | }
48 |
49 | return await transport.handlePostMessage(req, res);
50 | } else if (req.method === 'GET') {
51 | const transport = new SSEServerTransport('/sse', res);
52 | sessions.set(transport.sessionId, transport);
53 | testDebug(`create SSE session: ${transport.sessionId}`);
54 | const connection = await server.createConnection(transport);
55 | res.on('close', () => {
56 | testDebug(`delete SSE session: ${transport.sessionId}`);
57 | sessions.delete(transport.sessionId);
58 | // eslint-disable-next-line no-console
59 | void connection.close().catch(e => console.error(e));
60 | });
61 | return;
62 | }
63 |
64 | res.statusCode = 405;
65 | res.end('Method not allowed');
66 | }
67 |
68 | async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
69 | const sessionId = req.headers['mcp-session-id'] as string | undefined;
70 | if (sessionId) {
71 | const transport = sessions.get(sessionId);
72 | if (!transport) {
73 | res.statusCode = 404;
74 | res.end('Session not found');
75 | return;
76 | }
77 | return await transport.handleRequest(req, res);
78 | }
79 |
80 | if (req.method === 'POST') {
81 | const transport = new StreamableHTTPServerTransport({
82 | sessionIdGenerator: () => crypto.randomUUID(),
83 | onsessioninitialized: sessionId => {
84 | sessions.set(sessionId, transport);
85 | }
86 | });
87 | transport.onclose = () => {
88 | if (transport.sessionId)
89 | sessions.delete(transport.sessionId);
90 | };
91 | await server.createConnection(transport);
92 | await transport.handleRequest(req, res);
93 | return;
94 | }
95 |
96 | res.statusCode = 400;
97 | res.end('Invalid request');
98 | }
99 |
100 | export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
101 | const { host, port } = config;
102 | const httpServer = http.createServer();
103 | await new Promise<void>((resolve, reject) => {
104 | httpServer.on('error', reject);
105 | httpServer.listen(port, host, () => {
106 | resolve();
107 | httpServer.removeListener('error', reject);
108 | });
109 | });
110 | return httpServer;
111 | }
112 |
113 | export function startHttpTransport(httpServer: http.Server, mcpServer: Server) {
114 | const sseSessions = new Map<string, SSEServerTransport>();
115 | const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
116 | httpServer.on('request', async (req, res) => {
117 | const url = new URL(`http://localhost${req.url}`);
118 | if (url.pathname.startsWith('/mcp'))
119 | await handleStreamable(mcpServer, req, res, streamableSessions);
120 | else
121 | await handleSSE(mcpServer, req, res, url, sseSessions);
122 | });
123 | const url = httpAddressToString(httpServer.address());
124 | const message = [
125 | `Listening on ${url}`,
126 | 'Put this in your client config:',
127 | JSON.stringify({
128 | 'mcpServers': {
129 | 'playwright': {
130 | 'url': `${url}/sse`
131 | }
132 | }
133 | }, undefined, 2),
134 | 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
135 | ].join('\n');
136 | // eslint-disable-next-line no-console
137 | console.error(message);
138 | }
139 |
140 | export function httpAddressToString(address: string | AddressInfo | null): string {
141 | assert(address, 'Could not bind server socket');
142 | if (typeof address === 'string')
143 | return address;
144 | const resolvedPort = address.port;
145 | let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
146 | if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
147 | resolvedHost = 'localhost';
148 | return `http://${resolvedHost}:${resolvedPort}`;
149 | }
150 |
```
--------------------------------------------------------------------------------
/tests/testserver/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright 2017 Google Inc. All rights reserved.
3 | * Modifications copyright (c) Microsoft Corporation.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import fs from 'fs';
19 | import url from 'node:url';
20 | import http from 'http';
21 | import https from 'https';
22 | import path from 'path';
23 | import debug from 'debug';
24 |
25 | const fulfillSymbol = Symbol('fulfil callback');
26 | const rejectSymbol = Symbol('reject callback');
27 |
28 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
29 | const __filename = url.fileURLToPath(import.meta.url);
30 |
31 | export class TestServer {
32 | private _server: http.Server;
33 | readonly debugServer: any;
34 | private _routes = new Map<string, (request: http.IncomingMessage, response: http.ServerResponse) => any>();
35 | private _csp = new Map<string, string>();
36 | private _extraHeaders = new Map<string, object>();
37 | private _requestSubscribers = new Map<string, Promise<any>>();
38 | readonly PORT: number;
39 | readonly PREFIX: string;
40 | readonly CROSS_PROCESS_PREFIX: string;
41 | readonly HELLO_WORLD: string;
42 |
43 | static async create(port: number): Promise<TestServer> {
44 | const server = new TestServer(port);
45 | await new Promise(x => server._server.once('listening', x));
46 | return server;
47 | }
48 |
49 | static async createHTTPS(port: number): Promise<TestServer> {
50 | const server = new TestServer(port, {
51 | key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
52 | cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
53 | passphrase: 'aaaa',
54 | });
55 | await new Promise(x => server._server.once('listening', x));
56 | return server;
57 | }
58 |
59 | constructor(port: number, sslOptions?: object) {
60 | if (sslOptions)
61 | this._server = https.createServer(sslOptions, this._onRequest.bind(this));
62 | else
63 | this._server = http.createServer(this._onRequest.bind(this));
64 | this._server.listen(port);
65 | this.debugServer = debug('pw:testserver');
66 |
67 | const cross_origin = '127.0.0.1';
68 | const same_origin = 'localhost';
69 | const protocol = sslOptions ? 'https' : 'http';
70 | this.PORT = port;
71 | this.PREFIX = `${protocol}://${same_origin}:${port}/`;
72 | this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
73 | this.HELLO_WORLD = `${this.PREFIX}hello-world`;
74 | }
75 |
76 | setCSP(path: string, csp: string) {
77 | this._csp.set(path, csp);
78 | }
79 |
80 | setExtraHeaders(path: string, object: Record<string, string>) {
81 | this._extraHeaders.set(path, object);
82 | }
83 |
84 | async stop() {
85 | this.reset();
86 | await new Promise(x => this._server.close(x));
87 | }
88 |
89 | route(path: string, handler: (request: http.IncomingMessage, response: http.ServerResponse) => any) {
90 | this._routes.set(path, handler);
91 | }
92 |
93 | setContent(path: string, content: string, mimeType: string) {
94 | this.route(path, (req, res) => {
95 | res.writeHead(200, { 'Content-Type': mimeType });
96 | res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
97 | });
98 | }
99 |
100 | redirect(from: string, to: string) {
101 | this.route(from, (req, res) => {
102 | const headers = this._extraHeaders.get(req.url!) || {};
103 | res.writeHead(302, { ...headers, location: to });
104 | res.end();
105 | });
106 | }
107 |
108 | waitForRequest(path: string): Promise<http.IncomingMessage> {
109 | let promise = this._requestSubscribers.get(path);
110 | if (promise)
111 | return promise;
112 | let fulfill, reject;
113 | promise = new Promise((f, r) => {
114 | fulfill = f;
115 | reject = r;
116 | });
117 | promise[fulfillSymbol] = fulfill;
118 | promise[rejectSymbol] = reject;
119 | this._requestSubscribers.set(path, promise);
120 | return promise;
121 | }
122 |
123 | reset() {
124 | this._routes.clear();
125 | this._csp.clear();
126 | this._extraHeaders.clear();
127 | this._server.closeAllConnections();
128 | const error = new Error('Static Server has been reset');
129 | for (const subscriber of this._requestSubscribers.values())
130 | subscriber[rejectSymbol].call(null, error);
131 | this._requestSubscribers.clear();
132 |
133 | this.setContent('/favicon.ico', '', 'image/x-icon');
134 |
135 | this.setContent('/', ``, 'text/html');
136 |
137 | this.setContent('/hello-world', `
138 | <title>Title</title>
139 | <body>Hello, world!</body>
140 | `, 'text/html');
141 | }
142 |
143 | _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
144 | request.on('error', error => {
145 | if ((error as any).code === 'ECONNRESET')
146 | response.end();
147 | else
148 | throw error;
149 | });
150 | (request as any).postBody = new Promise(resolve => {
151 | const chunks: Buffer[] = [];
152 | request.on('data', chunk => {
153 | chunks.push(chunk);
154 | });
155 | request.on('end', () => resolve(Buffer.concat(chunks)));
156 | });
157 | const path = request.url || '/';
158 | this.debugServer(`request ${request.method} ${path}`);
159 | // Notify request subscriber.
160 | if (this._requestSubscribers.has(path)) {
161 | this._requestSubscribers.get(path)![fulfillSymbol].call(null, request);
162 | this._requestSubscribers.delete(path);
163 | }
164 | const handler = this._routes.get(path);
165 | if (handler) {
166 | handler.call(null, request, response);
167 | } else {
168 | response.writeHead(404);
169 | response.end();
170 | }
171 | }
172 | }
173 |
```
--------------------------------------------------------------------------------
/src/tools/vision.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { z } from 'zod';
18 | import { defineTool } from './tool.js';
19 |
20 | import * as javascript from '../javascript.js';
21 |
22 | const elementSchema = z.object({
23 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
24 | });
25 |
26 | const screenshot = defineTool({
27 | capability: 'core',
28 | schema: {
29 | name: 'browser_screen_capture',
30 | title: 'Take a screenshot',
31 | description: 'Take a screenshot of the current page',
32 | inputSchema: z.object({}),
33 | type: 'readOnly',
34 | },
35 |
36 | handle: async context => {
37 | const tab = await context.ensureTab();
38 | const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
39 |
40 | const code = [
41 | `// Take a screenshot of the current page`,
42 | `await page.screenshot(${javascript.formatObject(options)});`,
43 | ];
44 |
45 | const action = () => tab.page.screenshot(options).then(buffer => {
46 | return {
47 | content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
48 | };
49 | });
50 |
51 | return {
52 | code,
53 | action,
54 | captureSnapshot: false,
55 | waitForNetwork: false
56 | };
57 | },
58 | });
59 |
60 | const moveMouse = defineTool({
61 | capability: 'core',
62 | schema: {
63 | name: 'browser_screen_move_mouse',
64 | title: 'Move mouse',
65 | description: 'Move mouse to a given position',
66 | inputSchema: elementSchema.extend({
67 | x: z.coerce.number().describe('X coordinate'),
68 | y: z.coerce.number().describe('Y coordinate'),
69 | }),
70 | type: 'readOnly',
71 | },
72 |
73 | handle: async (context, params) => {
74 | const tab = context.currentTabOrDie();
75 | const code = [
76 | `// Move mouse to (${params.x}, ${params.y})`,
77 | `await page.mouse.move(${params.x}, ${params.y});`,
78 | ];
79 | const action = () => tab.page.mouse.move(params.x, params.y);
80 | return {
81 | code,
82 | action,
83 | captureSnapshot: false,
84 | waitForNetwork: false
85 | };
86 | },
87 | });
88 |
89 | const click = defineTool({
90 | capability: 'core',
91 | schema: {
92 | name: 'browser_screen_click',
93 | title: 'Click',
94 | description: 'Click left mouse button',
95 | inputSchema: elementSchema.extend({
96 | x: z.coerce.number().describe('X coordinate'),
97 | y: z.coerce.number().describe('Y coordinate'),
98 | }),
99 | type: 'destructive',
100 | },
101 |
102 | handle: async (context, params) => {
103 | const tab = context.currentTabOrDie();
104 | const code = [
105 | `// Click mouse at coordinates (${params.x}, ${params.y})`,
106 | `await page.mouse.move(${params.x}, ${params.y});`,
107 | `await page.mouse.down();`,
108 | `await page.mouse.up();`,
109 | ];
110 | const action = async () => {
111 | await tab.page.mouse.move(params.x, params.y);
112 | await tab.page.mouse.down();
113 | await tab.page.mouse.up();
114 | };
115 | return {
116 | code,
117 | action,
118 | captureSnapshot: false,
119 | waitForNetwork: true,
120 | };
121 | },
122 | });
123 |
124 | const drag = defineTool({
125 | capability: 'core',
126 | schema: {
127 | name: 'browser_screen_drag',
128 | title: 'Drag mouse',
129 | description: 'Drag left mouse button',
130 | inputSchema: elementSchema.extend({
131 | startX: z.coerce.number().describe('Start X coordinate'),
132 | startY: z.coerce.number().describe('Start Y coordinate'),
133 | endX: z.coerce.number().describe('End X coordinate'),
134 | endY: z.coerce.number().describe('End Y coordinate'),
135 | }),
136 | type: 'destructive',
137 | },
138 |
139 | handle: async (context, params) => {
140 | const tab = context.currentTabOrDie();
141 |
142 | const code = [
143 | `// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
144 | `await page.mouse.move(${params.startX}, ${params.startY});`,
145 | `await page.mouse.down();`,
146 | `await page.mouse.move(${params.endX}, ${params.endY});`,
147 | `await page.mouse.up();`,
148 | ];
149 |
150 | const action = async () => {
151 | await tab.page.mouse.move(params.startX, params.startY);
152 | await tab.page.mouse.down();
153 | await tab.page.mouse.move(params.endX, params.endY);
154 | await tab.page.mouse.up();
155 | };
156 |
157 | return {
158 | code,
159 | action,
160 | captureSnapshot: false,
161 | waitForNetwork: true,
162 | };
163 | },
164 | });
165 |
166 | const type = defineTool({
167 | capability: 'core',
168 | schema: {
169 | name: 'browser_screen_type',
170 | title: 'Type text',
171 | description: 'Type text',
172 | inputSchema: z.object({
173 | text: z.string().describe('Text to type into the element'),
174 | submit: z.coerce.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
175 | }),
176 | type: 'destructive',
177 | },
178 |
179 | handle: async (context, params) => {
180 | const tab = context.currentTabOrDie();
181 |
182 | const code = [
183 | `// Type ${params.text}`,
184 | `await page.keyboard.type('${params.text}');`,
185 | ];
186 |
187 | const action = async () => {
188 | await tab.page.keyboard.type(params.text);
189 | if (params.submit)
190 | await tab.page.keyboard.press('Enter');
191 | };
192 |
193 | if (params.submit) {
194 | code.push(`// Submit text`);
195 | code.push(`await page.keyboard.press('Enter');`);
196 | }
197 |
198 | return {
199 | code,
200 | action,
201 | captureSnapshot: false,
202 | waitForNetwork: true,
203 | };
204 | },
205 | });
206 |
207 | export default [
208 | screenshot,
209 | moveMouse,
210 | click,
211 | drag,
212 | type,
213 | ];
214 |
```
--------------------------------------------------------------------------------
/tests/dialogs.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test, expect } from './fixtures.js';
18 |
19 | // https://github.com/microsoft/playwright/issues/35663
20 | test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
21 |
22 | test('alert dialog', async ({ client, server }) => {
23 | server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
24 | expect(await client.callTool({
25 | name: 'browser_navigate',
26 | arguments: { url: server.PREFIX },
27 | })).toContainTextContent('- button "Button" [ref=e2]');
28 |
29 | expect(await client.callTool({
30 | name: 'browser_click',
31 | arguments: {
32 | element: 'Button',
33 | ref: 'e2',
34 | },
35 | })).toHaveTextContent(`- Ran Playwright code:
36 | \`\`\`js
37 | // Click Button
38 | await page.getByRole('button', { name: 'Button' }).click();
39 | \`\`\`
40 |
41 | ### Modal state
42 | - ["alert" dialog with message "Alert"]: can be handled by the "browser_handle_dialog" tool`);
43 |
44 | const result = await client.callTool({
45 | name: 'browser_handle_dialog',
46 | arguments: {
47 | accept: true,
48 | },
49 | });
50 |
51 | expect(result).not.toContainTextContent('### Modal state');
52 | expect(result).toHaveTextContent(`- Ran Playwright code:
53 | \`\`\`js
54 | // <internal code to handle "alert" dialog>
55 | \`\`\`
56 |
57 | - Page URL: ${server.PREFIX}
58 | - Page Title:
59 | - Page Snapshot
60 | \`\`\`yaml
61 | - button "Button" [active] [ref=e2]
62 | \`\`\`
63 | `);
64 | });
65 |
66 | test('two alert dialogs', async ({ client, server }) => {
67 | test.fixme(true, 'Race between the dialog and ariaSnapshot');
68 |
69 | server.setContent('/', `
70 | <title>Title</title>
71 | <body>
72 | <button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
73 | </body>
74 | `, 'text/html');
75 |
76 | expect(await client.callTool({
77 | name: 'browser_navigate',
78 | arguments: { url: server.PREFIX },
79 | })).toContainTextContent('- button "Button" [ref=e2]');
80 |
81 | expect(await client.callTool({
82 | name: 'browser_click',
83 | arguments: {
84 | element: 'Button',
85 | ref: 'e2',
86 | },
87 | })).toHaveTextContent(`- Ran Playwright code:
88 | \`\`\`js
89 | // Click Button
90 | await page.getByRole('button', { name: 'Button' }).click();
91 | \`\`\`
92 |
93 | ### Modal state
94 | - ["alert" dialog with message "Alert 1"]: can be handled by the "browser_handle_dialog" tool`);
95 |
96 | const result = await client.callTool({
97 | name: 'browser_handle_dialog',
98 | arguments: {
99 | accept: true,
100 | },
101 | });
102 |
103 | expect(result).not.toContainTextContent('### Modal state');
104 | });
105 |
106 | test('confirm dialog (true)', async ({ client, server }) => {
107 | server.setContent('/', `
108 | <title>Title</title>
109 | <body>
110 | <button onclick="document.body.textContent = confirm('Confirm')">Button</button>
111 | </body>
112 | `, 'text/html');
113 |
114 | expect(await client.callTool({
115 | name: 'browser_navigate',
116 | arguments: { url: server.PREFIX },
117 | })).toContainTextContent('- button "Button" [ref=e2]');
118 |
119 | expect(await client.callTool({
120 | name: 'browser_click',
121 | arguments: {
122 | element: 'Button',
123 | ref: 'e2',
124 | },
125 | })).toContainTextContent(`### Modal state
126 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
127 |
128 | const result = await client.callTool({
129 | name: 'browser_handle_dialog',
130 | arguments: {
131 | accept: true,
132 | },
133 | });
134 |
135 | expect(result).not.toContainTextContent('### Modal state');
136 | expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
137 | expect(result).toContainTextContent(`- Page Snapshot
138 | \`\`\`yaml
139 | - generic [active] [ref=e1]: "true"
140 | \`\`\``);
141 | });
142 |
143 | test('confirm dialog (false)', async ({ client, server }) => {
144 | server.setContent('/', `
145 | <title>Title</title>
146 | <body>
147 | <button onclick="document.body.textContent = confirm('Confirm')">Button</button>
148 | </body>
149 | `, 'text/html');
150 |
151 | expect(await client.callTool({
152 | name: 'browser_navigate',
153 | arguments: { url: server.PREFIX },
154 | })).toContainTextContent('- button "Button" [ref=e2]');
155 |
156 | expect(await client.callTool({
157 | name: 'browser_click',
158 | arguments: {
159 | element: 'Button',
160 | ref: 'e2',
161 | },
162 | })).toContainTextContent(`### Modal state
163 | - ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
164 |
165 | const result = await client.callTool({
166 | name: 'browser_handle_dialog',
167 | arguments: {
168 | accept: false,
169 | },
170 | });
171 |
172 | expect(result).toContainTextContent(`- Page Snapshot
173 | \`\`\`yaml
174 | - generic [active] [ref=e1]: "false"
175 | \`\`\``);
176 | });
177 |
178 | test('prompt dialog', async ({ client, server }) => {
179 | server.setContent('/', `
180 | <title>Title</title>
181 | <body>
182 | <button onclick="document.body.textContent = prompt('Prompt')">Button</button>
183 | </body>
184 | `, 'text/html');
185 |
186 | expect(await client.callTool({
187 | name: 'browser_navigate',
188 | arguments: { url: server.PREFIX },
189 | })).toContainTextContent('- button "Button" [ref=e2]');
190 |
191 | expect(await client.callTool({
192 | name: 'browser_click',
193 | arguments: {
194 | element: 'Button',
195 | ref: 'e2',
196 | },
197 | })).toContainTextContent(`### Modal state
198 | - ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
199 |
200 | const result = await client.callTool({
201 | name: 'browser_handle_dialog',
202 | arguments: {
203 | accept: true,
204 | promptText: 'Answer',
205 | },
206 | });
207 |
208 | expect(result).toContainTextContent(`- Page Snapshot
209 | \`\`\`yaml
210 | - generic [active] [ref=e1]: Answer
211 | \`\`\``);
212 | });
213 |
```
--------------------------------------------------------------------------------
/src/browserServer.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /* eslint-disable no-console */
18 |
19 | import net from 'net';
20 |
21 | import { program } from 'commander';
22 | import playwright from 'playwright';
23 |
24 | import { HttpServer } from './httpServer.js';
25 | import { packageJSON } from './package.js';
26 |
27 | import type http from 'http';
28 |
29 | export type LaunchBrowserRequest = {
30 | browserType: string;
31 | userDataDir: string;
32 | launchOptions: playwright.LaunchOptions;
33 | contextOptions: playwright.BrowserContextOptions;
34 | };
35 |
36 | export type BrowserInfo = {
37 | browserType: string;
38 | userDataDir: string;
39 | cdpPort: number;
40 | launchOptions: playwright.LaunchOptions;
41 | contextOptions: playwright.BrowserContextOptions;
42 | error?: string;
43 | };
44 |
45 | type BrowserEntry = {
46 | browser?: playwright.Browser;
47 | info: BrowserInfo;
48 | };
49 |
50 | class BrowserServer {
51 | private _server = new HttpServer();
52 | private _entries: BrowserEntry[] = [];
53 |
54 | constructor() {
55 | this._setupExitHandler();
56 | }
57 |
58 | async start(port: number) {
59 | await this._server.start({ port });
60 | this._server.routePath('/json/list', (req, res) => {
61 | this._handleJsonList(res);
62 | });
63 | this._server.routePath('/json/launch', async (req, res) => {
64 | void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
65 | });
66 | this._setEntries([]);
67 | }
68 |
69 | private _handleJsonList(res: http.ServerResponse) {
70 | const list = this._entries.map(browser => browser.info);
71 | res.end(JSON.stringify(list));
72 | }
73 |
74 | private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
75 | const request = await readBody<LaunchBrowserRequest>(req);
76 | let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
77 | if (!info || info.error)
78 | info = await this._newBrowser(request);
79 | res.end(JSON.stringify(info));
80 | }
81 |
82 | private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
83 | const cdpPort = await findFreePort();
84 | (request.launchOptions as any).cdpPort = cdpPort;
85 | const info: BrowserInfo = {
86 | browserType: request.browserType,
87 | userDataDir: request.userDataDir,
88 | cdpPort,
89 | launchOptions: request.launchOptions,
90 | contextOptions: request.contextOptions,
91 | };
92 |
93 | const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
94 | const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
95 | ...request.launchOptions,
96 | ...request.contextOptions,
97 | handleSIGINT: false,
98 | handleSIGTERM: false,
99 | }).then(context => {
100 | return { browser: context.browser()!, error: undefined };
101 | }).catch(error => {
102 | return { browser: undefined, error: error.message };
103 | });
104 | this._setEntries([...this._entries, {
105 | browser,
106 | info: {
107 | browserType: request.browserType,
108 | userDataDir: request.userDataDir,
109 | cdpPort,
110 | launchOptions: request.launchOptions,
111 | contextOptions: request.contextOptions,
112 | error,
113 | },
114 | }]);
115 | browser?.on('disconnected', () => {
116 | this._setEntries(this._entries.filter(entry => entry.browser !== browser));
117 | });
118 | return info;
119 | }
120 |
121 | private _updateReport() {
122 | // Clear the current line and move cursor to top of screen
123 | process.stdout.write('\x1b[2J\x1b[H');
124 | process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
125 | process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
126 |
127 | if (this._entries.length === 0) {
128 | process.stdout.write('No browsers currently running\n');
129 | return;
130 | }
131 |
132 | process.stdout.write('Running browsers:\n');
133 | for (const entry of this._entries) {
134 | const status = entry.browser ? 'running' : 'error';
135 | const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
136 | process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
137 | if (entry.info.error)
138 | process.stdout.write(` Error: ${entry.info.error}\n`);
139 | }
140 |
141 | }
142 |
143 | private _setEntries(entries: BrowserEntry[]) {
144 | this._entries = entries;
145 | this._updateReport();
146 | }
147 |
148 | private _setupExitHandler() {
149 | let isExiting = false;
150 | const handleExit = async () => {
151 | if (isExiting)
152 | return;
153 | isExiting = true;
154 | setTimeout(() => process.exit(0), 15000);
155 | for (const entry of this._entries)
156 | await entry.browser?.close().catch(() => {});
157 | process.exit(0);
158 | };
159 |
160 | process.stdin.on('close', handleExit);
161 | process.on('SIGINT', handleExit);
162 | process.on('SIGTERM', handleExit);
163 | }
164 | }
165 |
166 | program
167 | .name('browser-agent')
168 | .option('-p, --port <port>', 'Port to listen on', '9224')
169 | .action(async options => {
170 | await main(options);
171 | });
172 |
173 | void program.parseAsync(process.argv);
174 |
175 | async function main(options: { port: string }) {
176 | const server = new BrowserServer();
177 | await server.start(+options.port);
178 | }
179 |
180 | function readBody<T>(req: http.IncomingMessage): Promise<T> {
181 | return new Promise((resolve, reject) => {
182 | const chunks: Buffer[] = [];
183 | req.on('data', (chunk: Buffer) => chunks.push(chunk));
184 | req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
185 | });
186 | }
187 |
188 | async function findFreePort(): Promise<number> {
189 | return new Promise((resolve, reject) => {
190 | const server = net.createServer();
191 | server.listen(0, () => {
192 | const { port } = server.address() as net.AddressInfo;
193 | server.close(() => resolve(port));
194 | });
195 | server.on('error', reject);
196 | });
197 | }
198 |
```
--------------------------------------------------------------------------------
/utils/update-readme.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | /**
3 | * Copyright (c) Microsoft Corporation.
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 | // @ts-check
18 |
19 | import fs from 'node:fs'
20 | import path from 'node:path'
21 | import url from 'node:url'
22 | import zodToJsonSchema from 'zod-to-json-schema'
23 |
24 | import commonTools from '../lib/tools/common.js';
25 | import consoleTools from '../lib/tools/console.js';
26 | import dialogsTools from '../lib/tools/dialogs.js';
27 | import filesTools from '../lib/tools/files.js';
28 | import installTools from '../lib/tools/install.js';
29 | import keyboardTools from '../lib/tools/keyboard.js';
30 | import navigateTools from '../lib/tools/navigate.js';
31 | import networkTools from '../lib/tools/network.js';
32 | import pdfTools from '../lib/tools/pdf.js';
33 | import snapshotTools from '../lib/tools/snapshot.js';
34 | import tabsTools from '../lib/tools/tabs.js';
35 | import screenshotTools from '../lib/tools/screenshot.js';
36 | import testTools from '../lib/tools/testing.js';
37 | import visionTools from '../lib/tools/vision.js';
38 | import waitTools from '../lib/tools/wait.js';
39 | import { execSync } from 'node:child_process';
40 |
41 | const categories = {
42 | 'Interactions': [
43 | ...snapshotTools,
44 | ...keyboardTools(true),
45 | ...waitTools(true),
46 | ...filesTools(true),
47 | ...dialogsTools(true),
48 | ],
49 | 'Navigation': [
50 | ...navigateTools(true),
51 | ],
52 | 'Resources': [
53 | ...screenshotTools,
54 | ...pdfTools,
55 | ...networkTools,
56 | ...consoleTools,
57 | ],
58 | 'Utilities': [
59 | ...installTools,
60 | ...commonTools(true),
61 | ],
62 | 'Tabs': [
63 | ...tabsTools(true),
64 | ],
65 | 'Testing': [
66 | ...testTools,
67 | ],
68 | 'Vision mode': [
69 | ...visionTools,
70 | ...keyboardTools(),
71 | ...waitTools(false),
72 | ...filesTools(false),
73 | ...dialogsTools(false),
74 | ],
75 | };
76 |
77 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
78 | const __filename = url.fileURLToPath(import.meta.url);
79 |
80 | /**
81 | * @param {import('../src/tools/tool.js').ToolSchema<any>} tool
82 | * @returns {string[]}
83 | */
84 | function formatToolForReadme(tool) {
85 | const lines = /** @type {string[]} */ ([]);
86 | lines.push(`<!-- NOTE: This has been generated via ${path.basename(__filename)} -->`);
87 | lines.push(``);
88 | lines.push(`- **${tool.name}**`);
89 | lines.push(` - Title: ${tool.title}`);
90 | lines.push(` - Description: ${tool.description}`);
91 |
92 | const inputSchema = /** @type {any} */ (zodToJsonSchema(tool.inputSchema || {}));
93 | const requiredParams = inputSchema.required || [];
94 | if (inputSchema.properties && Object.keys(inputSchema.properties).length) {
95 | lines.push(` - Parameters:`);
96 | Object.entries(inputSchema.properties).forEach(([name, param]) => {
97 | const optional = !requiredParams.includes(name);
98 | const meta = /** @type {string[]} */ ([]);
99 | if (param.type)
100 | meta.push(param.type);
101 | if (optional)
102 | meta.push('optional');
103 | lines.push(` - \`${name}\` ${meta.length ? `(${meta.join(', ')})` : ''}: ${param.description}`);
104 | });
105 | } else {
106 | lines.push(` - Parameters: None`);
107 | }
108 | lines.push(` - Read-only: **${tool.type === 'readOnly'}**`);
109 | lines.push('');
110 | return lines;
111 | }
112 |
113 | /**
114 | * @param {string} content
115 | * @param {string} startMarker
116 | * @param {string} endMarker
117 | * @param {string[]} generatedLines
118 | * @returns {Promise<string>}
119 | */
120 | async function updateSection(content, startMarker, endMarker, generatedLines) {
121 | const startMarkerIndex = content.indexOf(startMarker);
122 | const endMarkerIndex = content.indexOf(endMarker);
123 | if (startMarkerIndex === -1 || endMarkerIndex === -1)
124 | throw new Error('Markers for generated section not found in README');
125 |
126 | return [
127 | content.slice(0, startMarkerIndex + startMarker.length),
128 | '',
129 | generatedLines.join('\n'),
130 | '',
131 | content.slice(endMarkerIndex),
132 | ].join('\n');
133 | }
134 |
135 | /**
136 | * @param {string} content
137 | * @returns {Promise<string>}
138 | */
139 | async function updateTools(content) {
140 | console.log('Loading tool information from compiled modules...');
141 |
142 | const totalTools = Object.values(categories).flat().length;
143 | console.log(`Found ${totalTools} tools`);
144 |
145 | const generatedLines = /** @type {string[]} */ ([]);
146 | for (const [category, categoryTools] of Object.entries(categories)) {
147 | generatedLines.push(`<details>\n<summary><b>${category}</b></summary>`);
148 | generatedLines.push('');
149 | for (const tool of categoryTools)
150 | generatedLines.push(...formatToolForReadme(tool.schema));
151 | generatedLines.push(`</details>`);
152 | generatedLines.push('');
153 | }
154 |
155 | const startMarker = `<!--- Tools generated by ${path.basename(__filename)} -->`;
156 | const endMarker = `<!--- End of tools generated section -->`;
157 | return updateSection(content, startMarker, endMarker, generatedLines);
158 | }
159 |
160 | /**
161 | * @param {string} content
162 | * @returns {Promise<string>}
163 | */
164 | async function updateOptions(content) {
165 | console.log('Listing options...');
166 | const output = execSync('node cli.js --help');
167 | const lines = output.toString().split('\n');
168 | const firstLine = lines.findIndex(line => line.includes('--version'));
169 | lines.splice(0, firstLine + 1);
170 | const lastLine = lines.findIndex(line => line.includes('--help'));
171 | lines.splice(lastLine);
172 | const startMarker = `<!--- Options generated by ${path.basename(__filename)} -->`;
173 | const endMarker = `<!--- End of options generated section -->`;
174 | return updateSection(content, startMarker, endMarker, [
175 | '```',
176 | '> npx @playwright/mcp@latest --help',
177 | ...lines,
178 | '```',
179 | ]);
180 | }
181 |
182 | async function updateReadme() {
183 | const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
184 | const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
185 | const withTools = await updateTools(readmeContent);
186 | const withOptions = await updateOptions(withTools);
187 | await fs.promises.writeFile(readmePath, withOptions, 'utf-8');
188 | console.log('README updated successfully');
189 | }
190 |
191 | updateReadme().catch(err => {
192 | console.error('Error updating README:', err);
193 | process.exit(1);
194 | });
195 |
```
--------------------------------------------------------------------------------
/tests/screenshot.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 |
19 | import { test, expect } from './fixtures.js';
20 |
21 | test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
22 | const { client } = await startClient({
23 | config: { outputDir: testInfo.outputPath('output') },
24 | });
25 | expect(await client.callTool({
26 | name: 'browser_navigate',
27 | arguments: { url: server.HELLO_WORLD },
28 | })).toContainTextContent(`Navigate to http://localhost`);
29 |
30 | expect(await client.callTool({
31 | name: 'browser_take_screenshot',
32 | })).toEqual({
33 | content: [
34 | {
35 | data: expect.any(String),
36 | mimeType: 'image/jpeg',
37 | type: 'image',
38 | },
39 | {
40 | text: expect.stringContaining(`Screenshot viewport and save it as`),
41 | type: 'text',
42 | },
43 | ],
44 | });
45 | });
46 |
47 | test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
48 | const { client } = await startClient({
49 | config: { outputDir: testInfo.outputPath('output') },
50 | });
51 | expect(await client.callTool({
52 | name: 'browser_navigate',
53 | arguments: { url: server.HELLO_WORLD },
54 | })).toContainTextContent(`[ref=e1]`);
55 |
56 | expect(await client.callTool({
57 | name: 'browser_take_screenshot',
58 | arguments: {
59 | element: 'hello button',
60 | ref: 'e1',
61 | },
62 | })).toEqual({
63 | content: [
64 | {
65 | data: expect.any(String),
66 | mimeType: 'image/jpeg',
67 | type: 'image',
68 | },
69 | {
70 | text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
71 | type: 'text',
72 | },
73 | ],
74 | });
75 | });
76 |
77 | test('--output-dir should work', async ({ startClient, server }, testInfo) => {
78 | const outputDir = testInfo.outputPath('output');
79 | const { client } = await startClient({
80 | config: { outputDir },
81 | });
82 | expect(await client.callTool({
83 | name: 'browser_navigate',
84 | arguments: { url: server.HELLO_WORLD },
85 | })).toContainTextContent(`Navigate to http://localhost`);
86 |
87 | await client.callTool({
88 | name: 'browser_take_screenshot',
89 | });
90 |
91 | expect(fs.existsSync(outputDir)).toBeTruthy();
92 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
93 | expect(files).toHaveLength(1);
94 | expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
95 | });
96 |
97 | for (const raw of [undefined, true]) {
98 | test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
99 | const outputDir = testInfo.outputPath('output');
100 | const ext = raw ? 'png' : 'jpeg';
101 | const { client } = await startClient({
102 | config: { outputDir },
103 | });
104 | expect(await client.callTool({
105 | name: 'browser_navigate',
106 | arguments: { url: server.PREFIX },
107 | })).toContainTextContent(`Navigate to http://localhost`);
108 |
109 | expect(await client.callTool({
110 | name: 'browser_take_screenshot',
111 | arguments: { raw },
112 | })).toEqual({
113 | content: [
114 | {
115 | data: expect.any(String),
116 | mimeType: `image/${ext}`,
117 | type: 'image',
118 | },
119 | {
120 | text: expect.stringMatching(
121 | new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
122 | ),
123 | type: 'text',
124 | },
125 | ],
126 | });
127 |
128 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
129 |
130 | expect(fs.existsSync(outputDir)).toBeTruthy();
131 | expect(files).toHaveLength(1);
132 | expect(files[0]).toMatch(
133 | new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
134 | );
135 | });
136 |
137 | }
138 |
139 | test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
140 | const outputDir = testInfo.outputPath('output');
141 | const { client } = await startClient({
142 | config: { outputDir },
143 | });
144 | expect(await client.callTool({
145 | name: 'browser_navigate',
146 | arguments: { url: server.HELLO_WORLD },
147 | })).toContainTextContent(`Navigate to http://localhost`);
148 |
149 | expect(await client.callTool({
150 | name: 'browser_take_screenshot',
151 | arguments: {
152 | filename: 'output.jpeg',
153 | },
154 | })).toEqual({
155 | content: [
156 | {
157 | data: expect.any(String),
158 | mimeType: 'image/jpeg',
159 | type: 'image',
160 | },
161 | {
162 | text: expect.stringContaining(`output.jpeg`),
163 | type: 'text',
164 | },
165 | ],
166 | });
167 |
168 | const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
169 |
170 | expect(fs.existsSync(outputDir)).toBeTruthy();
171 | expect(files).toHaveLength(1);
172 | expect(files[0]).toMatch(/^output\.jpeg$/);
173 | });
174 |
175 | test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
176 | const outputDir = testInfo.outputPath('output');
177 | const { client } = await startClient({
178 | config: {
179 | outputDir,
180 | imageResponses: 'omit',
181 | },
182 | });
183 |
184 | expect(await client.callTool({
185 | name: 'browser_navigate',
186 | arguments: { url: server.HELLO_WORLD },
187 | })).toContainTextContent(`Navigate to http://localhost`);
188 |
189 | await client.callTool({
190 | name: 'browser_take_screenshot',
191 | });
192 |
193 | expect(await client.callTool({
194 | name: 'browser_take_screenshot',
195 | })).toEqual({
196 | content: [
197 | {
198 | text: expect.stringContaining(`Screenshot viewport and save it as`),
199 | type: 'text',
200 | },
201 | ],
202 | });
203 | });
204 |
205 | test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
206 | const outputDir = testInfo.outputPath('output');
207 |
208 | const { client } = await startClient({
209 | clientName: 'cursor:vscode',
210 | config: { outputDir },
211 | });
212 |
213 | expect(await client.callTool({
214 | name: 'browser_navigate',
215 | arguments: { url: server.HELLO_WORLD },
216 | })).toContainTextContent(`Navigate to http://localhost`);
217 |
218 | await client.callTool({
219 | name: 'browser_take_screenshot',
220 | });
221 |
222 | expect(await client.callTool({
223 | name: 'browser_take_screenshot',
224 | })).toEqual({
225 | content: [
226 | {
227 | text: expect.stringContaining(`Screenshot viewport and save it as`),
228 | type: 'text',
229 | },
230 | ],
231 | });
232 | });
233 |
```
--------------------------------------------------------------------------------
/src/tools/snapshot.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { z } from 'zod';
18 |
19 | import { defineTool } from './tool.js';
20 | import * as javascript from '../javascript.js';
21 | import { generateLocator } from './utils.js';
22 |
23 | const snapshot = defineTool({
24 | capability: 'core',
25 | schema: {
26 | name: 'browser_snapshot',
27 | title: 'Page snapshot',
28 | description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
29 | inputSchema: z.object({}),
30 | type: 'readOnly',
31 | },
32 |
33 | handle: async context => {
34 | await context.ensureTab();
35 |
36 | return {
37 | code: [`// <internal code to capture accessibility snapshot>`],
38 | captureSnapshot: true,
39 | waitForNetwork: false,
40 | };
41 | },
42 | });
43 |
44 | const elementSchema = z.object({
45 | element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
46 | ref: z.string().describe('Exact target element reference from the page snapshot'),
47 | });
48 |
49 | const clickSchema = elementSchema.extend({
50 | doubleClick: z.coerce.boolean().optional().describe('Whether to perform a double click instead of a single click'),
51 | });
52 |
53 | const click = defineTool({
54 | capability: 'core',
55 | schema: {
56 | name: 'browser_click',
57 | title: 'Click',
58 | description: 'Perform click on a web page',
59 | inputSchema: clickSchema,
60 | type: 'destructive',
61 | },
62 |
63 | handle: async (context, params) => {
64 | const tab = context.currentTabOrDie();
65 | const locator = tab.snapshotOrDie().refLocator(params);
66 |
67 | const code: string[] = [];
68 | if (params.doubleClick) {
69 | code.push(`// Double click ${params.element}`);
70 | code.push(`await page.${await generateLocator(locator)}.dblclick();`);
71 | } else {
72 | code.push(`// Click ${params.element}`);
73 | code.push(`await page.${await generateLocator(locator)}.click();`);
74 | }
75 |
76 | return {
77 | code,
78 | action: () => params.doubleClick ? locator.dblclick() : locator.click(),
79 | captureSnapshot: true,
80 | waitForNetwork: true,
81 | };
82 | },
83 | });
84 |
85 | const drag = defineTool({
86 | capability: 'core',
87 | schema: {
88 | name: 'browser_drag',
89 | title: 'Drag mouse',
90 | description: 'Perform drag and drop between two elements',
91 | inputSchema: z.object({
92 | startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
93 | startRef: z.string().describe('Exact source element reference from the page snapshot'),
94 | endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
95 | endRef: z.string().describe('Exact target element reference from the page snapshot'),
96 | }),
97 | type: 'destructive',
98 | },
99 |
100 | handle: async (context, params) => {
101 | const snapshot = context.currentTabOrDie().snapshotOrDie();
102 | const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
103 | const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
104 |
105 | const code = [
106 | `// Drag ${params.startElement} to ${params.endElement}`,
107 | `await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
108 | ];
109 |
110 | return {
111 | code,
112 | action: () => startLocator.dragTo(endLocator),
113 | captureSnapshot: true,
114 | waitForNetwork: true,
115 | };
116 | },
117 | });
118 |
119 | const hover = defineTool({
120 | capability: 'core',
121 | schema: {
122 | name: 'browser_hover',
123 | title: 'Hover mouse',
124 | description: 'Hover over element on page',
125 | inputSchema: elementSchema,
126 | type: 'readOnly',
127 | },
128 |
129 | handle: async (context, params) => {
130 | const snapshot = context.currentTabOrDie().snapshotOrDie();
131 | const locator = snapshot.refLocator(params);
132 |
133 | const code = [
134 | `// Hover over ${params.element}`,
135 | `await page.${await generateLocator(locator)}.hover();`
136 | ];
137 |
138 | return {
139 | code,
140 | action: () => locator.hover(),
141 | captureSnapshot: true,
142 | waitForNetwork: true,
143 | };
144 | },
145 | });
146 |
147 | const typeSchema = elementSchema.extend({
148 | text: z.string().describe('Text to type into the element'),
149 | submit: z.coerce.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
150 | slowly: z.coerce.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
151 | });
152 |
153 | const type = defineTool({
154 | capability: 'core',
155 | schema: {
156 | name: 'browser_type',
157 | title: 'Type text',
158 | description: 'Type text into editable element',
159 | inputSchema: typeSchema,
160 | type: 'destructive',
161 | },
162 |
163 | handle: async (context, params) => {
164 | const snapshot = context.currentTabOrDie().snapshotOrDie();
165 | const locator = snapshot.refLocator(params);
166 |
167 | const code: string[] = [];
168 | const steps: (() => Promise<void>)[] = [];
169 |
170 | if (params.slowly) {
171 | code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
172 | code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
173 | steps.push(() => locator.pressSequentially(params.text));
174 | } else {
175 | code.push(`// Fill "${params.text}" into "${params.element}"`);
176 | code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
177 | steps.push(() => locator.fill(params.text));
178 | }
179 |
180 | if (params.submit) {
181 | code.push(`// Submit text`);
182 | code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
183 | steps.push(() => locator.press('Enter'));
184 | }
185 |
186 | return {
187 | code,
188 | action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
189 | captureSnapshot: true,
190 | waitForNetwork: true,
191 | };
192 | },
193 | });
194 |
195 | const selectOptionSchema = elementSchema.extend({
196 | values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
197 | });
198 |
199 | const selectOption = defineTool({
200 | capability: 'core',
201 | schema: {
202 | name: 'browser_select_option',
203 | title: 'Select option',
204 | description: 'Select an option in a dropdown',
205 | inputSchema: selectOptionSchema,
206 | type: 'destructive',
207 | },
208 |
209 | handle: async (context, params) => {
210 | const snapshot = context.currentTabOrDie().snapshotOrDie();
211 | const locator = snapshot.refLocator(params);
212 |
213 | const code = [
214 | `// Select options [${params.values.join(', ')}] in ${params.element}`,
215 | `await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
216 | ];
217 |
218 | return {
219 | code,
220 | action: () => locator.selectOption(params.values).then(() => {}),
221 | captureSnapshot: true,
222 | waitForNetwork: true,
223 | };
224 | },
225 | });
226 |
227 | export default [
228 | snapshot,
229 | click,
230 | drag,
231 | hover,
232 | type,
233 | selectOption,
234 | ];
235 |
```
--------------------------------------------------------------------------------
/src/httpServer.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 | import path from 'path';
19 | import http from 'http';
20 | import net from 'net';
21 |
22 | import mime from 'mime';
23 |
24 | import { ManualPromise } from './manualPromise.js';
25 |
26 |
27 | export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
28 |
29 | export type Transport = {
30 | sendEvent?: (method: string, params: any) => void;
31 | close?: () => void;
32 | onconnect: () => void;
33 | dispatch: (method: string, params: any) => Promise<any>;
34 | onclose: () => void;
35 | };
36 |
37 | export class HttpServer {
38 | private _server: http.Server;
39 | private _urlPrefixPrecise: string = '';
40 | private _urlPrefixHumanReadable: string = '';
41 | private _port: number = 0;
42 | private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
43 |
44 | constructor() {
45 | this._server = http.createServer(this._onRequest.bind(this));
46 | decorateServer(this._server);
47 | }
48 |
49 | server() {
50 | return this._server;
51 | }
52 |
53 | routePrefix(prefix: string, handler: ServerRouteHandler) {
54 | this._routes.push({ prefix, handler });
55 | }
56 |
57 | routePath(path: string, handler: ServerRouteHandler) {
58 | this._routes.push({ exact: path, handler });
59 | }
60 |
61 | port(): number {
62 | return this._port;
63 | }
64 |
65 | private async _tryStart(port: number | undefined, host: string) {
66 | const errorPromise = new ManualPromise();
67 | const errorListener = (error: Error) => errorPromise.reject(error);
68 | this._server.on('error', errorListener);
69 |
70 | try {
71 | this._server.listen(port, host);
72 | await Promise.race([
73 | new Promise(cb => this._server!.once('listening', cb)),
74 | errorPromise,
75 | ]);
76 | } finally {
77 | this._server.removeListener('error', errorListener);
78 | }
79 | }
80 |
81 | async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
82 | const host = options.host || 'localhost';
83 | if (options.preferredPort) {
84 | try {
85 | await this._tryStart(options.preferredPort, host);
86 | } catch (e: any) {
87 | if (!e || !e.message || !e.message.includes('EADDRINUSE'))
88 | throw e;
89 | await this._tryStart(undefined, host);
90 | }
91 | } else {
92 | await this._tryStart(options.port, host);
93 | }
94 |
95 | const address = this._server.address();
96 | if (typeof address === 'string') {
97 | this._urlPrefixPrecise = address;
98 | this._urlPrefixHumanReadable = address;
99 | } else {
100 | this._port = address!.port;
101 | const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
102 | this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
103 | this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
104 | }
105 | }
106 |
107 | async stop() {
108 | await new Promise(cb => this._server!.close(cb));
109 | }
110 |
111 | urlPrefix(purpose: 'human-readable' | 'precise'): string {
112 | return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
113 | }
114 |
115 | serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
116 | try {
117 | for (const [name, value] of Object.entries(headers || {}))
118 | response.setHeader(name, value);
119 | if (request.headers.range)
120 | this._serveRangeFile(request, response, absoluteFilePath);
121 | else
122 | this._serveFile(response, absoluteFilePath);
123 | return true;
124 | } catch (e) {
125 | return false;
126 | }
127 | }
128 |
129 | _serveFile(response: http.ServerResponse, absoluteFilePath: string) {
130 | const content = fs.readFileSync(absoluteFilePath);
131 | response.statusCode = 200;
132 | const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
133 | response.setHeader('Content-Type', contentType);
134 | response.setHeader('Content-Length', content.byteLength);
135 | response.end(content);
136 | }
137 |
138 | _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
139 | const range = request.headers.range;
140 | if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
141 | response.statusCode = 400;
142 | return response.end('Bad request');
143 | }
144 |
145 | // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
146 | const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
147 |
148 | // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
149 | let start: number;
150 | let end: number;
151 | const size = fs.statSync(absoluteFilePath).size;
152 | if (startStr !== '' && endStr === '') {
153 | // No end specified: use the whole file
154 | start = +startStr;
155 | end = size - 1;
156 | } else if (startStr === '' && endStr !== '') {
157 | // No start specified: calculate start manually
158 | start = size - +endStr;
159 | end = size - 1;
160 | } else {
161 | start = +startStr;
162 | end = +endStr;
163 | }
164 |
165 | // Handle unavailable range request
166 | if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
167 | // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
168 | response.writeHead(416, {
169 | 'Content-Range': `bytes */${size}`
170 | });
171 | return response.end();
172 | }
173 |
174 | // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
175 | response.writeHead(206, {
176 | 'Content-Range': `bytes ${start}-${end}/${size}`,
177 | 'Accept-Ranges': 'bytes',
178 | 'Content-Length': end - start + 1,
179 | 'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
180 | });
181 |
182 | const readable = fs.createReadStream(absoluteFilePath, { start, end });
183 | readable.pipe(response);
184 | }
185 |
186 | private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
187 | if (request.method === 'OPTIONS') {
188 | response.writeHead(200);
189 | response.end();
190 | return;
191 | }
192 |
193 | request.on('error', () => response.end());
194 | try {
195 | if (!request.url) {
196 | response.end();
197 | return;
198 | }
199 | const url = new URL('http://localhost' + request.url);
200 | for (const route of this._routes) {
201 | if (route.exact && url.pathname === route.exact) {
202 | route.handler(request, response);
203 | return;
204 | }
205 | if (route.prefix && url.pathname.startsWith(route.prefix)) {
206 | route.handler(request, response);
207 | return;
208 | }
209 | }
210 | response.statusCode = 404;
211 | response.end();
212 | } catch (e) {
213 | response.end();
214 | }
215 | }
216 | }
217 |
218 | function decorateServer(server: net.Server) {
219 | const sockets = new Set<net.Socket>();
220 | server.on('connection', socket => {
221 | sockets.add(socket);
222 | socket.once('close', () => sockets.delete(socket));
223 | });
224 |
225 | const close = server.close;
226 | server.close = (callback?: (err?: Error) => void) => {
227 | for (const socket of sockets)
228 | socket.destroy();
229 | sockets.clear();
230 | return close.call(server, callback);
231 | };
232 | }
233 |
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 | import os from 'os';
19 | import path from 'path';
20 | import { devices } from 'playwright';
21 |
22 | import type { Config, ToolCapability } from '../config.js';
23 | import type { BrowserContextOptions, LaunchOptions } from 'playwright';
24 | import { sanitizeForFilePath } from './tools/utils.js';
25 |
26 | export type CLIOptions = {
27 | allowedOrigins?: string[];
28 | blockedOrigins?: string[];
29 | blockServiceWorkers?: boolean;
30 | browser?: string;
31 | browserAgent?: string;
32 | caps?: string;
33 | cdpEndpoint?: string;
34 | config?: string;
35 | device?: string;
36 | executablePath?: string;
37 | headless?: boolean;
38 | host?: string;
39 | ignoreHttpsErrors?: boolean;
40 | isolated?: boolean;
41 | imageResponses?: 'allow' | 'omit' | 'auto';
42 | sandbox: boolean;
43 | outputDir?: string;
44 | port?: number;
45 | proxyBypass?: string;
46 | proxyServer?: string;
47 | saveTrace?: boolean;
48 | storageState?: string;
49 | userAgent?: string;
50 | userDataDir?: string;
51 | viewportSize?: string;
52 | vision?: boolean;
53 | };
54 |
55 | const defaultConfig: FullConfig = {
56 | browser: {
57 | browserName: 'chromium',
58 | launchOptions: {
59 | channel: 'chrome',
60 | headless: os.platform() === 'linux' && !process.env.DISPLAY,
61 | chromiumSandbox: true,
62 | },
63 | contextOptions: {
64 | viewport: null,
65 | },
66 | },
67 | network: {
68 | allowedOrigins: undefined,
69 | blockedOrigins: undefined,
70 | },
71 | server: {},
72 | outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
73 | };
74 |
75 | type BrowserUserConfig = NonNullable<Config['browser']>;
76 |
77 | export type FullConfig = Config & {
78 | browser: Omit<BrowserUserConfig, 'browserName'> & {
79 | browserName: 'chromium' | 'firefox' | 'webkit';
80 | launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
81 | contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
82 | },
83 | network: NonNullable<Config['network']>,
84 | outputDir: string;
85 | server: NonNullable<Config['server']>,
86 | };
87 |
88 | export async function resolveConfig(config: Config): Promise<FullConfig> {
89 | return mergeConfig(defaultConfig, config);
90 | }
91 |
92 | export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
93 | const configInFile = await loadConfig(cliOptions.config);
94 | const cliOverrides = await configFromCLIOptions(cliOptions);
95 | const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
96 | // Derive artifact output directory from config.outputDir
97 | if (result.saveTrace)
98 | result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
99 | return result;
100 | }
101 |
102 | export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
103 | let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
104 | let channel: string | undefined;
105 | switch (cliOptions.browser) {
106 | case 'chrome':
107 | case 'chrome-beta':
108 | case 'chrome-canary':
109 | case 'chrome-dev':
110 | case 'chromium':
111 | case 'msedge':
112 | case 'msedge-beta':
113 | case 'msedge-canary':
114 | case 'msedge-dev':
115 | browserName = 'chromium';
116 | channel = cliOptions.browser;
117 | break;
118 | case 'firefox':
119 | browserName = 'firefox';
120 | break;
121 | case 'webkit':
122 | browserName = 'webkit';
123 | break;
124 | }
125 |
126 | // Launch options
127 | const launchOptions: LaunchOptions = {
128 | channel,
129 | executablePath: cliOptions.executablePath,
130 | headless: cliOptions.headless,
131 | };
132 |
133 | // --no-sandbox was passed, disable the sandbox
134 | if (!cliOptions.sandbox)
135 | launchOptions.chromiumSandbox = false;
136 |
137 | if (cliOptions.proxyServer) {
138 | launchOptions.proxy = {
139 | server: cliOptions.proxyServer
140 | };
141 | if (cliOptions.proxyBypass)
142 | launchOptions.proxy.bypass = cliOptions.proxyBypass;
143 | }
144 |
145 | if (cliOptions.device && cliOptions.cdpEndpoint)
146 | throw new Error('Device emulation is not supported with cdpEndpoint.');
147 |
148 | // Context options
149 | const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
150 | if (cliOptions.storageState)
151 | contextOptions.storageState = cliOptions.storageState;
152 |
153 | if (cliOptions.userAgent)
154 | contextOptions.userAgent = cliOptions.userAgent;
155 |
156 | if (cliOptions.viewportSize) {
157 | try {
158 | const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
159 | if (isNaN(width) || isNaN(height))
160 | throw new Error('bad values');
161 | contextOptions.viewport = { width, height };
162 | } catch (e) {
163 | throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
164 | }
165 | }
166 |
167 | if (cliOptions.ignoreHttpsErrors)
168 | contextOptions.ignoreHTTPSErrors = true;
169 |
170 | if (cliOptions.blockServiceWorkers)
171 | contextOptions.serviceWorkers = 'block';
172 |
173 | const result: Config = {
174 | browser: {
175 | browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
176 | browserName,
177 | isolated: cliOptions.isolated,
178 | userDataDir: cliOptions.userDataDir,
179 | launchOptions,
180 | contextOptions,
181 | cdpEndpoint: cliOptions.cdpEndpoint,
182 | },
183 | server: {
184 | port: cliOptions.port,
185 | host: cliOptions.host,
186 | },
187 | capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
188 | vision: !!cliOptions.vision,
189 | network: {
190 | allowedOrigins: cliOptions.allowedOrigins,
191 | blockedOrigins: cliOptions.blockedOrigins,
192 | },
193 | saveTrace: cliOptions.saveTrace,
194 | outputDir: cliOptions.outputDir,
195 | imageResponses: cliOptions.imageResponses,
196 | };
197 |
198 | return result;
199 | }
200 |
201 | async function loadConfig(configFile: string | undefined): Promise<Config> {
202 | if (!configFile)
203 | return {};
204 |
205 | try {
206 | return JSON.parse(await fs.promises.readFile(configFile, 'utf8'));
207 | } catch (error) {
208 | throw new Error(`Failed to load config file: ${configFile}, ${error}`);
209 | }
210 | }
211 |
212 | export async function outputFile(config: FullConfig, name: string): Promise<string> {
213 | await fs.promises.mkdir(config.outputDir, { recursive: true });
214 | const fileName = sanitizeForFilePath(name);
215 | return path.join(config.outputDir, fileName);
216 | }
217 |
218 | function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
219 | return Object.fromEntries(
220 | Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
221 | ) as Partial<T>;
222 | }
223 |
224 | function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
225 | const browser: FullConfig['browser'] = {
226 | ...pickDefined(base.browser),
227 | ...pickDefined(overrides.browser),
228 | browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
229 | isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
230 | launchOptions: {
231 | ...pickDefined(base.browser?.launchOptions),
232 | ...pickDefined(overrides.browser?.launchOptions),
233 | ...{ assistantMode: true },
234 | },
235 | contextOptions: {
236 | ...pickDefined(base.browser?.contextOptions),
237 | ...pickDefined(overrides.browser?.contextOptions),
238 | },
239 | };
240 |
241 | if (browser.browserName !== 'chromium' && browser.launchOptions)
242 | delete browser.launchOptions.channel;
243 |
244 | return {
245 | ...pickDefined(base),
246 | ...pickDefined(overrides),
247 | browser,
248 | network: {
249 | ...pickDefined(base.network),
250 | ...pickDefined(overrides.network),
251 | },
252 | server: {
253 | ...pickDefined(base.server),
254 | ...pickDefined(overrides.server),
255 | },
256 | } as FullConfig;
257 | }
258 |
```
--------------------------------------------------------------------------------
/tests/fixtures.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'fs';
18 | import url from 'url';
19 | import path from 'path';
20 | import { chromium } from 'playwright';
21 |
22 | import { test as baseTest, expect as baseExpect } from '@playwright/test';
23 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
24 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
25 | import { TestServer } from './testserver/index.ts';
26 |
27 | import type { Config } from '../config';
28 | import type { BrowserContext } from 'playwright';
29 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
30 | import type { Stream } from 'stream';
31 |
32 | export type TestOptions = {
33 | mcpBrowser: string | undefined;
34 | mcpMode: 'docker' | undefined;
35 | };
36 |
37 | type CDPServer = {
38 | endpoint: string;
39 | start: () => Promise<BrowserContext>;
40 | };
41 |
42 | type TestFixtures = {
43 | client: Client;
44 | visionClient: Client;
45 | startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
46 | wsEndpoint: string;
47 | cdpServer: CDPServer;
48 | server: TestServer;
49 | httpsServer: TestServer;
50 | mcpHeadless: boolean;
51 | };
52 |
53 | type WorkerFixtures = {
54 | _workerServers: { server: TestServer, httpsServer: TestServer };
55 | };
56 |
57 | export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
58 |
59 | client: async ({ startClient }, use) => {
60 | const { client } = await startClient();
61 | await use(client);
62 | },
63 |
64 | visionClient: async ({ startClient }, use) => {
65 | const { client } = await startClient({ args: ['--vision'] });
66 | await use(client);
67 | },
68 |
69 | startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
70 | const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
71 | const configDir = path.dirname(test.info().config.configFile!);
72 | let client: Client | undefined;
73 |
74 | await use(async options => {
75 | const args: string[] = [];
76 | if (userDataDir)
77 | args.push('--user-data-dir', userDataDir);
78 | if (process.env.CI && process.platform === 'linux')
79 | args.push('--no-sandbox');
80 | if (mcpHeadless)
81 | args.push('--headless');
82 | if (mcpBrowser)
83 | args.push(`--browser=${mcpBrowser}`);
84 | if (options?.args)
85 | args.push(...options.args);
86 | if (options?.config) {
87 | const configFile = testInfo.outputPath('config.json');
88 | await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
89 | args.push(`--config=${path.relative(configDir, configFile)}`);
90 | }
91 |
92 | client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
93 | const { transport, stderr } = await createTransport(args, mcpMode);
94 | let stderrBuffer = '';
95 | stderr?.on('data', data => {
96 | if (process.env.PWMCP_DEBUG)
97 | process.stderr.write(data);
98 | stderrBuffer += data.toString();
99 | });
100 | await client.connect(transport);
101 | await client.ping();
102 | return { client, stderr: () => stderrBuffer };
103 | });
104 |
105 | await client?.close();
106 | },
107 |
108 | wsEndpoint: async ({ }, use) => {
109 | const browserServer = await chromium.launchServer();
110 | await use(browserServer.wsEndpoint());
111 | await browserServer.close();
112 | },
113 |
114 | cdpServer: async ({ mcpBrowser }, use, testInfo) => {
115 | test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
116 |
117 | let browserContext: BrowserContext | undefined;
118 | const port = 3200 + test.info().parallelIndex;
119 | await use({
120 | endpoint: `http://localhost:${port}`,
121 | start: async () => {
122 | browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
123 | channel: mcpBrowser,
124 | headless: true,
125 | args: [
126 | `--remote-debugging-port=${port}`,
127 | ],
128 | });
129 | return browserContext;
130 | }
131 | });
132 | await browserContext?.close();
133 | },
134 |
135 | mcpHeadless: async ({ headless }, use) => {
136 | await use(headless);
137 | },
138 |
139 | mcpBrowser: ['chrome', { option: true }],
140 |
141 | mcpMode: [undefined, { option: true }],
142 |
143 | _workerServers: [async ({ }, use, workerInfo) => {
144 | const port = 8907 + workerInfo.workerIndex * 4;
145 | const server = await TestServer.create(port);
146 |
147 | const httpsPort = port + 1;
148 | const httpsServer = await TestServer.createHTTPS(httpsPort);
149 |
150 | await use({ server, httpsServer });
151 |
152 | await Promise.all([
153 | server.stop(),
154 | httpsServer.stop(),
155 | ]);
156 | }, { scope: 'worker' }],
157 |
158 | server: async ({ _workerServers }, use) => {
159 | _workerServers.server.reset();
160 | await use(_workerServers.server);
161 | },
162 |
163 | httpsServer: async ({ _workerServers }, use) => {
164 | _workerServers.httpsServer.reset();
165 | await use(_workerServers.httpsServer);
166 | },
167 | });
168 |
169 | async function createTransport(args: string[], mcpMode: TestOptions['mcpMode']): Promise<{
170 | transport: Transport,
171 | stderr: Stream | null,
172 | }> {
173 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
174 | const __filename = url.fileURLToPath(import.meta.url);
175 | if (mcpMode === 'docker') {
176 | const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
177 | const transport = new StdioClientTransport({
178 | command: 'docker',
179 | args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
180 | });
181 | return {
182 | transport,
183 | stderr: transport.stderr,
184 | };
185 | }
186 |
187 | const transport = new StdioClientTransport({
188 | command: 'node',
189 | args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
190 | cwd: path.join(path.dirname(__filename), '..'),
191 | stderr: 'pipe',
192 | env: {
193 | ...process.env,
194 | DEBUG: 'pw:mcp:test',
195 | DEBUG_COLORS: '0',
196 | DEBUG_HIDE_DATE: '1',
197 | },
198 | });
199 | return {
200 | transport,
201 | stderr: transport.stderr!,
202 | };
203 | }
204 |
205 | type Response = Awaited<ReturnType<Client['callTool']>>;
206 |
207 | export const expect = baseExpect.extend({
208 | toHaveTextContent(response: Response, content: string | RegExp) {
209 | const isNot = this.isNot;
210 | try {
211 | const text = (response.content as any)[0].text;
212 | if (typeof content === 'string') {
213 | if (isNot)
214 | baseExpect(text.trim()).not.toBe(content.trim());
215 | else
216 | baseExpect(text.trim()).toBe(content.trim());
217 | } else {
218 | if (isNot)
219 | baseExpect(text).not.toMatch(content);
220 | else
221 | baseExpect(text).toMatch(content);
222 | }
223 | } catch (e) {
224 | return {
225 | pass: isNot,
226 | message: () => e.message,
227 | };
228 | }
229 | return {
230 | pass: !isNot,
231 | message: () => ``,
232 | };
233 | },
234 |
235 | toContainTextContent(response: Response, content: string | string[]) {
236 | const isNot = this.isNot;
237 | try {
238 | content = Array.isArray(content) ? content : [content];
239 | const texts = (response.content as any).map(c => c.text);
240 | for (let i = 0; i < texts.length; i++) {
241 | if (isNot)
242 | expect(texts[i]).not.toContain(content[i]);
243 | else
244 | expect(texts[i]).toContain(content[i]);
245 | }
246 | } catch (e) {
247 | return {
248 | pass: isNot,
249 | message: () => e.message,
250 | };
251 | }
252 | return {
253 | pass: !isNot,
254 | message: () => ``,
255 | };
256 | },
257 | });
258 |
259 | export function formatOutput(output: string): string[] {
260 | return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
261 | }
262 |
```
--------------------------------------------------------------------------------
/tests/core.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import { test, expect } from './fixtures.js';
18 |
19 | test('browser_navigate', async ({ client, server }) => {
20 | expect(await client.callTool({
21 | name: 'browser_navigate',
22 | arguments: { url: server.HELLO_WORLD },
23 | })).toHaveTextContent(`
24 | - Ran Playwright code:
25 | \`\`\`js
26 | // Navigate to ${server.HELLO_WORLD}
27 | await page.goto('${server.HELLO_WORLD}');
28 | \`\`\`
29 |
30 | - Page URL: ${server.HELLO_WORLD}
31 | - Page Title: Title
32 | - Page Snapshot
33 | \`\`\`yaml
34 | - generic [active] [ref=e1]: Hello, world!
35 | \`\`\`
36 | `
37 | );
38 | });
39 |
40 | test('browser_click', async ({ client, server, mcpBrowser }) => {
41 | server.setContent('/', `
42 | <title>Title</title>
43 | <button>Submit</button>
44 | `, 'text/html');
45 |
46 | await client.callTool({
47 | name: 'browser_navigate',
48 | arguments: { url: server.PREFIX },
49 | });
50 |
51 | expect(await client.callTool({
52 | name: 'browser_click',
53 | arguments: {
54 | element: 'Submit button',
55 | ref: 'e2',
56 | },
57 | })).toHaveTextContent(`
58 | - Ran Playwright code:
59 | \`\`\`js
60 | // Click Submit button
61 | await page.getByRole('button', { name: 'Submit' }).click();
62 | \`\`\`
63 |
64 | - Page URL: ${server.PREFIX}
65 | - Page Title: Title
66 | - Page Snapshot
67 | \`\`\`yaml
68 | - button "Submit" ${mcpBrowser !== 'webkit' || process.platform === 'linux' ? '[active] ' : ''}[ref=e2]
69 | \`\`\`
70 | `);
71 | });
72 |
73 | test('browser_click (double)', async ({ client, server }) => {
74 | server.setContent('/', `
75 | <title>Title</title>
76 | <script>
77 | function handle() {
78 | document.querySelector('h1').textContent = 'Double clicked';
79 | }
80 | </script>
81 | <h1 ondblclick="handle()">Click me</h1>
82 | `, 'text/html');
83 |
84 | await client.callTool({
85 | name: 'browser_navigate',
86 | arguments: { url: server.PREFIX },
87 | });
88 |
89 | expect(await client.callTool({
90 | name: 'browser_click',
91 | arguments: {
92 | element: 'Click me',
93 | ref: 'e2',
94 | doubleClick: true,
95 | },
96 | })).toHaveTextContent(`
97 | - Ran Playwright code:
98 | \`\`\`js
99 | // Double click Click me
100 | await page.getByRole('heading', { name: 'Click me' }).dblclick();
101 | \`\`\`
102 |
103 | - Page URL: ${server.PREFIX}
104 | - Page Title: Title
105 | - Page Snapshot
106 | \`\`\`yaml
107 | - heading "Double clicked" [level=1] [ref=e3]
108 | \`\`\`
109 | `);
110 | });
111 |
112 | test('browser_select_option', async ({ client, server }) => {
113 | server.setContent('/', `
114 | <title>Title</title>
115 | <select>
116 | <option value="foo">Foo</option>
117 | <option value="bar">Bar</option>
118 | </select>
119 | `, 'text/html');
120 |
121 | await client.callTool({
122 | name: 'browser_navigate',
123 | arguments: { url: server.PREFIX },
124 | });
125 |
126 | expect(await client.callTool({
127 | name: 'browser_select_option',
128 | arguments: {
129 | element: 'Select',
130 | ref: 'e2',
131 | values: ['bar'],
132 | },
133 | })).toHaveTextContent(`
134 | - Ran Playwright code:
135 | \`\`\`js
136 | // Select options [bar] in Select
137 | await page.getByRole('combobox').selectOption(['bar']);
138 | \`\`\`
139 |
140 | - Page URL: ${server.PREFIX}
141 | - Page Title: Title
142 | - Page Snapshot
143 | \`\`\`yaml
144 | - combobox [ref=e2]:
145 | - option "Foo"
146 | - option "Bar" [selected]
147 | \`\`\`
148 | `);
149 | });
150 |
151 | test('browser_select_option (multiple)', async ({ client, server }) => {
152 | server.setContent('/', `
153 | <title>Title</title>
154 | <select multiple>
155 | <option value="foo">Foo</option>
156 | <option value="bar">Bar</option>
157 | <option value="baz">Baz</option>
158 | </select>
159 | `, 'text/html');
160 |
161 | await client.callTool({
162 | name: 'browser_navigate',
163 | arguments: { url: server.PREFIX },
164 | });
165 |
166 | expect(await client.callTool({
167 | name: 'browser_select_option',
168 | arguments: {
169 | element: 'Select',
170 | ref: 'e2',
171 | values: ['bar', 'baz'],
172 | },
173 | })).toHaveTextContent(`
174 | - Ran Playwright code:
175 | \`\`\`js
176 | // Select options [bar, baz] in Select
177 | await page.getByRole('listbox').selectOption(['bar', 'baz']);
178 | \`\`\`
179 |
180 | - Page URL: ${server.PREFIX}
181 | - Page Title: Title
182 | - Page Snapshot
183 | \`\`\`yaml
184 | - listbox [ref=e2]:
185 | - option "Foo" [ref=e3]
186 | - option "Bar" [selected] [ref=e4]
187 | - option "Baz" [selected] [ref=e5]
188 | \`\`\`
189 | `);
190 | });
191 |
192 | test('browser_type', async ({ client, server }) => {
193 | server.setContent('/', `
194 | <!DOCTYPE html>
195 | <html>
196 | <input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
197 | </html>
198 | `, 'text/html');
199 |
200 | await client.callTool({
201 | name: 'browser_navigate',
202 | arguments: {
203 | url: server.PREFIX,
204 | },
205 | });
206 | await client.callTool({
207 | name: 'browser_type',
208 | arguments: {
209 | element: 'textbox',
210 | ref: 'e2',
211 | text: 'Hi!',
212 | submit: true,
213 | },
214 | });
215 | expect(await client.callTool({
216 | name: 'browser_console_messages',
217 | })).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
218 | });
219 |
220 | test('browser_type (slowly)', async ({ client, server }) => {
221 | server.setContent('/', `
222 | <input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
223 | `, 'text/html');
224 |
225 | await client.callTool({
226 | name: 'browser_navigate',
227 | arguments: {
228 | url: server.PREFIX,
229 | },
230 | });
231 | await client.callTool({
232 | name: 'browser_type',
233 | arguments: {
234 | element: 'textbox',
235 | ref: 'e2',
236 | text: 'Hi!',
237 | submit: true,
238 | slowly: true,
239 | },
240 | });
241 | expect(await client.callTool({
242 | name: 'browser_console_messages',
243 | })).toHaveTextContent([
244 | '[LOG] Key pressed: H Text: ',
245 | '[LOG] Key pressed: i Text: H',
246 | '[LOG] Key pressed: ! Text: Hi',
247 | '[LOG] Key pressed: Enter Text: Hi!',
248 | ].join('\n'));
249 | });
250 |
251 | test('browser_resize', async ({ client, server }) => {
252 | server.setContent('/', `
253 | <title>Resize Test</title>
254 | <body>
255 | <div id="size">Waiting for resize...</div>
256 | <script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
257 | </script>
258 | </body>
259 | `, 'text/html');
260 | await client.callTool({
261 | name: 'browser_navigate',
262 | arguments: { url: server.PREFIX },
263 | });
264 |
265 | const response = await client.callTool({
266 | name: 'browser_resize',
267 | arguments: {
268 | width: 390,
269 | height: 780,
270 | },
271 | });
272 | expect(response).toContainTextContent(`- Ran Playwright code:
273 | \`\`\`js
274 | // Resize browser window to 390x780
275 | await page.setViewportSize({ width: 390, height: 780 });
276 | \`\`\``);
277 | await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
278 | });
279 |
280 | test('old locator error message', async ({ client, server }) => {
281 | server.setContent('/', `
282 | <button>Button 1</button>
283 | <button>Button 2</button>
284 | <script>
285 | document.querySelector('button').addEventListener('click', () => {
286 | document.querySelectorAll('button')[1].remove();
287 | });
288 | </script>
289 | `, 'text/html');
290 |
291 | expect(await client.callTool({
292 | name: 'browser_navigate',
293 | arguments: {
294 | url: server.PREFIX,
295 | },
296 | })).toContainTextContent(`
297 | - button "Button 1" [ref=e2]
298 | - button "Button 2" [ref=e3]
299 | `.trim());
300 |
301 | await client.callTool({
302 | name: 'browser_click',
303 | arguments: {
304 | element: 'Button 1',
305 | ref: 'e2',
306 | },
307 | });
308 |
309 | expect(await client.callTool({
310 | name: 'browser_click',
311 | arguments: {
312 | element: 'Button 2',
313 | ref: 'e3',
314 | },
315 | })).toContainTextContent('Ref not found');
316 | });
317 |
318 | test('visibility: hidden > visible should be shown', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/535' } }, async ({ client, server }) => {
319 | server.setContent('/', `
320 | <div style="visibility: hidden;">
321 | <div style="visibility: visible;">
322 | <button>Button</button>
323 | </div>
324 | </div>
325 | `, 'text/html');
326 |
327 | await client.callTool({
328 | name: 'browser_navigate',
329 | arguments: { url: server.PREFIX },
330 | });
331 |
332 | expect(await client.callTool({
333 | name: 'browser_snapshot'
334 | })).toContainTextContent('- button "Button"');
335 | });
336 |
```
--------------------------------------------------------------------------------
/tests/sse.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'node:fs';
18 | import url from 'node:url';
19 |
20 | import { ChildProcess, spawn } from 'node:child_process';
21 | import path from 'node:path';
22 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
23 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
24 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
25 |
26 | import { test as baseTest, expect } from './fixtures.js';
27 | import type { Config } from '../config.d.ts';
28 |
29 | // NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
30 | const __filename = url.fileURLToPath(import.meta.url);
31 |
32 | const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
33 | serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
34 | let cp: ChildProcess | undefined;
35 | const userDataDir = testInfo.outputPath('user-data-dir');
36 | await use(async (options?: { args?: string[], noPort?: boolean }) => {
37 | if (cp)
38 | throw new Error('Process already running');
39 |
40 | cp = spawn('node', [
41 | path.join(path.dirname(__filename), '../cli.js'),
42 | ...(options?.noPort ? [] : ['--port=0']),
43 | '--user-data-dir=' + userDataDir,
44 | ...(mcpHeadless ? ['--headless'] : []),
45 | ...(options?.args || []),
46 | ], {
47 | stdio: 'pipe',
48 | env: {
49 | ...process.env,
50 | DEBUG: 'pw:mcp:test',
51 | DEBUG_COLORS: '0',
52 | DEBUG_HIDE_DATE: '1',
53 | },
54 | });
55 | let stderr = '';
56 | const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
57 | stderr += data.toString();
58 | const match = stderr.match(/Listening on (http:\/\/.*)/);
59 | if (match)
60 | resolve(match[1]);
61 | }));
62 |
63 | return { url: new URL(url), stderr: () => stderr };
64 | });
65 | cp?.kill('SIGTERM');
66 | },
67 | });
68 |
69 | test('sse transport', async ({ serverEndpoint }) => {
70 | const { url } = await serverEndpoint();
71 | const transport = new SSEClientTransport(url);
72 | const client = new Client({ name: 'test', version: '1.0.0' });
73 | await client.connect(transport);
74 | await client.ping();
75 | });
76 |
77 | test('sse transport (config)', async ({ serverEndpoint }) => {
78 | const config: Config = {
79 | server: {
80 | port: 0,
81 | }
82 | };
83 | const configFile = test.info().outputPath('config.json');
84 | await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
85 |
86 | const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
87 | const transport = new SSEClientTransport(url);
88 | const client = new Client({ name: 'test', version: '1.0.0' });
89 | await client.connect(transport);
90 | await client.ping();
91 | });
92 |
93 | test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
94 | const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
95 |
96 | const transport1 = new SSEClientTransport(url);
97 | const client1 = new Client({ name: 'test', version: '1.0.0' });
98 | await client1.connect(transport1);
99 | await client1.callTool({
100 | name: 'browser_navigate',
101 | arguments: { url: server.HELLO_WORLD },
102 | });
103 | await client1.close();
104 |
105 | const transport2 = new SSEClientTransport(url);
106 | const client2 = new Client({ name: 'test', version: '1.0.0' });
107 | await client2.connect(transport2);
108 | await client2.callTool({
109 | name: 'browser_navigate',
110 | arguments: { url: server.HELLO_WORLD },
111 | });
112 | await client2.close();
113 |
114 | await expect(async () => {
115 | const lines = stderr().split('\n');
116 | expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
117 | expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
118 |
119 | expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
120 | expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
121 |
122 | expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
123 | expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
124 |
125 | expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
126 | expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
127 | }).toPass();
128 | });
129 |
130 | test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
131 | const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
132 |
133 | const transport1 = new SSEClientTransport(url);
134 | const client1 = new Client({ name: 'test', version: '1.0.0' });
135 | await client1.connect(transport1);
136 | await client1.callTool({
137 | name: 'browser_navigate',
138 | arguments: { url: server.HELLO_WORLD },
139 | });
140 |
141 | const transport2 = new SSEClientTransport(url);
142 | const client2 = new Client({ name: 'test', version: '1.0.0' });
143 | await client2.connect(transport2);
144 | await client2.callTool({
145 | name: 'browser_navigate',
146 | arguments: { url: server.HELLO_WORLD },
147 | });
148 | await client1.close();
149 |
150 | const transport3 = new SSEClientTransport(url);
151 | const client3 = new Client({ name: 'test', version: '1.0.0' });
152 | await client3.connect(transport3);
153 | await client3.callTool({
154 | name: 'browser_navigate',
155 | arguments: { url: server.HELLO_WORLD },
156 | });
157 |
158 | await client2.close();
159 | await client3.close();
160 |
161 | await expect(async () => {
162 | const lines = stderr().split('\n');
163 | expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
164 | expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
165 |
166 | expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
167 | expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
168 |
169 | expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
170 | expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
171 |
172 | expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
173 | expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
174 | }).toPass();
175 | });
176 |
177 | test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
178 | const { url, stderr } = await serverEndpoint();
179 |
180 | const transport1 = new SSEClientTransport(url);
181 | const client1 = new Client({ name: 'test', version: '1.0.0' });
182 | await client1.connect(transport1);
183 | await client1.callTool({
184 | name: 'browser_navigate',
185 | arguments: { url: server.HELLO_WORLD },
186 | });
187 | await client1.close();
188 |
189 | const transport2 = new SSEClientTransport(url);
190 | const client2 = new Client({ name: 'test', version: '1.0.0' });
191 | await client2.connect(transport2);
192 | await client2.callTool({
193 | name: 'browser_navigate',
194 | arguments: { url: server.HELLO_WORLD },
195 | });
196 | await client2.close();
197 |
198 | await expect(async () => {
199 | const lines = stderr().split('\n');
200 | expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
201 | expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
202 |
203 | expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
204 | expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
205 |
206 | expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
207 | expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
208 |
209 | expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
210 | expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
211 | }).toPass();
212 | });
213 |
214 | test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
215 | const { url } = await serverEndpoint();
216 |
217 | const transport1 = new SSEClientTransport(url);
218 | const client1 = new Client({ name: 'test', version: '1.0.0' });
219 | await client1.connect(transport1);
220 | await client1.callTool({
221 | name: 'browser_navigate',
222 | arguments: { url: server.HELLO_WORLD },
223 | });
224 |
225 | const transport2 = new SSEClientTransport(url);
226 | const client2 = new Client({ name: 'test', version: '1.0.0' });
227 | await client2.connect(transport2);
228 | const response = await client2.callTool({
229 | name: 'browser_navigate',
230 | arguments: { url: server.HELLO_WORLD },
231 | });
232 | expect(response.isError).toBe(true);
233 | expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
234 |
235 | await client1.close();
236 | await client2.close();
237 | });
238 |
239 | test('streamable http transport', async ({ serverEndpoint }) => {
240 | const { url } = await serverEndpoint();
241 | const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
242 | const client = new Client({ name: 'test', version: '1.0.0' });
243 | await client.connect(transport);
244 | await client.ping();
245 | expect(transport.sessionId, 'has session support').toBeDefined();
246 | });
247 |
```
--------------------------------------------------------------------------------
/src/browserContextFactory.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import fs from 'node:fs';
18 | import net from 'node:net';
19 | import path from 'node:path';
20 | import os from 'node:os';
21 |
22 | import debug from 'debug';
23 | import * as playwright from 'playwright';
24 | import { userDataDir } from './fileUtils.js';
25 |
26 | import type { FullConfig } from './config.js';
27 | import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.js';
28 |
29 | const testDebug = debug('pw:mcp:test');
30 |
31 | export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
32 | if (browserConfig.remoteEndpoint)
33 | return new RemoteContextFactory(browserConfig);
34 | if (browserConfig.cdpEndpoint)
35 | return new CdpContextFactory(browserConfig);
36 | if (browserConfig.isolated)
37 | return new IsolatedContextFactory(browserConfig);
38 | if (browserConfig.browserAgent)
39 | return new BrowserServerContextFactory(browserConfig);
40 | return new PersistentContextFactory(browserConfig);
41 | }
42 |
43 | export interface BrowserContextFactory {
44 | createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
45 | }
46 |
47 | class BaseContextFactory implements BrowserContextFactory {
48 | readonly browserConfig: FullConfig['browser'];
49 | protected _browserPromise: Promise<playwright.Browser> | undefined;
50 | readonly name: string;
51 |
52 | constructor(name: string, browserConfig: FullConfig['browser']) {
53 | this.name = name;
54 | this.browserConfig = browserConfig;
55 | }
56 |
57 | protected async _obtainBrowser(): Promise<playwright.Browser> {
58 | if (this._browserPromise)
59 | return this._browserPromise;
60 | testDebug(`obtain browser (${this.name})`);
61 | this._browserPromise = this._doObtainBrowser();
62 | void this._browserPromise.then(browser => {
63 | browser.on('disconnected', () => {
64 | this._browserPromise = undefined;
65 | });
66 | }).catch(() => {
67 | this._browserPromise = undefined;
68 | });
69 | return this._browserPromise;
70 | }
71 |
72 | protected async _doObtainBrowser(): Promise<playwright.Browser> {
73 | throw new Error('Not implemented');
74 | }
75 |
76 | async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
77 | testDebug(`create browser context (${this.name})`);
78 | const browser = await this._obtainBrowser();
79 | const browserContext = await this._doCreateContext(browser);
80 | return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
81 | }
82 |
83 | protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
84 | throw new Error('Not implemented');
85 | }
86 |
87 | private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
88 | testDebug(`close browser context (${this.name})`);
89 | if (browser.contexts().length === 1)
90 | this._browserPromise = undefined;
91 | await browserContext.close().catch(() => {});
92 | if (browser.contexts().length === 0) {
93 | testDebug(`close browser (${this.name})`);
94 | await browser.close().catch(() => {});
95 | }
96 | }
97 | }
98 |
99 | class IsolatedContextFactory extends BaseContextFactory {
100 | constructor(browserConfig: FullConfig['browser']) {
101 | super('isolated', browserConfig);
102 | }
103 |
104 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
105 | await injectCdpPort(this.browserConfig);
106 | const browserType = playwright[this.browserConfig.browserName];
107 | return browserType.launch({
108 | ...this.browserConfig.launchOptions,
109 | handleSIGINT: false,
110 | handleSIGTERM: false,
111 | }).catch(error => {
112 | if (error.message.includes('Executable doesn\'t exist'))
113 | throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
114 | throw error;
115 | });
116 | }
117 |
118 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
119 | return browser.newContext(this.browserConfig.contextOptions);
120 | }
121 | }
122 |
123 | class CdpContextFactory extends BaseContextFactory {
124 | constructor(browserConfig: FullConfig['browser']) {
125 | super('cdp', browserConfig);
126 | }
127 |
128 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
129 | return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
130 | }
131 |
132 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
133 | return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
134 | }
135 | }
136 |
137 | class RemoteContextFactory extends BaseContextFactory {
138 | constructor(browserConfig: FullConfig['browser']) {
139 | super('remote', browserConfig);
140 | }
141 |
142 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
143 | const url = new URL(this.browserConfig.remoteEndpoint!);
144 | url.searchParams.set('browser', this.browserConfig.browserName);
145 | if (this.browserConfig.launchOptions)
146 | url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
147 | return playwright[this.browserConfig.browserName].connect(String(url));
148 | }
149 |
150 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
151 | return browser.newContext();
152 | }
153 | }
154 |
155 | class PersistentContextFactory implements BrowserContextFactory {
156 | readonly browserConfig: FullConfig['browser'];
157 | private _userDataDirs = new Set<string>();
158 |
159 | constructor(browserConfig: FullConfig['browser']) {
160 | this.browserConfig = browserConfig;
161 | }
162 |
163 | async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
164 | await injectCdpPort(this.browserConfig);
165 | testDebug('create browser context (persistent)');
166 | const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
167 |
168 | this._userDataDirs.add(userDataDir);
169 | testDebug('lock user data dir', userDataDir);
170 |
171 | const browserType = playwright[this.browserConfig.browserName];
172 | for (let i = 0; i < 5; i++) {
173 | try {
174 | const browserContext = await browserType.launchPersistentContext(userDataDir, {
175 | ...this.browserConfig.launchOptions,
176 | ...this.browserConfig.contextOptions,
177 | handleSIGINT: false,
178 | handleSIGTERM: false,
179 | });
180 | const close = () => this._closeBrowserContext(browserContext, userDataDir);
181 | return { browserContext, close };
182 | } catch (error: any) {
183 | if (error.message.includes('Executable doesn\'t exist'))
184 | throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
185 | if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
186 | // User data directory is already in use, try again.
187 | await new Promise(resolve => setTimeout(resolve, 1000));
188 | continue;
189 | }
190 | throw error;
191 | }
192 | }
193 | throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
194 | }
195 |
196 | private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
197 | testDebug('close browser context (persistent)');
198 | testDebug('release user data dir', userDataDir);
199 | await browserContext.close().catch(() => {});
200 | this._userDataDirs.delete(userDataDir);
201 | testDebug('close browser context complete (persistent)');
202 | }
203 |
204 | private async _createUserDataDir() {
205 | let cacheDirectory: string;
206 | if (process.platform === 'linux')
207 | cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
208 | else if (process.platform === 'darwin')
209 | cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
210 | else if (process.platform === 'win32')
211 | cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
212 | else
213 | throw new Error('Unsupported platform: ' + process.platform);
214 | const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
215 | await fs.promises.mkdir(result, { recursive: true });
216 | return result;
217 | }
218 | }
219 |
220 | export class BrowserServerContextFactory extends BaseContextFactory {
221 | constructor(browserConfig: FullConfig['browser']) {
222 | super('persistent', browserConfig);
223 | }
224 |
225 | protected override async _doObtainBrowser(): Promise<playwright.Browser> {
226 | const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
227 | method: 'POST',
228 | body: JSON.stringify({
229 | browserType: this.browserConfig.browserName,
230 | userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
231 | launchOptions: this.browserConfig.launchOptions,
232 | contextOptions: this.browserConfig.contextOptions,
233 | } as LaunchBrowserRequest),
234 | });
235 | const info = await response.json() as BrowserInfo;
236 | if (info.error)
237 | throw new Error(info.error);
238 | return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
239 | }
240 |
241 | protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
242 | return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
243 | }
244 |
245 | private async _createUserDataDir() {
246 | const dir = await userDataDir(this.browserConfig);
247 | await fs.promises.mkdir(dir, { recursive: true });
248 | return dir;
249 | }
250 | }
251 |
252 | async function injectCdpPort(browserConfig: FullConfig['browser']) {
253 | if (browserConfig.browserName === 'chromium')
254 | (browserConfig.launchOptions as any).cdpPort = await findFreePort();
255 | }
256 |
257 | async function findFreePort() {
258 | return new Promise((resolve, reject) => {
259 | const server = net.createServer();
260 | server.listen(0, () => {
261 | const { port } = server.address() as net.AddressInfo;
262 | server.close(() => resolve(port));
263 | });
264 | server.on('error', reject);
265 | });
266 | }
267 |
```
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Copyright (c) Microsoft Corporation.
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | import debug from 'debug';
18 | import * as playwright from 'playwright';
19 |
20 | import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
21 | import { ManualPromise } from './manualPromise.js';
22 | import { Tab } from './tab.js';
23 | import { outputFile } from './config.js';
24 |
25 | import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
26 | import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
27 | import type { FullConfig } from './config.js';
28 | import type { BrowserContextFactory } from './browserContextFactory.js';
29 |
30 | type PendingAction = {
31 | dialogShown: ManualPromise<void>;
32 | };
33 |
34 | const testDebug = debug('pw:mcp:test');
35 |
36 | export class Context {
37 | readonly tools: Tool[];
38 | readonly config: FullConfig;
39 | private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
40 | private _browserContextFactory: BrowserContextFactory;
41 | private _tabs: Tab[] = [];
42 | private _currentTab: Tab | undefined;
43 | private _modalStates: (ModalState & { tab: Tab })[] = [];
44 | private _pendingAction: PendingAction | undefined;
45 | private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
46 | clientVersion: { name: string; version: string; } | undefined;
47 |
48 | constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
49 | this.tools = tools;
50 | this.config = config;
51 | this._browserContextFactory = browserContextFactory;
52 | testDebug('create context');
53 | }
54 |
55 | clientSupportsImages(): boolean {
56 | if (this.config.imageResponses === 'allow')
57 | return true;
58 | if (this.config.imageResponses === 'omit')
59 | return false;
60 | return !this.clientVersion?.name.includes('cursor');
61 | }
62 |
63 | modalStates(): ModalState[] {
64 | return this._modalStates;
65 | }
66 |
67 | setModalState(modalState: ModalState, inTab: Tab) {
68 | this._modalStates.push({ ...modalState, tab: inTab });
69 | }
70 |
71 | clearModalState(modalState: ModalState) {
72 | this._modalStates = this._modalStates.filter(state => state !== modalState);
73 | }
74 |
75 | modalStatesMarkdown(): string[] {
76 | const result: string[] = ['### Modal state'];
77 | if (this._modalStates.length === 0)
78 | result.push('- There is no modal state present');
79 | for (const state of this._modalStates) {
80 | const tool = this.tools.find(tool => tool.clearsModalState === state.type);
81 | result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
82 | }
83 | return result;
84 | }
85 |
86 | tabs(): Tab[] {
87 | return this._tabs;
88 | }
89 |
90 | currentTabOrDie(): Tab {
91 | if (!this._currentTab)
92 | throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
93 | return this._currentTab;
94 | }
95 |
96 | async newTab(): Promise<Tab> {
97 | const { browserContext } = await this._ensureBrowserContext();
98 | const page = await browserContext.newPage();
99 | this._currentTab = this._tabs.find(t => t.page === page)!;
100 | return this._currentTab;
101 | }
102 |
103 | async selectTab(index: number) {
104 | this._currentTab = this._tabs[index - 1];
105 | await this._currentTab.page.bringToFront();
106 | }
107 |
108 | async ensureTab(): Promise<Tab> {
109 | const { browserContext } = await this._ensureBrowserContext();
110 | if (!this._currentTab)
111 | await browserContext.newPage();
112 | return this._currentTab!;
113 | }
114 |
115 | async listTabsMarkdown(): Promise<string> {
116 | if (!this._tabs.length)
117 | return '### No tabs open';
118 | const lines: string[] = ['### Open tabs'];
119 | for (let i = 0; i < this._tabs.length; i++) {
120 | const tab = this._tabs[i];
121 | const title = await tab.title();
122 | const url = tab.page.url();
123 | const current = tab === this._currentTab ? ' (current)' : '';
124 | lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
125 | }
126 | return lines.join('\n');
127 | }
128 |
129 | async closeTab(index: number | undefined) {
130 | const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
131 | await tab?.page.close();
132 | return await this.listTabsMarkdown();
133 | }
134 |
135 | async run(tool: Tool, params: Record<string, unknown> | undefined) {
136 | // Tab management is done outside of the action() call.
137 | const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
138 | const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
139 | const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
140 |
141 | if (resultOverride)
142 | return resultOverride;
143 |
144 | if (!this._currentTab) {
145 | return {
146 | content: [{
147 | type: 'text',
148 | text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
149 | }],
150 | };
151 | }
152 |
153 | const tab = this.currentTabOrDie();
154 | // TODO: race against modal dialogs to resolve clicks.
155 | let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
156 | try {
157 | if (waitForNetwork)
158 | actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
159 | else
160 | actionResult = await racingAction?.() ?? undefined;
161 | } finally {
162 | if (captureSnapshot && !this._javaScriptBlocked())
163 | await tab.captureSnapshot();
164 | }
165 |
166 | const result: string[] = [];
167 | result.push(`- Ran Playwright code:
168 | \`\`\`js
169 | ${code.join('\n')}
170 | \`\`\`
171 | `);
172 |
173 | if (this.modalStates().length) {
174 | result.push(...this.modalStatesMarkdown());
175 | return {
176 | content: [{
177 | type: 'text',
178 | text: result.join('\n'),
179 | }],
180 | };
181 | }
182 |
183 | if (this._downloads.length) {
184 | result.push('', '### Downloads');
185 | for (const entry of this._downloads) {
186 | if (entry.finished)
187 | result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
188 | else
189 | result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
190 | }
191 | result.push('');
192 | }
193 |
194 | if (this.tabs().length > 1)
195 | result.push(await this.listTabsMarkdown(), '');
196 |
197 | if (this.tabs().length > 1)
198 | result.push('### Current tab');
199 |
200 | result.push(
201 | `- Page URL: ${tab.page.url()}`,
202 | `- Page Title: ${await tab.title()}`
203 | );
204 |
205 | if (captureSnapshot && tab.hasSnapshot())
206 | result.push(tab.snapshotOrDie().text());
207 |
208 | const content = actionResult?.content ?? [];
209 |
210 | return {
211 | content: [
212 | ...content,
213 | {
214 | type: 'text',
215 | text: result.join('\n'),
216 | }
217 | ],
218 | };
219 | }
220 |
221 | async waitForTimeout(time: number) {
222 | if (!this._currentTab || this._javaScriptBlocked()) {
223 | await new Promise(f => setTimeout(f, time));
224 | return;
225 | }
226 |
227 | await callOnPageNoTrace(this._currentTab.page, page => {
228 | return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
229 | });
230 | }
231 |
232 | private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
233 | this._pendingAction = {
234 | dialogShown: new ManualPromise(),
235 | };
236 |
237 | let result: ToolActionResult | undefined;
238 | try {
239 | await Promise.race([
240 | action().then(r => result = r),
241 | this._pendingAction.dialogShown,
242 | ]);
243 | } finally {
244 | this._pendingAction = undefined;
245 | }
246 | return result;
247 | }
248 |
249 | private _javaScriptBlocked(): boolean {
250 | return this._modalStates.some(state => state.type === 'dialog');
251 | }
252 |
253 | dialogShown(tab: Tab, dialog: playwright.Dialog) {
254 | this.setModalState({
255 | type: 'dialog',
256 | description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
257 | dialog,
258 | }, tab);
259 | this._pendingAction?.dialogShown.resolve();
260 | }
261 |
262 | async downloadStarted(tab: Tab, download: playwright.Download) {
263 | const entry = {
264 | download,
265 | finished: false,
266 | outputFile: await outputFile(this.config, download.suggestedFilename())
267 | };
268 | this._downloads.push(entry);
269 | await download.saveAs(entry.outputFile);
270 | entry.finished = true;
271 | }
272 |
273 | private _onPageCreated(page: playwright.Page) {
274 | const tab = new Tab(this, page, tab => this._onPageClosed(tab));
275 | this._tabs.push(tab);
276 | if (!this._currentTab)
277 | this._currentTab = tab;
278 | }
279 |
280 | private _onPageClosed(tab: Tab) {
281 | this._modalStates = this._modalStates.filter(state => state.tab !== tab);
282 | const index = this._tabs.indexOf(tab);
283 | if (index === -1)
284 | return;
285 | this._tabs.splice(index, 1);
286 |
287 | if (this._currentTab === tab)
288 | this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
289 | if (!this._tabs.length)
290 | void this.close();
291 | }
292 |
293 | async close() {
294 | if (!this._browserContextPromise)
295 | return;
296 |
297 | testDebug('close context');
298 |
299 | const promise = this._browserContextPromise;
300 | this._browserContextPromise = undefined;
301 |
302 | await promise.then(async ({ browserContext, close }) => {
303 | if (this.config.saveTrace)
304 | await browserContext.tracing.stop();
305 | await close();
306 | });
307 | }
308 |
309 | private async _setupRequestInterception(context: playwright.BrowserContext) {
310 | if (this.config.network?.allowedOrigins?.length) {
311 | await context.route('**', route => route.abort('blockedbyclient'));
312 |
313 | for (const origin of this.config.network.allowedOrigins)
314 | await context.route(`*://${origin}/**`, route => route.continue());
315 | }
316 |
317 | if (this.config.network?.blockedOrigins?.length) {
318 | for (const origin of this.config.network.blockedOrigins)
319 | await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
320 | }
321 | }
322 |
323 | private _ensureBrowserContext() {
324 | if (!this._browserContextPromise) {
325 | this._browserContextPromise = this._setupBrowserContext();
326 | this._browserContextPromise.catch(() => {
327 | this._browserContextPromise = undefined;
328 | });
329 | }
330 | return this._browserContextPromise;
331 | }
332 |
333 | private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
334 | // TODO: move to the browser context factory to make it based on isolation mode.
335 | const result = await this._browserContextFactory.createContext();
336 | const { browserContext } = result;
337 | await this._setupRequestInterception(browserContext);
338 | for (const page of browserContext.pages())
339 | this._onPageCreated(page);
340 | browserContext.on('page', page => this._onPageCreated(page));
341 | if (this.config.saveTrace) {
342 | await browserContext.tracing.start({
343 | name: 'trace',
344 | screenshots: false,
345 | snapshots: true,
346 | sources: false,
347 | });
348 | }
349 | return result;
350 | }
351 | }
352 |
```