@playwright/mcp 0.0.33 → 0.0.35-alpha-2025-08-27

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.
Files changed (42) hide show
  1. package/README.md +60 -39
  2. package/config.d.ts +1 -1
  3. package/lib/browserContextFactory.js +32 -26
  4. package/lib/browserServerBackend.js +18 -62
  5. package/lib/config.js +1 -1
  6. package/lib/context.js +6 -6
  7. package/lib/extension/cdpRelay.js +33 -21
  8. package/lib/extension/extensionContextFactory.js +9 -9
  9. package/lib/extension/protocol.js +18 -0
  10. package/lib/index.js +2 -1
  11. package/lib/loopTools/context.js +3 -2
  12. package/lib/loopTools/main.js +15 -10
  13. package/lib/mcp/{transport.js → http.js} +51 -37
  14. package/lib/mcp/mdb.js +198 -0
  15. package/lib/mcp/proxyBackend.js +104 -0
  16. package/lib/mcp/server.js +63 -33
  17. package/lib/mcp/tool.js +32 -0
  18. package/lib/program.js +49 -20
  19. package/lib/sessionLog.js +1 -1
  20. package/lib/tab.js +2 -2
  21. package/lib/tools/evaluate.js +1 -1
  22. package/lib/tools/form.js +57 -0
  23. package/lib/tools/keyboard.js +1 -1
  24. package/lib/tools/navigate.js +0 -16
  25. package/lib/tools/pdf.js +1 -1
  26. package/lib/tools/screenshot.js +1 -1
  27. package/lib/tools/snapshot.js +1 -1
  28. package/lib/tools/tabs.js +31 -59
  29. package/lib/tools/verify.js +137 -0
  30. package/lib/tools/wait.js +3 -4
  31. package/lib/tools.js +5 -1
  32. package/lib/{javascript.js → utils/codegen.js} +1 -1
  33. package/lib/{fileUtils.js → utils/fileUtils.js} +6 -2
  34. package/lib/{utils.js → utils/guid.js} +3 -7
  35. package/lib/{package.js → utils/package.js} +1 -1
  36. package/lib/vscode/host.js +128 -0
  37. package/lib/vscode/main.js +62 -0
  38. package/package.json +6 -5
  39. package/lib/extension/main.js +0 -26
  40. package/lib/httpServer.js +0 -39
  41. /package/lib/{manualPromise.js → mcp/manualPromise.js} +0 -0
  42. /package/lib/{log.js → utils/log.js} +0 -0
package/README.md CHANGED
@@ -56,6 +56,21 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
56
56
 
57
57
  </details>
58
58
 
59
+ <details>
60
+ <summary>Codex</summary>
61
+
62
+ Create or edit the configuration file `~/.codex/config.toml` and add:
63
+
64
+ ```toml
65
+ [mcp_servers.playwright]
66
+ command = "npx"
67
+ args = ["@playwright/mcp@latest"]
68
+ ```
69
+
70
+ For more information, see the [Codex MCP documentation](https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers).
71
+
72
+ </details>
73
+
59
74
  <details>
60
75
  <summary>Cursor</summary>
61
76
 
@@ -100,6 +115,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
100
115
  Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
101
116
  </details>
102
117
 
118
+ <details>
119
+ <summary>opencode</summary>
120
+
121
+ Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
122
+
123
+ ```json
124
+ {
125
+ "$schema": "https://opencode.ai/config.json",
126
+ "mcp": {
127
+ "playwright": {
128
+ "type": "local",
129
+ "command": [
130
+ "npx",
131
+ "@playwright/mcp@latest"
132
+ ],
133
+ "enabled": true
134
+ }
135
+ }
136
+ }
137
+
138
+ ```
139
+ </details>
140
+
103
141
  <details>
104
142
  <summary>Qodo Gen</summary>
105
143
 
@@ -158,6 +196,9 @@ Playwright MCP server supports following arguments. They can be provided in the
158
196
  --config <path> path to the configuration file.
159
197
  --device <device> device to emulate, for example: "iPhone 15"
160
198
  --executable-path <path> path to the browser executable.
