@playwright/mcp 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
10
10
 
11
11
  ### Requirements
12
12
  - Node.js 18 or newer
13
- - VS Code, Cursor, Windsurf, Claude Desktop or any other MCP client
13
+ - VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
14
14
 
15
15
  <!--
16
16
  // Generate using:
@@ -52,6 +52,12 @@ After installation, the Playwright MCP server will be available for use with you
52
52
  <details>
53
53
  <summary><b>Install in Cursor</b></summary>
54
54
 
55
+ #### Click the button to install:
56
+
57
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
58
+
59
+ #### Or install manually:
60
+
55
61
  Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
56
62
 
57
63
  ```js
@@ -71,7 +77,7 @@ Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, u
71
77
  <details>
72
78
  <summary><b>Install in Windsurf</b></summary>
73
79
 
74
- Follow Windsuff MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
80
+ Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use following configuration:
75
81
 
76
82
  ```js
77
83
  {
@@ -106,6 +112,68 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
106
112
  ```
107
113
  </details>
108
114
 
115
+ <details>
116
+ <summary><b>Install in Claude Code</b></summary>
117
+
118
+ Use the Claude Code CLI to add the Playwright MCP server:
119
+
120
+ ```bash
121
+ claude mcp add playwright npx @playwright/mcp@latest
122
+ ```
123
+ </details>
124
+
125
+ <details>
126
+ <summary><b>Install in Goose</b></summary>
127
+
128
+ #### Click the button to install:
129
+
130
+ [![Install in Goose](https://block.github.io/goose/img/extension-install-dark.svg)](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
131
+
132
+ #### Or install manually:
133
+
134
+ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
135
+ </details>
136
+
137
+ <details>
138
+ <summary><b>Install in Qodo Gen</b></summary>
139
+
140
+ Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
141
+
142
+ ```js
143
+ {
144
+ "mcpServers": {
145
+ "playwright": {
146
+ "command": "npx",
147
+ "args": [
148
+ "@playwright/mcp@latest"
149
+ ]
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ Click <code>Save</code>.
156
+ </details>
157
+
158
+ <details>
159
+ <summary><b>Install in Gemini CLI</b></summary>
160
+
161
+ Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use following configuration:
162
+
163
+ ```js
164
+ {
165
+ "mcpServers": {
166
+ "playwright": {
167
+ "command": "npx",
168
+ "args": [
169
+ "@playwright/mcp@latest"
170
+ ]
171
+ }
172
+ }
173
+ }
174
+ ```
175
+ </details>
176
+
109
177
  ### Configuration
110
178
 
111
179
  Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
@@ -124,6 +192,7 @@ Playwright MCP server supports following arguments. They can be provided in the
124
192
  --block-service-workers block service workers
125
193
  --browser <browser> browser or chrome channel to use, possible
126
194
  values: chrome, firefox, webkit, msedge.
195
+ --browser-agent <endpoint> Use browser agent (experimental).
127
196
  --caps <caps> comma-separated list of capabilities to enable,
128
197
  possible values: tabs, pdf, history, wait, files,
129
198
  install. Default is all.
@@ -288,9 +357,10 @@ npx @playwright/mcp@latest --config path/to/config.json
288
357
  };
289
358
 
290
359
  /**
291
- * Do not send image responses to the client.
360
+ * Whether to send image responses to the client. Can be "allow", "omit", or "auto".
361
+ * Defaults to "auto", images are omitted for Cursor clients and sent for all other clients.
292
362
  */
293
- noImageResponses?: boolean;
363
+ imageResponses?: 'allow' | 'omit' | 'auto';
294
364
  }
295
365
  ```
296
366
  </details>
@@ -354,7 +424,7 @@ http.createServer(async (req, res) => {
354
424
  // Creates a headless Playwright MCP server with SSE transport
355
425
  const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
356
426
  const transport = new SSEServerTransport('/messages', res);
357
- await connection.connect(transport);
427
+ await connection.sever.connect(transport);
358
428
 
359
429
  // ...
360
430
  });
@@ -408,6 +478,7 @@ X Y coordinate space, based on the provided screenshot.
408
478
  - Parameters:
409
479
  - `element` (string): Human-readable element description used to obtain permission to interact with the element
410
480
  - `ref` (string): Exact target element reference from the page snapshot
481
+ - `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
411
482
  - Read-only: **false**
412
483
 
413
484
  <!-- NOTE: This has been generated via update-readme.js -->
package/config.d.ts CHANGED
@@ -23,6 +23,11 @@ export type Config = {
23
23
  * The browser to use.
24
24
  */
25
25
  browser?: {
26
+ /**
27
+ * Use browser agent (experimental).
28
+ */
29
+ browserAgent?: string;
30
+
26
31
  /**
27
32
  * The type of browser to use.
28
33
  */
package/index.d.ts CHANGED
@@ -16,13 +16,11 @@
16
16
  */
17
17
 
18
18
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
- import type { Config } from './config';
20
- import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
19
+ import type { Config } from './config.js';
21
20
  import type { BrowserContext } from 'playwright';
22
21
 
23
22
  export type Connection = {
24
23
  server: Server;
25
- connect(transport: Transport): Promise<void>;
26
24
  close(): Promise<void>;
27
25
  };
28
26
 
@@ -14,10 +14,12 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import fs from 'node:fs';
17
- import os from 'node:os';
17
+ import net from 'node:net';
18
18
  import path from 'node:path';
19
+ import os from 'node:os';
19
20
  import debug from 'debug';
20
21
  import * as playwright from 'playwright';
22
+ import { userDataDir } from './fileUtils.js';
21
23
  const testDebug = debug('pw:mcp:test');
22
24
  export function contextFactory(browserConfig) {
23
25
  if (browserConfig.remoteEndpoint)
@@ -26,6 +28,8 @@ export function contextFactory(browserConfig) {
26
28
  return new CdpContextFactory(browserConfig);
27
29
  if (browserConfig.isolated)
28
30
  return new IsolatedContextFactory(browserConfig);
31
+ if (browserConfig.browserAgent)
32
+ return new BrowserServerContextFactory(browserConfig);
29
33
  return new PersistentContextFactory(browserConfig);
30
34
  }
31
35
  class BaseContextFactory {
@@ -78,6 +82,7 @@ class IsolatedContextFactory extends BaseContextFactory {
78
82
  super('isolated', browserConfig);
79
83
  }
80
84
  async _doObtainBrowser() {
85
+ await injectCdpPort(this.browserConfig);
81
86
  const browserType = playwright[this.browserConfig.browserName];
82
87
  return browserType.launch({
83
88
  ...this.browserConfig.launchOptions,
@@ -126,6 +131,7 @@ class PersistentContextFactory {
126
131
  this.browserConfig = browserConfig;
127
132
  }
128
133
  async createContext() {
134
+ await injectCdpPort(this.browserConfig);
129
135
  testDebug('create browser context (persistent)');
130
136
  const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
131
137
  this._userDataDirs.add(userDataDir);
@@ -177,3 +183,45 @@ class PersistentContextFactory {
177
183
  return result;
178
184
  }
179
185
  }
186
+ export class BrowserServerContextFactory extends BaseContextFactory {
187
+ constructor(browserConfig) {
188
+ super('persistent', browserConfig);
189
+ }
190
+ async _doObtainBrowser() {
191
+ const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
192
+ method: 'POST',
193
+ body: JSON.stringify({
194
+ browserType: this.browserConfig.browserName,
195
+ userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
196
+ launchOptions: this.browserConfig.launchOptions,
197
+ contextOptions: this.browserConfig.contextOptions,
198
+ }),
199
+ });
200
+ const info = await response.json();
201
+ if (info.error)
202
+ throw new Error(info.error);
203
+ return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
204
+ }
205
+ async _doCreateContext(browser) {
206
+ return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
207
+ }
208
+ async _createUserDataDir() {
209
+ const dir = await userDataDir(this.browserConfig);
210
+ await fs.promises.mkdir(dir, { recursive: true });
211
+ return dir;
212
+ }
213
+ }
214
+ async function injectCdpPort(browserConfig) {
215
+ if (browserConfig.browserName === 'chromium')
216
+ browserConfig.launchOptions.cdpPort = await findFreePort();
217
+ }
218
+ async function findFreePort() {
219
+ return new Promise((resolve, reject) => {
220
+ const server = net.createServer();
221
+ server.listen(0, () => {
222
+ const { port } = server.address();
223
+ server.close(() => resolve(port));
224
+ });
225
+ server.on('error', reject);
226
+ });
227
+ }
@@ -0,0 +1,151 @@
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
+ /* eslint-disable no-console */
17
+ import net from 'net';
18
+ import { program } from 'commander';
19
+ import playwright from 'playwright';
20
+ import { HttpServer } from './httpServer.js';
21
+ import { packageJSON } from './package.js';
22
+ class BrowserServer {
23
+ _server = new HttpServer();
24
+ _entries = [];
25
+ constructor() {
26
+ this._setupExitHandler();
27
+ }
28
+ async start(port) {
29
+ await this._server.start({ port });
30
+ this._server.routePath('/json/list', (req, res) => {
31
+ this._handleJsonList(res);
32
+ });
33
+ this._server.routePath('/json/launch', async (req, res) => {
34
+ void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
35
+ });
36
+ this._setEntries([]);
37
+ }
38
+ _handleJsonList(res) {
39
+ const list = this._entries.map(browser => browser.info);
40
+ res.end(JSON.stringify(list));
41
+ }
42
+ async _handleLaunchBrowser(req, res) {
43
+ const request = await readBody(req);
44
+ let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
45
+ if (!info || info.error)
46
+ info = await this._newBrowser(request);
47
+ res.end(JSON.stringify(info));
48
+ }
49
+ async _newBrowser(request) {
50
+ const cdpPort = await findFreePort();
51
+ request.launchOptions.cdpPort = cdpPort;
52
+ const info = {
53
+ browserType: request.browserType,
54
+ userDataDir: request.userDataDir,
55
+ cdpPort,
56
+ launchOptions: request.launchOptions,
57
+ contextOptions: request.contextOptions,
58
+ };
59
+ const browserType = playwright[request.browserType];
60
+ const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
61
+ ...request.launchOptions,
62
+ ...request.contextOptions,
63
+ handleSIGINT: false,
64
+ handleSIGTERM: false,
65
+ }).then(context => {
66
+ return { browser: context.browser(), error: undefined };
67
+ }).catch(error => {
68
+ return { browser: undefined, error: error.message };
69
+ });
70
+ this._setEntries([...this._entries, {
71
+ browser,
72
+ info: {
73
+ browserType: request.browserType,
74
+ userDataDir: request.userDataDir,
75
+ cdpPort,
76
+ launchOptions: request.launchOptions,
77
+ contextOptions: request.contextOptions,
78
+ error,
79
+ },
80
+ }]);
81
+ browser?.on('disconnected', () => {
82
+ this._setEntries(this._entries.filter(entry => entry.browser !== browser));
83
+ });
84
+ return info;
85
+ }
86
+ _updateReport() {
87
+ // Clear the current line and move cursor to top of screen
88
+ process.stdout.write('\x1b[2J\x1b[H');
89
+ process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
90
+ process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
91
+ if (this._entries.length === 0) {
92
+ process.stdout.write('No browsers currently running\n');
93
+ return;
94
+ }
95
+ process.stdout.write('Running browsers:\n');
96
+ for (const entry of this._entries) {
97
+ const status = entry.browser ? 'running' : 'error';
98
+ const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
99
+ process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
100
+ if (entry.info.error)
101
+ process.stdout.write(` Error: ${entry.info.error}\n`);
102
+ }
103
+ }
104
+ _setEntries(entries) {
105
+ this._entries = entries;
106
+ this._updateReport();
107
+ }
108
+ _setupExitHandler() {
109
+ let isExiting = false;
110
+ const handleExit = async () => {
111
+ if (isExiting)
112
+ return;
113
+ isExiting = true;
114
+ setTimeout(() => process.exit(0), 15000);
115
+ for (const entry of this._entries)
116
+ await entry.browser?.close().catch(() => { });
117
+ process.exit(0);
118
+ };
119
+ process.stdin.on('close', handleExit);
120
+ process.on('SIGINT', handleExit);
121
+ process.on('SIGTERM', handleExit);
122
+ }
123
+ }
124
+ program
125
+ .name('browser-agent')
126
+ .option('-p, --port <port>', 'Port to listen on', '9224')
127
+ .action(async (options) => {
128
+ await main(options);
129
+ });
130
+ void program.parseAsync(process.argv);
131
+ async function main(options) {
132
+ const server = new BrowserServer();
133
+ await server.start(+options.port);
134
+ }
135
+ function readBody(req) {
136
+ return new Promise((resolve, reject) => {
137
+ const chunks = [];
138
+ req.on('data', (chunk) => chunks.push(chunk));
139
+ req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
140
+ });
141
+ }
142
+ async function findFreePort() {
143
+ return new Promise((resolve, reject) => {
144
+ const server = net.createServer();
145
+ server.listen(0, () => {
146
+ const { port } = server.address();
147
+ server.close(() => resolve(port));
148
+ });
149
+ server.on('error', reject);
150
+ });
151
+ }
package/lib/config.js CHANGED
@@ -14,7 +14,6 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import fs from 'fs';
17
- import net from 'net';
18
17
  import os from 'os';
19
18
  import path from 'path';
20
19
  import { devices } from 'playwright';
@@ -48,8 +47,6 @@ export async function resolveCLIConfig(cliOptions) {
48
47
  // Derive artifact output directory from config.outputDir
49
48
  if (result.saveTrace)
50
49
  result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
51
- if (result.browser.browserName === 'chromium')
52
- result.browser.launchOptions.cdpPort = await findFreePort();
53
50
  return result;
54
51
  }
55
52
  export async function configFromCLIOptions(cliOptions) {
@@ -91,6 +88,8 @@ export async function configFromCLIOptions(cliOptions) {
91
88
  if (cliOptions.proxyBypass)
92
89
  launchOptions.proxy.bypass = cliOptions.proxyBypass;
93
90
  }
91
+ if (cliOptions.device && cliOptions.cdpEndpoint)
92
+ throw new Error('Device emulation is not supported with cdpEndpoint.');
94
93
  // Context options
95
94
  const contextOptions = cliOptions.device ? devices[cliOptions.device] : {};
96
95
  if (cliOptions.storageState)
@@ -114,6 +113,7 @@ export async function configFromCLIOptions(cliOptions) {
114
113
  contextOptions.serviceWorkers = 'block';
115
114
  const result = {
116
115
  browser: {
116
+ browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
117
117
  browserName,
118
118
  isolated: cliOptions.isolated,
119
119
  userDataDir: cliOptions.userDataDir,
@@ -137,16 +137,6 @@ export async function configFromCLIOptions(cliOptions) {
137
137
  };
138
138
  return result;
139
139
  }
140
- async function findFreePort() {
141
- return new Promise((resolve, reject) => {
142
- const server = net.createServer();
143
- server.listen(0, () => {
144
- const { port } = server.address();
145
- server.close(() => resolve(port));
146
- });
147
- server.on('error', reject);
148
- });
149
- }
150
140
  async function loadConfig(configFile) {
151
141
  if (!configFile)
152
142
  return {};
@@ -167,6 +157,8 @@ function pickDefined(obj) {
167
157
  }
168
158
  function mergeConfig(base, overrides) {
169
159
  const browser = {
160
+ ...pickDefined(base.browser),
161
+ ...pickDefined(overrides.browser),
170
162
  browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
171
163
  isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
172
164
  launchOptions: {
@@ -178,9 +170,6 @@ function mergeConfig(base, overrides) {
178
170
  ...pickDefined(base.browser?.contextOptions),
179
171
  ...pickDefined(overrides.browser?.contextOptions),
180
172
  },
181
- userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
182
- cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
183
- remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
184
173
  };
185
174
  if (browser.browserName !== 'chromium' && browser.launchOptions)
186
175
  delete browser.launchOptions.channel;
package/lib/context.js CHANGED
@@ -67,7 +67,7 @@ export class Context {
67
67
  }
68
68
  currentTabOrDie() {
69
69
  if (!this._currentTab)
70
- throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
70
+ throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
71
71
  return this._currentTab;
72
72
  }
73
73
  async newTab() {
@@ -0,0 +1,32 @@
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
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ export function cacheDir() {
19
+ let cacheDirectory;
20
+ if (process.platform === 'linux')
21
+ cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
22
+ else if (process.platform === 'darwin')
23
+ cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
24
+ else if (process.platform === 'win32')
25
+ cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
26
+ else
27
+ throw new Error('Unsupported platform: ' + process.platform);
28
+ return path.join(cacheDirectory, 'ms-playwright');
29
+ }
30
+ export async function userDataDir(browserConfig) {
31
+ return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
32
+ }
@@ -0,0 +1,201 @@
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
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import http from 'http';
19
+ import mime from 'mime';
20
+ import { ManualPromise } from './manualPromise.js';
21
+ export class HttpServer {
22
+ _server;
23
+ _urlPrefixPrecise = '';
24
+ _urlPrefixHumanReadable = '';
25
+ _port = 0;
26
+ _routes = [];
27
+ constructor() {
28
+ this._server = http.createServer(this._onRequest.bind(this));
29
+ decorateServer(this._server);
30
+ }
31
+ server() {
32
+ return this._server;
33
+ }
34
+ routePrefix(prefix, handler) {
35
+ this._routes.push({ prefix, handler });
36
+ }
37
+ routePath(path, handler) {
38
+ this._routes.push({ exact: path, handler });
39
+ }
40
+ port() {
41
+ return this._port;
42
+ }
43
+ async _tryStart(port, host) {
44
+ const errorPromise = new ManualPromise();
45
+ const errorListener = (error) => errorPromise.reject(error);
46
+ this._server.on('error', errorListener);
47
+ try {
48
+ this._server.listen(port, host);
49
+ await Promise.race([
50
+ new Promise(cb => this._server.once('listening', cb)),
51
+ errorPromise,
52
+ ]);
53
+ }
54
+ finally {
55
+ this._server.removeListener('error', errorListener);
56
+ }
57
+ }
58
+ async start(options = {}) {
59
+ const host = options.host || 'localhost';
60
+ if (options.preferredPort) {
61
+ try {
62
+ await this._tryStart(options.preferredPort, host);
63
+ }
64
+ catch (e) {
65
+ if (!e || !e.message || !e.message.includes('EADDRINUSE'))
66
+ throw e;
67
+ await this._tryStart(undefined, host);
68
+ }
69
+ }
70
+ else {
71
+ await this._tryStart(options.port, host);
72
+ }
73
+ const address = this._server.address();
74
+ if (typeof address === 'string') {
75
+ this._urlPrefixPrecise = address;
76
+ this._urlPrefixHumanReadable = address;
77
+ }
78
+ else {
79
+ this._port = address.port;
80
+ const resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
81
+ this._urlPrefixPrecise = `http://${resolvedHost}:${address.port}`;
82
+ this._urlPrefixHumanReadable = `http://${host}:${address.port}`;
83
+ }
84
+ }
85
+ async stop() {
86
+ await new Promise(cb => this._server.close(cb));
87
+ }
88
+ urlPrefix(purpose) {
89
+ return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
90
+ }
91
+ serveFile(request, response, absoluteFilePath, headers) {
92
+ try {
93
+ for (const [name, value] of Object.entries(headers || {}))
94
+ response.setHeader(name, value);
95
+ if (request.headers.range)
96
+ this._serveRangeFile(request, response, absoluteFilePath);
97
+ else
98
+ this._serveFile(response, absoluteFilePath);
99
+ return true;
100
+ }
101
+ catch (e) {
102
+ return false;
103
+ }
104
+ }
105
+ _serveFile(response, absoluteFilePath) {
106
+ const content = fs.readFileSync(absoluteFilePath);
107
+ response.statusCode = 200;
108
+ const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
109
+ response.setHeader('Content-Type', contentType);
110
+ response.setHeader('Content-Length', content.byteLength);
111
+ response.end(content);
112
+ }
113
+ _serveRangeFile(request, response, absoluteFilePath) {
114
+ const range = request.headers.range;
115
+ if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
116
+ response.statusCode = 400;
117
+ return response.end('Bad request');
118
+ }
119
+ // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
120
+ const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
121
+ // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
122
+ let start;
123
+ let end;
124
+ const size = fs.statSync(absoluteFilePath).size;
125
+ if (startStr !== '' && endStr === '') {
126
+ // No end specified: use the whole file
127
+ start = +startStr;
128
+ end = size - 1;
129
+ }
130
+ else if (startStr === '' && endStr !== '') {
131
+ // No start specified: calculate start manually
132
+ start = size - +endStr;
133
+ end = size - 1;
134
+ }
135
+ else {
136
+ start = +startStr;
137
+ end = +endStr;
138
+ }
139
+ // Handle unavailable range request
140
+ if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
141
+ // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
142
+ response.writeHead(416, {
143
+ 'Content-Range': `bytes */${size}`
144
+ });
145
+ return response.end();
146
+ }
147
+ // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
148
+ response.writeHead(206, {
149
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
150
+ 'Accept-Ranges': 'bytes',
151
+ 'Content-Length': end - start + 1,
152
+ 'Content-Type': mime.getType(path.extname(absoluteFilePath)),
153
+ });
154
+ const readable = fs.createReadStream(absoluteFilePath, { start, end });
155
+ readable.pipe(response);
156
+ }
157
+ _onRequest(request, response) {
158
+ if (request.method === 'OPTIONS') {
159
+ response.writeHead(200);
160
+ response.end();
161
+ return;
162
+ }
163
+ request.on('error', () => response.end());
164
+ try {
165
+ if (!request.url) {
166
+ response.end();
167
+ return;
168
+ }
169
+ const url = new URL('http://localhost' + request.url);
170
+ for (const route of this._routes) {
171
+ if (route.exact && url.pathname === route.exact) {
172
+ route.handler(request, response);
173
+ return;
174
+ }
175
+ if (route.prefix && url.pathname.startsWith(route.prefix)) {
176
+ route.handler(request, response);
177
+ return;
178
+ }
179
+ }
180
+ response.statusCode = 404;
181
+ response.end();
182
+ }
183
+ catch (e) {
184
+ response.end();
185
+ }
186
+ }
187
+ }
188
+ function decorateServer(server) {
189
+ const sockets = new Set();
190
+ server.on('connection', socket => {
191
+ sockets.add(socket);
192
+ socket.once('close', () => sockets.delete(socket));
193
+ });
194
+ const close = server.close;
195
+ server.close = (callback) => {
196
+ for (const socket of sockets)
197
+ socket.destroy();
198
+ sockets.clear();
199
+ return close.call(server, callback);
200
+ };
201
+ }
package/lib/program.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { program } from 'commander';
17
17
  // @ts-ignore
18
18
  import { startTraceViewerServer } from 'playwright-core/lib/server';
19
- import { startHttpTransport, startStdioTransport } from './transport.js';
19
+ import { startHttpServer, startHttpTransport, startStdioTransport } from './transport.js';
20
20
  import { resolveCLIConfig } from './config.js';
21
21
  import { Server } from './server.js';
22
22
  import { packageJSON } from './package.js';
@@ -27,6 +27,7 @@ program
27
27
  .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)
28
28
  .option('--block-service-workers', 'block service workers')
29
29
  .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
30
+ .option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
30
31
  .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
31
32
  .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
32
33
  .option('--config <path>', 'path to the configuration file.')
@@ -50,10 +51,11 @@ program
50
51
  .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
51
52
  .action(async (options) => {
52
53
  const config = await resolveCLIConfig(options);
54
+ const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
53
55
  const server = new Server(config);
54
56
  server.setupExitWatchdog();
55
- if (config.server.port !== undefined)
56
- startHttpTransport(server);
57
+ if (httpServer)
58
+ startHttpTransport(httpServer, server);
57
59
  else
58
60
  await startStdioTransport(server);
59
61
  if (config.saveTrace) {
package/lib/tab.js CHANGED
@@ -73,7 +73,7 @@ export class Tab {
73
73
  // on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
74
74
  const download = await Promise.race([
75
75
  downloadEvent,
76
- new Promise(resolve => setTimeout(resolve, 500)),
76
+ new Promise(resolve => setTimeout(resolve, 1000)),
77
77
  ]);
78
78
  if (!download)
79
79
  throw e;
@@ -39,25 +39,33 @@ const elementSchema = z.object({
39
39
  element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
40
40
  ref: z.string().describe('Exact target element reference from the page snapshot'),
41
41
  });
42
+ const clickSchema = elementSchema.extend({
43
+ doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
44
+ });
42
45
  const click = defineTool({
43
46
  capability: 'core',
44
47
  schema: {
45
48
  name: 'browser_click',
46
49
  title: 'Click',
47
50
  description: 'Perform click on a web page',
48
- inputSchema: elementSchema,
51
+ inputSchema: clickSchema,
49
52
  type: 'destructive',
50
53
  },
51
54
  handle: async (context, params) => {
52
55
  const tab = context.currentTabOrDie();
53
56
  const locator = tab.snapshotOrDie().refLocator(params);
54
- const code = [
55
- `// Click ${params.element}`,
56
- `await page.${await generateLocator(locator)}.click();`
57
- ];
57
+ const code = [];
58
+ if (params.doubleClick) {
59
+ code.push(`// Double click ${params.element}`);
60
+ code.push(`await page.${await generateLocator(locator)}.dblclick();`);
61
+ }
62
+ else {
63
+ code.push(`// Click ${params.element}`);
64
+ code.push(`await page.${await generateLocator(locator)}.click();`);
65
+ }
58
66
  return {
59
67
  code,
60
- action: () => locator.click(),
68
+ action: () => params.doubleClick ? locator.dblclick() : locator.click(),
61
69
  captureSnapshot: true,
62
70
  waitForNetwork: true,
63
71
  };
@@ -66,7 +66,14 @@ export function sanitizeForFilePath(s) {
66
66
  return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
67
67
  }
68
68
  export async function generateLocator(locator) {
69
- return locator._generateLocatorString();
69
+ try {
70
+ return await locator._generateLocatorString();
71
+ }
72
+ catch (e) {
73
+ if (e instanceof Error && /locator._generateLocatorString: No element matching locator/.test(e.message))
74
+ throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
75
+ throw e;
76
+ }
70
77
  }
71
78
  export async function callOnPageNoTrace(page, callback) {
72
79
  return await page._wrapApiCall(() => callback(page), { internal: true });
package/lib/transport.js CHANGED
@@ -83,44 +83,51 @@ async function handleStreamable(server, req, res, sessions) {
83
83
  res.statusCode = 400;
84
84
  res.end('Invalid request');
85
85
  }
86
- export function startHttpTransport(server) {
86
+ export async function startHttpServer(config) {
87
+ const { host, port } = config;
88
+ const httpServer = http.createServer();
89
+ await new Promise((resolve, reject) => {
90
+ httpServer.on('error', reject);
91
+ httpServer.listen(port, host, () => {
92
+ resolve();
93
+ httpServer.removeListener('error', reject);
94
+ });
95
+ });
96
+ return httpServer;
97
+ }
98
+ export function startHttpTransport(httpServer, mcpServer) {
87
99
  const sseSessions = new Map();
88
100
  const streamableSessions = new Map();
89
- const httpServer = http.createServer(async (req, res) => {
101
+ httpServer.on('request', async (req, res) => {
90
102
  const url = new URL(`http://localhost${req.url}`);
91
103
  if (url.pathname.startsWith('/mcp'))
92
- await handleStreamable(server, req, res, streamableSessions);
104
+ await handleStreamable(mcpServer, req, res, streamableSessions);
93
105
  else
94
- await handleSSE(server, req, res, url, sseSessions);
106
+ await handleSSE(mcpServer, req, res, url, sseSessions);
95
107
  });
96
- const { host, port } = server.config.server;
97
- httpServer.listen(port, host, () => {
98
- const address = httpServer.address();
99
- assert(address, 'Could not bind server socket');
100
- let url;
101
- if (typeof address === 'string') {
102
- url = address;
103
- }
104
- else {
105
- const resolvedPort = address.port;
106
- let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
107
- if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
108
- resolvedHost = 'localhost';
109
- url = `http://${resolvedHost}:${resolvedPort}`;
110
- }
111
- const message = [
112
- `Listening on ${url}`,
113
- 'Put this in your client config:',
114
- JSON.stringify({
115
- 'mcpServers': {
116
- 'playwright': {
117
- 'url': `${url}/sse`
118
- }
108
+ const url = httpAddressToString(httpServer.address());
109
+ const message = [
110
+ `Listening on ${url}`,
111
+ 'Put this in your client config:',
112
+ JSON.stringify({
113
+ 'mcpServers': {
114
+ 'playwright': {
115
+ 'url': `${url}/sse`
119
116
  }
120
- }, undefined, 2),
121
- 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
122
- ].join('\n');
123
- // eslint-disable-next-line no-console
124
- console.error(message);
125
- });
117
+ }
118
+ }, undefined, 2),
119
+ 'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
120
+ ].join('\n');
121
+ // eslint-disable-next-line no-console
122
+ console.error(message);
123
+ }
124
+ export function httpAddressToString(address) {
125
+ assert(address, 'Could not bind server socket');
126
+ if (typeof address === 'string')
127
+ return address;
128
+ const resolvedPort = address.port;
129
+ let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
130
+ if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
131
+ resolvedHost = 'localhost';
132
+ return `http://${resolvedHost}:${resolvedPort}`;
126
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwright/mcp",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "ctest": "playwright test --project=chrome",
25
25
  "ftest": "playwright test --project=firefox",
26
26
  "wtest": "playwright test --project=webkit",
27
+ "run-server": "node lib/browserServer.js",
27
28
  "clean": "rm -rf lib",
28
29
  "npm-publish": "npm run clean && npm run build && npm run test && npm publish"
29
30
  },
@@ -38,16 +39,20 @@
38
39
  "@modelcontextprotocol/sdk": "^1.11.0",
39
40
  "commander": "^13.1.0",
40
41
  "debug": "^4.4.1",
41
- "playwright": "1.53.0-alpha-2025-05-27",
42
+ "mime": "^4.0.7",
43
+ "playwright": "1.54.1",
44
+ "ws": "^8.18.1",
42
45
  "zod-to-json-schema": "^3.24.4"
43
46
  },
44
47
  "devDependencies": {
45
48
  "@eslint/eslintrc": "^3.2.0",
46
49
  "@eslint/js": "^9.19.0",
47
- "@playwright/test": "1.53.0-alpha-2025-05-27",
50
+ "@playwright/test": "1.54.1",
48
51
  "@stylistic/eslint-plugin": "^3.0.1",
52
+ "@types/chrome": "^0.0.315",
49
53
  "@types/debug": "^4.1.12",
50
54
  "@types/node": "^22.13.10",
55
+ "@types/ws": "^8.18.1",
51
56
  "@typescript-eslint/eslint-plugin": "^8.26.1",
52
57
  "@typescript-eslint/parser": "^8.26.1",
53
58
  "@typescript-eslint/utils": "^8.26.1",
@@ -1,16 +0,0 @@
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
- export {};