@playwright/mcp 0.0.25 → 0.0.26

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
@@ -146,6 +146,8 @@ Playwright MCP server supports following arguments. They can be provided in the
146
146
  example ".com,chromium.org,.domain.com"
147
147
  --proxy-server <proxy> specify proxy server, for example
148
148
  "http://myproxy:3128" or "socks5://myproxy:8080"
149
+ --save-trace Whether to save the Playwright Trace of the
150
+ session into the output directory.
149
151
  --storage-state <path> path to the storage state file for isolated
150
152
  sessions.
151
153
  --user-agent <ua string> specify user agent string
package/config.d.ts CHANGED
@@ -94,6 +94,11 @@ export type Config = {
94
94
  */
95
95
  vision?: boolean;
96
96
 
97
+ /**
98
+ * Whether to save the Playwright trace of the session into the output directory.
99
+ */
100
+ saveTrace?: boolean;
101
+
97
102
  /**
98
103
  * The directory to save output files.
99
104
  */
package/lib/config.js CHANGED
@@ -35,11 +35,19 @@ const defaultConfig = {
35
35
  allowedOrigins: undefined,
36
36
  blockedOrigins: undefined,
37
37
  },
38
+ outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
38
39
  };
39
- export async function resolveConfig(cliOptions) {
40
- const config = await loadConfig(cliOptions.config);
40
+ export async function resolveConfig(config) {
41
+ return mergeConfig(defaultConfig, config);
42
+ }
43
+ export async function resolveCLIConfig(cliOptions) {
44
+ const configInFile = await loadConfig(cliOptions.config);
41
45
  const cliOverrides = await configFromCLIOptions(cliOptions);
42
- return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
46
+ const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
47
+ // Derive artifact output directory from config.outputDir
48
+ if (result.saveTrace)
49
+ result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
50
+ return result;
43
51
  }
44
52
  export async function configFromCLIOptions(cliOptions) {
45
53
  let browserName;
@@ -127,6 +135,7 @@ export async function configFromCLIOptions(cliOptions) {
127
135
  allowedOrigins: cliOptions.allowedOrigins,
128
136
  blockedOrigins: cliOptions.blockedOrigins,
129
137
  },
138
+ saveTrace: cliOptions.saveTrace,
130
139
  outputDir: cliOptions.outputDir,
131
140
  };
132
141
  if (!cliOptions.imageResponses) {
@@ -156,18 +165,17 @@ async function loadConfig(configFile) {
156
165
  }
157
166
  }
158
167
  export async function outputFile(config, name) {
159
- const result = config.outputDir ?? os.tmpdir();
160
- await fs.promises.mkdir(result, { recursive: true });
168
+ await fs.promises.mkdir(config.outputDir, { recursive: true });
161
169
  const fileName = sanitizeForFilePath(name);
162
- return path.join(result, fileName);
170
+ return path.join(config.outputDir, fileName);
163
171
  }
164
172
  function pickDefined(obj) {
165
173
  return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined));
166
174
  }
167
175
  function mergeConfig(base, overrides) {
168
176
  const browser = {
169
- ...pickDefined(base.browser),
170
- ...pickDefined(overrides.browser),
177
+ browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
178
+ isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
171
179
  launchOptions: {
172
180
  ...pickDefined(base.browser?.launchOptions),
173
181
  ...pickDefined(overrides.browser?.launchOptions),
@@ -177,6 +185,9 @@ function mergeConfig(base, overrides) {
177
185
  ...pickDefined(base.browser?.contextOptions),
178
186
  ...pickDefined(overrides.browser?.contextOptions),
179
187
  },
188
+ userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
189
+ cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
190
+ remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
180
191
  };
181
192
  if (browser.browserName !== 'chromium' && browser.launchOptions)
182
193
  delete browser.launchOptions.channel;
@@ -187,6 +198,6 @@ function mergeConfig(base, overrides) {
187
198
  network: {
188
199
  ...pickDefined(base.network),
189
200
  ...pickDefined(overrides.network),
190
- },
201
+ }
191
202
  };
192
203
  }
