#
tokens: 43724/50000 19/95 files (page 2/3)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 2/3FirstPrevNextLast