199
+ --extension Connect to a running browser instance
200
+ (Edge/Chrome only). Requires the "Playwright MCP
201
+ Bridge" browser extension to be installed.
161
202
  --headless run browser in headless mode, headed by default
162
203
  --host <host> host to bind server to. Default is localhost. Use
163
204
  0.0.0.0 to bind to all interfaces.
@@ -191,7 +232,7 @@ Playwright MCP server supports following arguments. They can be provided in the
191
232
 
192
233
  ### User profile
193
234
 
194
- You can run Playwright MCP with persistent profile like a regular browser (default), or in the isolated contexts for the testing sessions.
235
+ You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
195
236
 
196
237
  **Persistent profile**
197
238
 
@@ -231,6 +272,10 @@ state [here](https://playwright.dev/docs/auth).
231
272
  }
232
273
  ```
233
274
 
275
+ **Browser Extension**
276
+
277
+ The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
278
+
234
279
  ### Configuration file
235
280
 
236
281
  The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
@@ -449,6 +494,15 @@ http.createServer(async (req, res) => {
449
494
 
450
495
  <!-- NOTE: This has been generated via update-readme.js -->
451
496
 
497
+ - **browser_fill_form**
498
+ - Title: Fill form
499
+ - Description: Fill multiple form fields
500
+ - Parameters:
501
+ - `fields` (array): Fields to fill in
502
+ - Read-only: **false**
503
+
504
+ <!-- NOTE: This has been generated via update-readme.js -->
505
+
452
506
  - **browser_handle_dialog**
453
507
  - Title: Handle a dialog
454
508
  - Description: Handle a dialog
@@ -486,14 +540,6 @@ http.createServer(async (req, res) => {
486
540
 
487
541
  <!-- NOTE: This has been generated via update-readme.js -->
488
542
 
489
- - **browser_navigate_forward**
490
- - Title: Go forward
491
- - Description: Go forward to the next page
492
- - Parameters: None
493
- - Read-only: **true**
494
-
495
- <!-- NOTE: This has been generated via update-readme.js -->
496
-
497
543
  - **browser_network_requests**
498
544
  - Title: List network requests
499
545
  - Description: Returns all network requests since loading the page
@@ -582,39 +628,14 @@ http.createServer(async (req, res) => {
582
628
 
583
629
  <!-- NOTE: This has been generated via update-readme.js -->
584
630
 
585
- - **browser_tab_close**
586
- - Title: Close a tab
587
- - Description: Close a tab
631
+ - **browser_tabs**
632
+ - Title: Manage tabs
633
+ - Description: List, create, close, or select a browser tab.
588
634
  - Parameters:
589
- - `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
635
+ - `action` (string): Operation to perform
636
+ - `index` (number, optional): Tab index, used for close/select. If omitted for close, current tab is closed.
590
637
  - Read-only: **false**
591
638
 