package/lib/context.js CHANGED
@@ -18,7 +18,7 @@ import url from 'node:url';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import * as playwright from 'playwright';
21
- import { waitForCompletion } from './tools/utils.js';
21
+ import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
22
22
  import { ManualPromise } from './manualPromise.js';
23
23
  import { Tab } from './tab.js';
24
24
  import { outputFile } from './config.js';
@@ -84,7 +84,7 @@ export class Context {
84
84
  const lines = ['### Open tabs'];
85
85
  for (let i = 0; i < this._tabs.length; i++) {
86
86
  const tab = this._tabs[i];
87
- const title = await tab.page.title();
87
+ const title = await tab.title();
88
88
  const url = tab.page.url();
89
89
  const current = tab === this._currentTab ? ' (current)' : '';
90
90
  lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
@@ -116,7 +116,7 @@ export class Context {
116
116
  let actionResult;
117
117
  try {
118
118
  if (waitForNetwork)
119
- actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
119
+ actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
120
120
  else
121
121
  actionResult = await racingAction?.() ?? undefined;
122
122
  }
@@ -153,7 +153,7 @@ ${code.join('\n')}
153
153
  result.push(await this.listTabsMarkdown(), '');
154
154
  if (this.tabs().length > 1)
155
155
  result.push('### Current tab');
156
- result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.page.title()}`);
156
+ result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.title()}`);
157
157
  if (captureSnapshot && tab.hasSnapshot())
158
158
  result.push(tab.snapshotOrDie().text());
159
159
  const content = actionResult?.content ?? [];
@@ -168,10 +168,13 @@ ${code.join('\n')}
168
168
  };
169
169
  }
170
170
  async waitForTimeout(time) {
171
- if (this._currentTab && !this._javaScriptBlocked())
172
- await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
173
- else
171
+ if (!this._currentTab || this._javaScriptBlocked()) {
174
172
  await new Promise(f => setTimeout(f, time));
173
+ return;
174
+ }
175
+ await callOnPageNoTrace(this._currentTab.page, page => {
176
+ return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
177
+ });
175
178
  }
