@playwright/mcp 0.0.32 → 0.0.33

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
@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
61
61
 
62
62
  #### Click the button to install:
63
63
 
64
- [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
64
+ [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
65
65
 
66
66
  #### Or install manually:
67
67
 
@@ -544,7 +544,7 @@ http.createServer(async (req, res) => {
544
544
  - Title: Take a screenshot
545
545
  - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
546
546
  - Parameters:
547
- - `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
547
+ - `type` (string, optional): Image format for the screenshot. Default is png.
548
548
  - `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
549
549
  - `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
550
550
  - `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
@@ -13,28 +13,34 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import fs from 'node:fs';
17
- import net from 'node:net';
18
- import path from 'node:path';
19
- import os from 'node:os';
16
+ import fs from 'fs';
17
+ import net from 'net';
18
+ import path from 'path';
20
19
  import * as playwright from 'playwright';
20
+ // @ts-ignore
21
+ import { registryDirectory } from 'playwright-core/lib/server/registry/index';
21
22
  import { logUnhandledError, testDebug } from './log.js';
22
- export function contextFactory(browserConfig) {
23
- if (browserConfig.remoteEndpoint)
24
- return new RemoteContextFactory(browserConfig);
25
- if (browserConfig.cdpEndpoint)
26
- return new CdpContextFactory(browserConfig);
27
- if (browserConfig.isolated)
28
- return new IsolatedContextFactory(browserConfig);
29
- return new PersistentContextFactory(browserConfig);
23
+ import { createHash } from './utils.js';
24
+ import { outputFile } from './config.js';
25
+ export function contextFactory(config) {
26
+ if (config.browser.remoteEndpoint)
27
+ return new RemoteContextFactory(config);
28
+ if (config.browser.cdpEndpoint)
29
+ return new CdpContextFactory(config);
30
+ if (config.browser.isolated)
31
+ return new IsolatedContextFactory(config);
32
+ return new PersistentContextFactory(config);
30
33
  }
31
34
  class BaseContextFactory {
32
- browserConfig;
33
- _browserPromise;
34
35
  name;
35
- constructor(name, browserConfig) {
36
+ description;
37
+ config;
38
+ _browserPromise;
39
+ _tracesDir;
40
+ constructor(name, description, config) {
36
41
  this.name = name;
37
- this.browserConfig = browserConfig;
42
+ this.description = description;
43
+ this.config = config;
38
44
  }
39
45
  async _obtainBrowser() {
40
46
  if (this._browserPromise)
@@ -53,7 +59,9 @@ class BaseContextFactory {
53
59
  async _doObtainBrowser() {
54
60
  throw new Error('Not implemented');
55
61
  }
56
- async createContext() {
62
+ async createContext(clientInfo) {
63
+ if (this.config.saveTrace)
64
+ this._tracesDir = await outputFile(this.config, clientInfo.rootPath, `traces-${Date.now()}`);
57
65
  testDebug(`create browser context (${this.name})`);
58
66
  const browser = await this._obtainBrowser();
59
67
  const browserContext = await this._doCreateContext(browser);
@@ -74,14 +82,15 @@ class BaseContextFactory {
74
82
  }
75
83
  }
76
84
  class IsolatedContextFactory extends BaseContextFactory {
77
- constructor(browserConfig) {
78
- super('isolated', browserConfig);
85
+ constructor(config) {
86
+ super('isolated', 'Create a new isolated browser context', config);
79
87
  }
80
88
  async _doObtainBrowser() {
81
- await injectCdpPort(this.browserConfig);
82
- const browserType = playwright[this.browserConfig.browserName];
89
+ await injectCdpPort(this.config.browser);
90
+ const browserType = playwright[this.config.browser.browserName];
83
91
  return browserType.launch({
84
- ...this.browserConfig.launchOptions,
92
+ tracesDir: this._tracesDir,
93
+ ...this.config.browser.launchOptions,
85
94
  handleSIGINT: false,
86
95
  handleSIGTERM: false,
87
96
  }).catch(error => {
@@ -91,53 +100,59 @@ class IsolatedContextFactory extends BaseContextFactory {
91
100
  });
92
101
  }
93
102
  async _doCreateContext(browser) {
94
- return browser.newContext(this.browserConfig.contextOptions);
103
+ return browser.newContext(this.config.browser.contextOptions);
95
104
  }
96
105
  }
97
106
  class CdpContextFactory extends BaseContextFactory {
98
- constructor(browserConfig) {
99
- super('cdp', browserConfig);
107
+ constructor(config) {
108
+ super('cdp', 'Connect to a browser over CDP', config);
100
109
  }
101
110
  async _doObtainBrowser() {
102
- return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint);
111
+ return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
103
112
  }
104
113
  async _doCreateContext(browser) {
105
- return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
114
+ return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
106
115
  }
107
116
  }
108
117
  class RemoteContextFactory extends BaseContextFactory {
109
- constructor(browserConfig) {
110
- super('remote', browserConfig);
118
+ constructor(config) {
119
+ super('remote', 'Connect to a browser using a remote endpoint', config);
111
120
  }
112
121
  async _doObtainBrowser() {
113
- const url = new URL(this.browserConfig.remoteEndpoint);
114
- url.searchParams.set('browser', this.browserConfig.browserName);
115
- if (this.browserConfig.launchOptions)
116
- url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
117
- return playwright[this.browserConfig.browserName].connect(String(url));
122
+ const url = new URL(this.config.browser.remoteEndpoint);
123
+ url.searchParams.set('browser', this.config.browser.browserName);
124
+ if (this.config.browser.launchOptions)
125
+ url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
126
+ return playwright[this.config.browser.browserName].connect(String(url));
118
127
  }
119
128
  async _doCreateContext(browser) {
120
129
  return browser.newContext();
121
130
  }
122
131
  }
123
132
  class PersistentContextFactory {
124
- browserConfig;
133
+ config;
134
+ name = 'persistent';
135
+ description = 'Create a new persistent browser context';
125
136
  _userDataDirs = new Set();
126
- constructor(browserConfig) {
127
- this.browserConfig = browserConfig;
137
+ constructor(config) {
138
+ this.config = config;
128
139
  }
129
- async createContext() {
130
- await injectCdpPort(this.browserConfig);
140
+ async createContext(clientInfo) {
141
+ await injectCdpPort(this.config.browser);
131
142
  testDebug('create browser context (persistent)');
132
- const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
143
+ 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()}`);
133
147
  this._userDataDirs.add(userDataDir);
134
148
  testDebug('lock user data dir', userDataDir);
135
- const browserType = playwright[this.browserConfig.browserName];
149
+ const browserType = playwright[this.config.browser.browserName];
136
150
  for (let i = 0; i < 5; i++) {
137
151
  try {
138
152
  const browserContext = await browserType.launchPersistentContext(userDataDir, {
139
- ...this.browserConfig.launchOptions,
140
- ...this.browserConfig.contextOptions,
153
+ tracesDir,
154
+ ...this.config.browser.launchOptions,
155
+ ...this.config.browser.contextOptions,
141
156
  handleSIGINT: false,
142
157
  handleSIGTERM: false,
143
158
  });
@@ -164,17 +179,12 @@ class PersistentContextFactory {
164
179
  this._userDataDirs.delete(userDataDir);
165
180
  testDebug('close browser context complete (persistent)');
166
181
  }
167
- async _createUserDataDir() {
168
- let cacheDirectory;
169
- if (process.platform === 'linux')
170
- cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
171
- else if (process.platform === 'darwin')
172
- cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
173
- else if (process.platform === 'win32')
174
- cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
175
- else
176
- throw new Error('Unsupported platform: ' + process.platform);
177
- const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
182
+ async _createUserDataDir(rootPath) {
183
+ const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
184
+ const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
185
+ // Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
186
+ const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
187
+ const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
178
188
  await fs.promises.mkdir(result, { recursive: true });
179
189
  return result;
180
190
  }
@@ -13,42 +13,109 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ import { fileURLToPath } from 'url';
17
+ import { z } from 'zod';
16
18
  import { Context } from './context.js';
17
19
  import { logUnhandledError } from './log.js';
18
20
  import { Response } from './response.js';
19
21
  import { SessionLog } from './sessionLog.js';
20
22
  import { filteredTools } from './tools.js';
21
23
  import { packageJSON } from './package.js';
24
+ import { defineTool } from './tools/tool.js';
22
25
  export class BrowserServerBackend {
23
26
  name = 'Playwright';
24
27
  version = packageJSON.version;
25
- onclose;
26
28
  _tools;
27
29
  _context;
28
30
  _sessionLog;
29
- constructor(config, browserContextFactory) {
31
+ _config;
32
+ _browserContextFactory;
33
+ constructor(config, factories) {
34
+ this._config = config;
35
+ this._browserContextFactory = factories[0];
30
36
  this._tools = filteredTools(config);
31
- this._context = new Context(this._tools, config, browserContextFactory);
37
+ if (factories.length > 1)
38
+ this._tools.push(this._defineContextSwitchTool(factories));
32
39
  }
33
- async initialize() {
34
- this._sessionLog = this._context.config.saveSession ? await SessionLog.create(this._context.config) : undefined;
40
+ async initialize(server) {
41
+ const capabilities = server.getClientCapabilities();
42
+ 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();
46
+ const firstRootUri = roots[0]?.uri;
47
+ const url = firstRootUri ? new URL(firstRootUri) : undefined;
48
+ rootPath = url ? fileURLToPath(url) : undefined;
49
+ }
50
+ this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
51
+ this._context = new Context({
52
+ tools: this._tools,
53
+ config: this._config,
54
+ browserContextFactory: this._browserContextFactory,
55
+ sessionLog: this._sessionLog,
56
+ clientInfo: { ...server.getClientVersion(), rootPath },
57
+ });
35
58
  }
36
59
  tools() {
37
60
  return this._tools.map(tool => tool.schema);
38
61
  }
39
62
  async callTool(schema, parsedArguments) {
40
- const response = new Response(this._context, schema.name, parsedArguments);
63
+ const context = this._context;
64
+ const response = new Response(context, schema.name, parsedArguments);
41
65
  const tool = this._tools.find(tool => tool.schema.name === schema.name);
42
- await tool.handle(this._context, parsedArguments, response);
43
- if (this._sessionLog)
44
- await this._sessionLog.log(response);
45
- return await response.serialize();
46
- }
47
- serverInitialized(version) {
48
- this._context.clientVersion = version;
66
+ context.setRunningTool(true);
67
+ try {
68
+ await tool.handle(context, parsedArguments, response);
69
+ await response.finish();
70
+ this._sessionLog?.logResponse(response);
71
+ }
72
+ catch (error) {
73
+ response.addError(String(error));
74
+ }
75
+ finally {
76
+ context.setRunningTool(false);
77
+ }
78
+ return response.serialize();
49
79
  }
50
80
  serverClosed() {
51
- this.onclose?.();
52
81
  void this._context.dispose().catch(logUnhandledError);
53
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;
120
+ }
54
121
  }
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 './tools/utils.js';
20
+ import { sanitizeForFilePath } from './utils.js';
21
21
  const defaultConfig = {
22
22
  browser: {
23
23
  browserName: 'chromium',
@@ -35,7 +35,7 @@ const defaultConfig = {
35
35
  blockedOrigins: undefined,
36
36
  },
37
37
  server: {},
38
- outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
38
+ saveTrace: false,
39
39
  };
40
40
  export async function resolveConfig(config) {
41
41
  return mergeConfig(defaultConfig, config);
@@ -48,9 +48,6 @@ export async function resolveCLIConfig(cliOptions) {
48
48
  result = mergeConfig(result, configInFile);
49
49
  result = mergeConfig(result, envOverrides);
50
50
  result = mergeConfig(result, cliOverrides);
51
- // Derive artifact output directory from config.outputDir
52
- if (result.saveTrace)
53
- result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
54
51
  return result;
55
52
  }
56
53
  export function configFromCLIOptions(cliOptions) {
@@ -179,10 +176,13 @@ async function loadConfig(configFile) {
179
176
  throw new Error(`Failed to load config file: ${configFile}, ${error}`);
180
177
  }
181
178
  }
182
- export async function outputFile(config, name) {
183
- await fs.promises.mkdir(config.outputDir, { recursive: true });
179
+ export async function outputFile(config, rootPath, name) {
180
+ const outputDir = config.outputDir
181
+ ?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
182
+ ?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
183
+ await fs.promises.mkdir(outputDir, { recursive: true });
184
184
  const fileName = sanitizeForFilePath(name);
185
- return path.join(config.outputDir, fileName);
185
+ return path.join(outputDir, fileName);
186
186
  }
187
187
  function pickDefined(obj) {
188
188
  return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined));
package/lib/context.js CHANGED
@@ -16,21 +16,29 @@
16
16
  import debug from 'debug';
17
17
  import { logUnhandledError } from './log.js';
18
18
  import { Tab } from './tab.js';
19
+ import { outputFile } from './config.js';
19
20
  const testDebug = debug('pw:mcp:test');
20
21
  export class Context {
21
22
  tools;
22
23
  config;
24
+ sessionLog;
25
+ options;
23
26
  _browserContextPromise;
24
27
  _browserContextFactory;
25
28
  _tabs = [];
26
29
  _currentTab;
27
- clientVersion;
30
+ _clientInfo;
28
31
  static _allContexts = new Set();
29
32
  _closeBrowserContextPromise;
30
- constructor(tools, config, browserContextFactory) {
31
- this.tools = tools;
32
- this.config = config;
33
- this._browserContextFactory = browserContextFactory;
33
+ _isRunningTool = false;
34
+ _abortController = new AbortController();
35
+ constructor(options) {
36
+ this.tools = options.tools;
37
+ this.config = options.config;
38
+ this.sessionLog = options.sessionLog;
39
+ this.options = options;
40
+ this._browserContextFactory = options.browserContextFactory;
41
+ this._clientInfo = options.clientInfo;
34
42
  testDebug('create context');
35
43
  Context._allContexts.add(this);
36
44
  }
@@ -68,27 +76,6 @@ export class Context {
68
76
  await browserContext.newPage();
69
77
  return this._currentTab;
70
78
  }
71
- async listTabsMarkdown(force = false) {
72
- if (this._tabs.length === 1 && !force)
73
- return [];
74
- if (!this._tabs.length) {
75
- return [
76
- '### No open tabs',
77
- 'Use the "browser_navigate" tool to navigate to a page first.',
78
- '',
79
- ];
80
- }
81
- const lines = ['### Open tabs'];
82
- for (let i = 0; i < this._tabs.length; i++) {
83
- const tab = this._tabs[i];
84
- const title = await tab.title();
85
- const url = tab.page.url();
86
- const current = tab === this._currentTab ? ' (current)' : '';
87
- lines.push(`- ${i}:${current} [${title}] (${url})`);
88
- }
89
- lines.push('');
90
- return lines;
91
- }
92
79
  async closeTab(index) {
93
80
  const tab = index === undefined ? this._currentTab : this._tabs[index];
94
81
  if (!tab)
@@ -97,6 +84,9 @@ export class Context {
97
84
  await tab.page.close();
98
85
  return url;
99
86
  }
87
+ async outputFile(name) {
88
+ return outputFile(this.config, this._clientInfo.rootPath, name);
89
+ }
100
90
  _onPageCreated(page) {
101
91
  const tab = new Tab(this, page, tab => this._onPageClosed(tab));
102
92
  this._tabs.push(tab);
@@ -119,6 +109,12 @@ export class Context {
119
109
  await this._closeBrowserContextPromise;
120
110
  this._closeBrowserContextPromise = undefined;
121
111
  }
112
+ isRunningTool() {
113
+ return this._isRunningTool;
114
+ }
115
+ setRunningTool(isRunningTool) {
116
+ this._isRunningTool = isRunningTool;
117
+ }
122
118
  async _closeBrowserContextImpl() {
123
119
  if (!this._browserContextPromise)
124
120
  return;
@@ -132,6 +128,7 @@ export class Context {
132
128
  });
133
129
  }
134
130
  async dispose() {
131
+ this._abortController.abort('MCP context disposed');
135
132
  await this.closeBrowserContext();
136
133
  Context._allContexts.delete(this);
137
134
  }
@@ -159,9 +156,11 @@ export class Context {
159
156
  if (this._closeBrowserContextPromise)
160
157
  throw new Error('Another browser context is being closed.');
161
158
  // TODO: move to the browser context factory to make it based on isolation mode.
162
- const result = await this._browserContextFactory.createContext(this.clientVersion);
159
+ const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
163
160
  const { browserContext } = result;
164
161
  await this._setupRequestInterception(browserContext);
162
+ if (this.sessionLog)
163
+ await InputRecorder.create(this, browserContext);
165
164
  for (const page of browserContext.pages())
166
165
  this._onPageCreated(page);
167
166
  browserContext.on('page', page => this._onPageCreated(page));
@@ -176,3 +175,52 @@ export class Context {
176
175
  return result;
177
176
  }
178
177
  }
178
+ export class InputRecorder {
179
+ _context;
180
+ _browserContext;
181
+ constructor(context, browserContext) {
182
+ this._context = context;
183
+ this._browserContext = browserContext;
184
+ }
185
+ static async create(context, browserContext) {
186
+ const recorder = new InputRecorder(context, browserContext);
187
+ await recorder._initialize();
188
+ return recorder;
189
+ }
190
+ async _initialize() {
191
+ const sessionLog = this._context.sessionLog;
192
+ await this._browserContext._enableRecorder({
193
+ mode: 'recording',
194
+ recorderMode: 'api',
195
+ }, {
196
+ actionAdded: (page, data, code) => {
197
+ if (this._context.isRunningTool())
198
+ return;
199
+ const tab = Tab.forPage(page);
200
+ if (tab)
201
+ sessionLog.logUserAction(data.action, tab, code, false);
202
+ },
203
+ actionUpdated: (page, data, code) => {
204
+ if (this._context.isRunningTool())
205
+ return;
206
+ const tab = Tab.forPage(page);
207
+ if (tab)
208
+ sessionLog.logUserAction(data.action, tab, code, true);
209
+ },
210
+ signalAdded: (page, data) => {
211
+ if (this._context.isRunningTool())
212
+ return;
213
+ if (data.signal.name !== 'navigation')
214
+ return;
215
+ const tab = Tab.forPage(page);
216
+ const navigateAction = {
217
+ name: 'navigate',
218
+ url: data.signal.url,
219
+ signals: [],
220
+ };
221
+ if (tab)
222
+ sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
223
+ },
224
+ });
225
+ }
226
+ }
@@ -13,19 +13,26 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ /**
17
+ * WebSocket server that bridges Playwright MCP and Chrome Extension
18
+ *
19
+ * Endpoints:
20
+ * - /cdp/guid - Full CDP interface for Playwright MCP
21
+ * - /extension/guid - Extension connection for chrome.debugger forwarding
22
+ */
16
23
  import { spawn } from 'child_process';
17
- import { WebSocket, WebSocketServer } from 'ws';
18
24
  import debug from 'debug';
19
- import * as playwright from 'playwright';
20
- // @ts-ignore
21
- const { registry } = await import('playwright-core/lib/server/registry/index');
22
- import { httpAddressToString, startHttpServer } from '../httpServer.js';
25
+ import { WebSocket, WebSocketServer } from 'ws';
26
+ import { httpAddressToString } from '../httpServer.js';
23
27
  import { logUnhandledError } from '../log.js';
24
28
  import { ManualPromise } from '../manualPromise.js';
29
+ // @ts-ignore
30
+ const { registry } = await import('playwright-core/lib/server/registry/index');
25
31
  const debugLogger = debug('pw:mcp:relay');
26
32
  export class CDPRelayServer {
27
33
  _wsHost;
28
34
  _browserChannel;
35
+ _userDataDir;
29
36
  _cdpPath;
30
37
  _extensionPath;
31
38
  _wss;
@@ -34,9 +41,10 @@ export class CDPRelayServer {
34
41
  _connectedTabInfo;
35
42
  _nextSessionId = 1;
36
43
  _extensionConnectionPromise;
37
- constructor(server, browserChannel) {
44
+ constructor(server, browserChannel, userDataDir) {
38
45
  this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
39
46
  this._browserChannel = browserChannel;
47
+ this._userDataDir = userDataDir;
40
48
  const uuid = crypto.randomUUID();
41
49
  this._cdpPath = `/cdp/${uuid}`;
42
50
  this._extensionPath = `/extension/${uuid}`;
@@ -50,16 +58,19 @@ export class CDPRelayServer {
50
58
  extensionEndpoint() {
51
59
  return `${this._wsHost}${this._extensionPath}`;
52
60
  }
53
- async ensureExtensionConnectionForMCPContext(clientInfo) {
61
+ async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
54
62
  debugLogger('Ensuring extension connection for MCP context');
55
63
  if (this._extensionConnection)
56
64
  return;
57
- await this._connectBrowser(clientInfo);
65
+ this._connectBrowser(clientInfo);
58
66
  debugLogger('Waiting for incoming extension connection');
59
- await this._extensionConnectionPromise;
67
+ await Promise.race([
68
+ this._extensionConnectionPromise,
69
+ new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
70
+ ]);
60
71
  debugLogger('Extension connection established');
61
72
  }
62
- async _connectBrowser(clientInfo) {
73
+ _connectBrowser(clientInfo) {
63
74
  const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
64
75
  // Need to specify "key" in the manifest.json to make the id stable when loading from file.
65
76
  const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
@@ -72,7 +83,11 @@ export class CDPRelayServer {
72
83
  const executablePath = executableInfo.executablePath();
73
84
  if (!executablePath)
74
85
  throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
75
- spawn(executablePath, [href], {
86
+ const args = [];
87
+ if (this._userDataDir)
88
+ args.push(`--user-data-dir=${this._userDataDir}`);
89
+ args.push(href);
90
+ spawn(executablePath, args, {
76
91
  windowsHide: true,
77
92
  detached: true,
78
93
  shell: false,
@@ -248,45 +263,6 @@ export class CDPRelayServer {
248
263
  this._playwrightConnection?.send(JSON.stringify(message));
249
264
  }
250
265
  }
251
- class ExtensionContextFactory {
252
- _relay;
253
- _browserPromise;
254
- constructor(relay) {
255
- this._relay = relay;
256
- }
257
- async createContext(clientInfo) {
258
- // First call will establish the connection to the extension.
259
- if (!this._browserPromise)
260
- this._browserPromise = this._obtainBrowser(clientInfo);
261
- const browser = await this._browserPromise;
262
- return {
263
- browserContext: browser.contexts()[0],
264
- close: async () => {
265
- debugLogger('close() called for browser context, ignoring');
266
- }
267
- };
268
- }
269
- clientDisconnected() {
270
- this._relay.closeConnections('MCP client disconnected');
271
- this._browserPromise = undefined;
272
- }
273
- async _obtainBrowser(clientInfo) {
274
- await this._relay.ensureExtensionConnectionForMCPContext(clientInfo);
275
- const browser = await playwright.chromium.connectOverCDP(this._relay.cdpEndpoint());
276
- browser.on('disconnected', () => {
277
- this._browserPromise = undefined;
278
- debugLogger('Browser disconnected');
279
- });
280
- return browser;
281
- }
282
- }
283
- export async function startCDPRelayServer(browserChannel, abortController) {
284
- const httpServer = await startHttpServer({});
285
- const cdpRelayServer = new CDPRelayServer(httpServer, browserChannel);
286
- abortController.signal.addEventListener('abort', () => cdpRelayServer.stop());
287
- debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
288
- return new ExtensionContextFactory(cdpRelayServer);
289
- }
290
266
  class ExtensionConnection {
291
267
  _ws;
292
268
  _callbacks = new Map();