592
- <!-- NOTE: This has been generated via update-readme.js -->
593
-
594
- - **browser_tab_list**
595
- - Title: List tabs
596
- - Description: List browser tabs
597
- - Parameters: None
598
- - Read-only: **true**
599
-
600
- <!-- NOTE: This has been generated via update-readme.js -->
601
-
602
- - **browser_tab_new**
603
- - Title: Open a new tab
604
- - Description: Open a new tab
605
- - Parameters:
606
- - `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
607
- - Read-only: **true**
608
-
609
- <!-- NOTE: This has been generated via update-readme.js -->
610
-
611
- - **browser_tab_select**
612
- - Title: Select a tab
613
- - Description: Select a tab by index
614
- - Parameters:
615
- - `index` (number): The index of the tab to select
616
- - Read-only: **true**
617
-
618
639
  </details>
619
640
 
620
641
  <details>
package/config.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import type * as playwright from 'playwright';
18
18
 
19
- export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
19
+ export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf' | 'verify';
20
20
 
21
21
  export type Config = {
22
22
  /**
@@ -19,8 +19,10 @@ import path from 'path';
19
19
  import * as playwright from 'playwright';
20
20
  // @ts-ignore
21
21
  import { registryDirectory } from 'playwright-core/lib/server/registry/index';
22
- import { logUnhandledError, testDebug } from './log.js';
23
- import { createHash } from './utils.js';
22
+ // @ts-ignore
23
+ import { startTraceViewerServer } from 'playwright-core/lib/server';
24
+ import { logUnhandledError, testDebug } from './utils/log.js';
25
+ import { createHash } from './utils/guid.js';
24
26
  import { outputFile } from './config.js';
25
27
  export function contextFactory(config) {
26
28
  if (config.browser.remoteEndpoint)
@@ -32,21 +34,18 @@ export function contextFactory(config) {
32
34
  return new PersistentContextFactory(config);
33
35
  }
34
36
  class BaseContextFactory {
35
- name;
36
- description;
37
37
  config;
38
+ _logName;
38
39
  _browserPromise;
39
- _tracesDir;
40
- constructor(name, description, config) {
41
- this.name = name;
42
- this.description = description;
40
+ constructor(name, config) {
41
+ this._logName = name;
43
42
  this.config = config;
44
43
  }
45
- async _obtainBrowser() {
44
+ async _obtainBrowser(clientInfo) {
46
45
  if (this._browserPromise)
47
46
  return this._browserPromise;
48
- testDebug(`obtain browser (${this.name})`);
49
- this._browserPromise = this._doObtainBrowser();
47
+ testDebug(`obtain browser (${this._logName})`);
48
+ this._browserPromise = this._doObtainBrowser(clientInfo);
50
49
  void this._browserPromise.then(browser => {
51
50
  browser.on('disconnected', () => {
52
51
  this._browserPromise = undefined;
@@ -56,14 +55,12 @@ class BaseContextFactory {
56
55
  });
57
56
  return this._browserPromise;
58
57
  }
59
- async _doObtainBrowser() {
58
+ async _doObtainBrowser(clientInfo) {
60
59
  throw new Error('Not implemented');
61
60
  }
62
61
  async createContext(clientInfo) {
63
- if (this.config.saveTrace)
64
- this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
65
- testDebug(`create browser context (${this.name})`);
66
- const browser = await this._obtainBrowser();
62
+ testDebug(`create browser context (${this._logName})`);
63
+ const browser = await this._obtainBrowser(clientInfo);
67
64
  const browserContext = await this._doCreateContext(browser);
68
65
  return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
69
66
  }
@@ -71,25 +68,25 @@ class BaseContextFactory {
71
68
  throw new Error('Not implemented');
72
69
  }
73
70
  async _closeBrowserContext(browserContext, browser) {
74
- testDebug(`close browser context (${this.name})`);
71
+ testDebug(`close browser context (${this._logName})`);
75
72
  if (browser.contexts().length === 1)
76
73
  this._browserPromise = undefined;
77
74
  await browserContext.close().catch(logUnhandledError);
78
75
  if (browser.contexts().length === 0) {
79
- testDebug(`close browser (${this.name})`);
76
+ testDebug(`close browser (${this._logName})`);
80
77
  await browser.close().catch(logUnhandledError);
81
78
  }
82
79
  }
83
80
  }
84
81
  class IsolatedContextFactory extends BaseContextFactory {
85
82
  constructor(config) {
86
- super('isolated', 'Create a new isolated browser context', config);
83
+ super('isolated', config);
87
84
  }
88
- async _doObtainBrowser() {
85
+ async _doObtainBrowser(clientInfo) {
89
86
  await injectCdpPort(this.config.browser);
90
87
  const browserType = playwright[this.config.browser.browserName];
91
88
  return browserType.launch({
92
- tracesDir: this._tracesDir,
89
+ tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
93
90
  ...this.config.browser.launchOptions,
94
91
  handleSIGINT: false,
95
92
  handleSIGTERM: false,
@@ -105,7 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
105
102
  }
106
103
  class CdpContextFactory extends BaseContextFactory {
107
104
  constructor(config) {
108
- super('cdp', 'Connect to a browser over CDP', config);
105
+ super('cdp', config);
109
106
  }
110
107
  async _doObtainBrowser() {
111
108
  return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
@@ -116,7 +113,7 @@ class CdpContextFactory extends BaseContextFactory {
116
113
  }
117
114
  class RemoteContextFactory extends BaseContextFactory {
118
115
  constructor(config) {
119
- super('remote', 'Connect to a browser using a remote endpoint', config);
116
+ super('remote', config);
120
117
  }
121
118
  async _doObtainBrowser() {
122
119
  const url = new URL(this.config.browser.remoteEndpoint);
@@ -141,9 +138,7 @@ class PersistentContextFactory {
141
138
  await injectCdpPort(this.config.browser);
142
139
  testDebug('create browser context (persistent)');
143
140
  const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
144
- let tracesDir;
145
- if (this.config.saveTrace)
146
- tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
141
+ const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
147
142
  this._userDataDirs.add(userDataDir);
148
143
  testDebug('lock user data dir', userDataDir);
149
144
  const browserType = playwright[this.config.browser.browserName];
@@ -203,3 +198,14 @@ async function findFreePort() {
203
198
  server.on('error', reject);
204
199
  });
205
200
  }
201
+ async function startTraceServer(config, rootPath) {
202
+ if (!config.saveTrace)
203
+ return undefined;
204
+ const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
205
+ const server = await startTraceViewerServer();
206
+ const urlPrefix = server.urlPrefix('human-readable');
207
+ const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
208
+ // eslint-disable-next-line no-console
209
+ console.error('\nTrace viewer listening on ' + url);
210
+ return tracesDir;
211
+ }
@@ -14,35 +14,26 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { fileURLToPath } from 'url';
17
- import { z } from 'zod';
18
17
  import { Context } from './context.js';
19
- import { logUnhandledError } from './log.js';
18
+ import { logUnhandledError } from './utils/log.js';
20
19
  import { Response } from './response.js';
21
20
  import { SessionLog } from './sessionLog.js';
22
21
  import { filteredTools } from './tools.js';
23
- import { packageJSON } from './package.js';
24
- import { defineTool } from './tools/tool.js';
22
+ import { toMcpTool } from './mcp/tool.js';
25
23
  export class BrowserServerBackend {
26
- name = 'Playwright';
27
- version = packageJSON.version;
28
24
  _tools;
29
25
  _context;
30
26
  _sessionLog;
31
27
  _config;
32
28
  _browserContextFactory;
33
- constructor(config, factories) {
29
+ constructor(config, factory) {
34
30
  this._config = config;
35
- this._browserContextFactory = factories[0];
31
+ this._browserContextFactory = factory;
36
32
  this._tools = filteredTools(config);
37
- if (factories.length > 1)
38
- this._tools.push(this._defineContextSwitchTool(factories));
39
33
  }
40
- async initialize(server) {
41
- const capabilities = server.getClientCapabilities();
34
+ async initialize(server, clientVersion, roots) {
42
35
  let rootPath;
43
- if (capabilities.roots && (server.getClientVersion()?.name === 'Visual Studio Code' ||
44
- server.getClientVersion()?.name === 'Visual Studio Code - Insiders')) {
45
- const { roots } = await server.listRoots();
36
+ if (roots.length > 0) {
46
37
  const firstRootUri = roots[0]?.uri;
47
38
  const url = firstRootUri ? new URL(firstRootUri) : undefined;
48
39
  rootPath = url ? fileURLToPath(url) : undefined;
@@ -53,17 +44,20 @@ export class BrowserServerBackend {
53
44
  config: this._config,
54
45
  browserContextFactory: this._browserContextFactory,
55
46
  sessionLog: this._sessionLog,
56
- clientInfo: { ...server.getClientVersion(), rootPath },
47
+ clientInfo: { ...clientVersion, rootPath },
57
48
  });
58
49
  }
59
- tools() {
60
- return this._tools.map(tool => tool.schema);
50
+ async listTools() {
51
+ return this._tools.map(tool => toMcpTool(tool.schema));
61
52
  }
62
- async callTool(schema, parsedArguments) {
53
+ async callTool(name, rawArguments) {
54
+ const tool = this._tools.find(tool => tool.schema.name === name);
55
+ if (!tool)
56
+ throw new Error(`Tool "${name}" not found`);
57
+ const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
63
58
  const context = this._context;
64
- const response = new Response(context, schema.name, parsedArguments);
65
- const tool = this._tools.find(tool => tool.schema.name === schema.name);
66
- context.setRunningTool(true);
59
+ const response = new Response(context, name, parsedArguments);
60
+ context.setRunningTool(name);
67
61
  try {
68
62
  await tool.handle(context, parsedArguments, response);
69
63
  await response.finish();
@@ -73,49 +67,11 @@ export class BrowserServerBackend {
73
67
  response.addError(String(error));
74
68
  }
75
69
  finally {
76
- context.setRunningTool(false);
70
+ context.setRunningTool(undefined);
77
71
  }
78
72
  return response.serialize();
79
73
  }
80
74
  serverClosed() {
81
- void this._context.dispose().catch(logUnhandledError);
82
- }
83
- _defineContextSwitchTool(factories) {
84
- const self = this;
85
- return defineTool({
86
- capability: 'core',
87
- schema: {
88
- name: 'browser_connect',
89
- title: 'Connect to a browser context',
90
- description: [
91
- 'Connect to a browser using one of the available methods:',
92
- ...factories.map(factory => `- "${factory.name}": ${factory.description}`),
93
- ].join('\n'),
94
- inputSchema: z.object({
95
- method: z.enum(factories.map(factory => factory.name)).default(factories[0].name).describe('The method to use to connect to the browser'),
96
- }),
97
- type: 'readOnly',
98
- },
99
- async handle(context, params, response) {
100
- const factory = factories.find(factory => factory.name === params.method);
101
- if (!factory) {
102
- response.addError('Unknown connection method: ' + params.method);
103
- return;
104
- }
105
- await self._setContextFactory(factory);
106
- response.addResult('Successfully changed connection method.');
107
- }
108
- });
109
- }
110
- async _setContextFactory(newFactory) {
111
- if (this._context) {
112
- const options = {
113
- ...this._context.options,
114
- browserContextFactory: newFactory,
115
- };
116
- await this._context.dispose();
117
- this._context = new Context(options);
118
- }
119
- this._browserContextFactory = newFactory;
75
+ void this._context?.dispose().catch(logUnhandledError);
120
76
  }
121
77
  }
package/lib/config.js CHANGED
@@ -17,7 +17,7 @@ import fs from 'fs';
17
17
  import os from 'os';
18
18
  import path from 'path';
19
19
  import { devices } from 'playwright';
20
- import { sanitizeForFilePath } from './utils.js';
20
+ import { sanitizeForFilePath } from './utils/fileUtils.js';
21
21
  const defaultConfig = {
22
22
  browser: {
23
23
  browserName: 'chromium',
package/lib/context.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import debug from 'debug';
17
- import { logUnhandledError } from './log.js';
17
+ import { logUnhandledError } from './utils/log.js';
18
18
  import { Tab } from './tab.js';
19
19
  import { outputFile } from './config.js';
20
20
  const testDebug = debug('pw:mcp:test');
@@ -30,7 +30,7 @@ export class Context {
30
30
  _clientInfo;
31
31
  static _allContexts = new Set();
32
32
  _closeBrowserContextPromise;
33
- _isRunningTool = false;
33
+ _runningToolName;
34
34
  _abortController = new AbortController();
35
35
  constructor(options) {
36
36
  this.tools = options.tools;
@@ -110,10 +110,10 @@ export class Context {
110
110
  this._closeBrowserContextPromise = undefined;
111
111
  }
112
112
  isRunningTool() {
113
- return this._isRunningTool;
113
+ return this._runningToolName !== undefined;
114
114
  }
115
- setRunningTool(isRunningTool) {
116
- this._isRunningTool = isRunningTool;
115
+ setRunningTool(name) {
116
+ this._runningToolName = name;
117
117
  }
118
118
  async _closeBrowserContextImpl() {
119
119
  if (!this._browserContextPromise)
@@ -156,7 +156,7 @@ export class Context {
156
156
  if (this._closeBrowserContextPromise)
157
157
  throw new Error('Another browser context is being closed.');
158
158
  // TODO: move to the browser context factory to make it based on isolation mode.
159
- const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
159
+ const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
160
160
  const { browserContext } = result;
161
161
  await this._setupRequestInterception(browserContext);
162
162
  if (this.sessionLog)
@@ -23,9 +23,10 @@
23
23
  import { spawn } from 'child_process';
24
24
  import debug from 'debug';
25
25
  import { WebSocket, WebSocketServer } from 'ws';
26
- import { httpAddressToString } from '../httpServer.js';
27
- import { logUnhandledError } from '../log.js';
28
- import { ManualPromise } from '../manualPromise.js';
26
+ import { httpAddressToString } from '../mcp/http.js';
27
+ import { logUnhandledError } from '../utils/log.js';
28
+ import { ManualPromise } from '../mcp/manualPromise.js';
29
+ import * as protocol from './protocol.js';
29
30
  // @ts-ignore
30
31
  const { registry } = await import('playwright-core/lib/server/registry/index');
31
32
  const debugLogger = debug('pw:mcp:relay');
@@ -33,6 +34,7 @@ export class CDPRelayServer {
33
34
  _wsHost;
34
35
  _browserChannel;
35
36
  _userDataDir;
37
+ _executablePath;
36
38
  _cdpPath;
37
39
  _extensionPath;
38
40
  _wss;
@@ -41,10 +43,11 @@ export class CDPRelayServer {
41
43
  _connectedTabInfo;
42
44
  _nextSessionId = 1;
43
45
  _extensionConnectionPromise;
44
- constructor(server, browserChannel, userDataDir) {
46
+ constructor(server, browserChannel, userDataDir, executablePath) {
45
47
  this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
46
48
  this._browserChannel = browserChannel;
47
49
  this._userDataDir = userDataDir;
50
+ this._executablePath = executablePath;
48
51
  const uuid = crypto.randomUUID();
49
52
  this._cdpPath = `/cdp/${uuid}`;
50
53
  this._extensionPath = `/extension/${uuid}`;
@@ -58,31 +61,44 @@ export class CDPRelayServer {
58
61
  extensionEndpoint() {
59
62
  return `${this._wsHost}${this._extensionPath}`;
60
63
  }
61
- async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
64
+ async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName) {
62
65
  debugLogger('Ensuring extension connection for MCP context');
63
66
  if (this._extensionConnection)
64
67
  return;
65
- this._connectBrowser(clientInfo);
68
+ this._connectBrowser(clientInfo, toolName);
66
69
  debugLogger('Waiting for incoming extension connection');
67
70
  await Promise.race([
68
71
  this._extensionConnectionPromise,
72
+ new Promise((_, reject) => setTimeout(() => {
73
+ reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
74
+ }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
69
75
  new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
70
76
  ]);
71
77
  debugLogger('Extension connection established');
72
78
  }
73
- _connectBrowser(clientInfo) {
79
+ _connectBrowser(clientInfo, toolName) {
74
80
  const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
75
81
  // Need to specify "key" in the manifest.json to make the id stable when loading from file.
76
82
  const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
77
83
  url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
78
- url.searchParams.set('client', JSON.stringify(clientInfo));
84
+ const client = {
85
+ name: clientInfo.name,
86
+ version: clientInfo.version,
87
+ };
88
+ url.searchParams.set('client', JSON.stringify(client));
89
+ url.searchParams.set('protocolVersion', process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
90
+ if (toolName)
91
+ url.searchParams.set('newTab', String(toolName === 'browser_navigate'));
79
92
  const href = url.toString();
80
- const executableInfo = registry.findExecutable(this._browserChannel);
81
- if (!executableInfo)
82
- throw new Error(`Unsupported channel: "${this._browserChannel}"`);
83
- const executablePath = executableInfo.executablePath();
84
- if (!executablePath)
85
- throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
93
+ let executablePath = this._executablePath;
94
+ if (!executablePath) {
95
+ const executableInfo = registry.findExecutable(this._browserChannel);
96
+ if (!executableInfo)
97
+ throw new Error(`Unsupported channel: "${this._browserChannel}"`);
98
+ executablePath = executableInfo.executablePath();
99
+ if (!executablePath)
100
+ throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
101
+ }
86
102
  const args = [];
87
103
  if (this._userDataDir)
88
104
  args.push(`--user-data-dir=${this._userDataDir}`);
@@ -186,10 +202,6 @@ export class CDPRelayServer {
186
202
  params: params.params
187
203
  });
188
204
  break;
189
- case 'detachedFromTab':
190
- debugLogger('← Debugger detached from tab:', params);
191
- this._connectedTabInfo = undefined;
192
- break;
193
205
  }
194
206
  }
195
207
  async _handlePlaywrightMessage(message) {
@@ -225,7 +237,7 @@ export class CDPRelayServer {
225
237
  if (sessionId)
226
238
  break;
227
239
  // Simulate auto-attach behavior with real target info
228
- const { targetInfo } = await this._extensionConnection.send('attachToTab');
240
+ const { targetInfo } = await this._extensionConnection.send('attachToTab', {});
229
241
  this._connectedTabInfo = {
230
242
  targetInfo,
231
243
  sessionId: `pw-tab-${this._nextSessionId++}`,
@@ -275,11 +287,11 @@ class ExtensionConnection {
275
287
  this._ws.on('close', this._onClose.bind(this));
276
288
  this._ws.on('error', this._onError.bind(this));
277
289
  }
278
- async send(method, params, sessionId) {
290
+ async send(method, params) {
279
291
  if (this._ws.readyState !== WebSocket.OPEN)
280
292
  throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
281
293
  const id = ++this._lastId;
282
- this._ws.send(JSON.stringify({ id, method, params, sessionId }));
294
+ this._ws.send(JSON.stringify({ id, method, params }));
283
295
  const error = new Error(`Protocol error: ${method}`);
284
296
  return new Promise((resolve, reject) => {
285
297
  this._callbacks.set(id, { resolve, reject, error });
@@ -15,20 +15,20 @@
15
15
  */
16
16
  import debug from 'debug';
17
17
  import * as playwright from 'playwright';
18
- import { startHttpServer } from '../httpServer.js';
18
+ import { startHttpServer } from '../mcp/http.js';
19
19
  import { CDPRelayServer } from './cdpRelay.js';
20
20
  const debugLogger = debug('pw:mcp:relay');
21
21
  export class ExtensionContextFactory {
22
- name = 'extension';
23
- description = 'Connect to a browser using the Playwright MCP extension';
24
22
  _browserChannel;
25
23
  _userDataDir;
26
- constructor(browserChannel, userDataDir) {
24
+ _executablePath;
25
+ constructor(browserChannel, userDataDir, executablePath) {
27
26
  this._browserChannel = browserChannel;
28
27
  this._userDataDir = userDataDir;
28
+ this._executablePath = executablePath;
29
29
  }
30
- async createContext(clientInfo, abortSignal) {
31
- const browser = await this._obtainBrowser(clientInfo, abortSignal);
30
+ async createContext(clientInfo, abortSignal, toolName) {
31
+ const browser = await this._obtainBrowser(clientInfo, abortSignal, toolName);
32
32
  return {
33
33
  browserContext: browser.contexts()[0],
34
34
  close: async () => {
@@ -37,9 +37,9 @@ export class ExtensionContextFactory {
37
37
  }
38
38
  };
39
39
  }
40
- async _obtainBrowser(clientInfo, abortSignal) {
40
+ async _obtainBrowser(clientInfo, abortSignal, toolName) {
41
41
  const relay = await this._startRelay(abortSignal);
42
- await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
42
+ await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName);
43
43
  return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
44
44
  }
45
45
  async _startRelay(abortSignal) {
@@ -48,7 +48,7 @@ export class ExtensionContextFactory {
48
48
  httpServer.close();
49
49
  throw new Error(abortSignal.reason);
50
50
  }
51
- const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
51
+ const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir, this._executablePath);
52
52
  abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
53
53
  debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
54
54
  return cdpRelayServer;
@@ -0,0 +1,18 @@
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
+ // Whenever the commands/events change, the version must be updated. The latest
17
+ // extension version should be compatible with the old MCP clients.
18
+ export const VERSION = 1;