176
179
  async _raceAgainstModalDialogs(action) {
177
180
  this._pendingAction = {
@@ -233,6 +236,8 @@ ${code.join('\n')}
233
236
  const promise = this._browserContextPromise;
234
237
  this._browserContextPromise = undefined;
235
238
  await promise.then(async ({ browserContext, browser }) => {
239
+ if (this.config.saveTrace)
240
+ await browserContext.tracing.stop();
236
241
  await browserContext.close().then(async () => {
237
242
  await browser?.close();
238
243
  }).catch(() => { });
@@ -264,6 +269,14 @@ ${code.join('\n')}
264
269
  for (const page of browserContext.pages())
265
270
  this._onPageCreated(page);
266
271
  browserContext.on('page', page => this._onPageCreated(page));
272
+ if (this.config.saveTrace) {
273
+ await browserContext.tracing.start({
274
+ name: 'trace',
275
+ screenshots: false,
276
+ snapshots: true,
277
+ sources: false,
278
+ });
279
+ }
267
280
  return { browser, browserContext };
268
281
  }
269
282
  async _createBrowserContext() {
@@ -291,8 +304,8 @@ async function createIsolatedContext(browserConfig) {
291
304
  try {
292
305
  const browserName = browserConfig?.browserName ?? 'chromium';
293
306
  const browserType = playwright[browserName];
294
- const browser = await browserType.launch(browserConfig?.launchOptions);
295
- const browserContext = await browser.newContext(browserConfig?.contextOptions);
307
+ const browser = await browserType.launch(browserConfig.launchOptions);
308
+ const browserContext = await browser.newContext(browserConfig.contextOptions);
296
309
  return { browser, browserContext };
297
310
  }
298
311
  catch (error) {
@@ -303,10 +316,10 @@ async function createIsolatedContext(browserConfig) {
303
316
  }
304
317
  async function launchPersistentContext(browserConfig) {
305
318
  try {
306
- const browserName = browserConfig?.browserName ?? 'chromium';
307
- const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
319
+ const browserName = browserConfig.browserName ?? 'chromium';
320
+ const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
308
321
  const browserType = playwright[browserName];
309
- const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
322
+ const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
310
323
  return { browserContext };
311
324
  }
312
325
  catch (error) {
@@ -325,12 +338,9 @@ async function createUserDataDir(browserConfig) {
325
338
  cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
326
339
  else
327
340
  throw new Error('Unsupported platform: ' + process.platform);
328
- const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
341
+ const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
329
342
  await fs.promises.mkdir(result, { recursive: true });
330
343
  return result;
331
344
  }
332
- export async function generateLocator(locator) {
333
- return locator._generateLocatorString();
334
- }
335
345
  const __filename = url.fileURLToPath(import.meta.url);
336
346
  export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
package/lib/index.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { createConnection as createConnectionImpl } from './connection.js';
17
- export async function createConnection(config = {}) {
17
+ import { resolveConfig } from './config.js';
18
+ export async function createConnection(userConfig = {}) {
19
+ const config = await resolveConfig(userConfig);
18
20
  return createConnectionImpl(config);
19
21
  }
@@ -13,6 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ import { callOnPageNoTrace } from './tools/utils.js';
16
17
  export class PageSnapshot {
17
18
  _page;
18
19
  _text;
@@ -28,11 +29,14 @@ export class PageSnapshot {
28
29
  return this._text;
29
30
  }
30
31
  async _build() {
31
- const yamlDocument = await this._page._snapshotForAI();
32
+ // FIXME: Rountrip evaluate to ensure _snapshotForAI works.
33
+ // This probably broke once we moved off locator snapshots
34
+ await this._page.evaluate(() => 1);
35
+ const snapshot = await callOnPageNoTrace(this._page, page => page._snapshotForAI());
32
36
  this._text = [
33
37
  `- Page Snapshot`,
34
38
  '```yaml',
35
- yamlDocument.toString({ indentSeq: false }).trim(),
39
+ snapshot,
36
40
  '```',
37
41
  ].join('\n');
38
42
  }
package/lib/program.js CHANGED
@@ -15,7 +15,9 @@
15
15
  */
16
16
  import { program } from 'commander';
17
17
  import { startHttpTransport, startStdioTransport } from './transport.js';
18
- import { resolveConfig } from './config.js';
18
+ import { resolveCLIConfig } from './config.js';
19
+ // @ts-ignore
20
+ import { startTraceViewerServer } from 'playwright-core/lib/server';
19
21
  import { packageJSON } from './context.js';
20
22
  program
21
23
  .version('Version ' + packageJSON.version)
@@ -39,19 +41,27 @@ program
39
41
  .option('--port <port>', 'port to listen on for SSE transport.')
40
42
  .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
41
43
  .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
44
+ .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
42
45
  .option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
43
46
  .option('--user-agent <ua string>', 'specify user agent string')
44
47
  .option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
45
48
  .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
46
49
  .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
47
50
  .action(async (options) => {
48
- const config = await resolveConfig(options);
51
+ const config = await resolveCLIConfig(options);
49
52
  const connectionList = [];
50
53
  setupExitWatchdog(connectionList);
51
54
  if (options.port)
52
55
  startHttpTransport(config, +options.port, options.host, connectionList);
53
56
  else
54
57
  await startStdioTransport(config, connectionList);
58
+ if (config.saveTrace) {
59
+ const server = await startTraceViewerServer();
60
+ const urlPrefix = server.urlPrefix('human-readable');
61
+ const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
62
+ // eslint-disable-next-line no-console
63
+ console.error('\nTrace viewer listening on ' + url);
64
+ }
55
65
  });
56
66
  function setupExitWatchdog(connectionList) {
57
67
  const handleExit = async () => {
package/lib/tab.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { PageSnapshot } from './pageSnapshot.js';
17
+ import { callOnPageNoTrace } from './tools/utils.js';
17
18
  export class Tab {
18
19
  context;
19
20
  page;
@@ -51,9 +52,15 @@ export class Tab {
51
52
  this._clearCollectedArtifacts();
52
53
  this._onPageClose(this);
53
54
  }
55
+ async title() {
56
+ return await callOnPageNoTrace(this.page, page => page.title());
57
+ }
58
+ async waitForLoadState(state, options) {
59
+ await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => { }));
60
+ }
54
61
  async navigate(url) {
55
62
  this._clearCollectedArtifacts();
56
- const downloadEvent = this.page.waitForEvent('download').catch(() => { });
63
+ const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => { }));
57
64
  try {
58
65
  await this.page.goto(url, { waitUntil: 'domcontentloaded' });
59
66
  }
@@ -72,7 +79,7 @@ export class Tab {
72
79
  throw e;
73
80
  }
74
81
  // Cap load event to 5 seconds, the page is operational at this point.
75
- await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => { });
82
+ await this.waitForLoadState('load', { timeout: 5000 });
76
83
  }
77
84
  hasSnapshot() {
78
85
  return !!this._snapshot;
@@ -13,7 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- export async function waitForCompletion(context, page, callback) {
16
+ export async function waitForCompletion(context, tab, callback) {
17
17
  const requests = new Set();
18
18
  let frameNavigated = false;
19
19
  let waitCallback = () => { };
@@ -30,22 +30,20 @@ export async function waitForCompletion(context, page, callback) {
30
30
  frameNavigated = true;
31
31
  dispose();
32
32
  clearTimeout(timeout);
33
- void frame.waitForLoadState('load').then(() => {
34
- waitCallback();
35
- });
33
+ void tab.waitForLoadState('load').then(waitCallback);
36
34
  };
37
35
  const onTimeout = () => {
38
36
  dispose();
39
37
  waitCallback();
40
38
  };
41
- page.on('request', requestListener);
42
- page.on('requestfinished', requestFinishedListener);
43
- page.on('framenavigated', frameNavigateListener);
39
+ tab.page.on('request', requestListener);
40
+ tab.page.on('requestfinished', requestFinishedListener);
41
+ tab.page.on('framenavigated', frameNavigateListener);
44
42
  const timeout = setTimeout(onTimeout, 10000);
45
43
  const dispose = () => {
46
- page.off('request', requestListener);
47
- page.off('requestfinished', requestFinishedListener);
48
- page.off('framenavigated', frameNavigateListener);
44
+ tab.page.off('request', requestListener);
45
+ tab.page.off('requestfinished', requestFinishedListener);
46
+ tab.page.off('framenavigated', frameNavigateListener);
49
47
  clearTimeout(timeout);
50
48
  };
51
49
  try {
@@ -68,5 +66,8 @@ export function sanitizeForFilePath(s) {
68
66
  return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
69
67
  }
70
68
  export async function generateLocator(locator) {
71
- return locator._generateLocatorString();
69
+ return locator._frame._wrapApiCall(() => locator._generateLocatorString(), true);
70
+ }
71
+ export async function callOnPageNoTrace(page, callback) {
72
+ return await page._wrapApiCall(() => callback(page), true);
72
73
  }
package/lib/transport.js CHANGED
@@ -127,6 +127,6 @@ export function startHttpTransport(config, port, hostname, connectionList) {
127
127
  'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
128
128
  ].join('\n');
129
129
  // eslint-disable-next-line no-console
130
- console.log(message);
130
+ console.error(message);
131
131
  });
132
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwright/mcp",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "type": "module",
6
6
  "repository": {