@wordbricks/playwright-mcp 0.1.26 → 0.1.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.
@@ -109,11 +109,16 @@ async function hidePlaywrightMarkers(browserContext) {
109
109
  },
110
110
  };
111
111
  const proxiedWindow = new Proxy(window, windowHandler);
112
- Object.defineProperty(globalThis, "window", {
113
- value: proxiedWindow,
114
- configurable: false,
115
- writable: false,
116
- });
112
+ try {
113
+ Object.defineProperty(globalThis, "window", {
114
+ value: proxiedWindow,
115
+ configurable: true,
116
+ writable: false,
117
+ });
118
+ }
119
+ catch {
120
+ // Ignore if window property is already non-configurable
121
+ }
117
122
  });
118
123
  }
119
124
  export function contextFactory(config) {
@@ -232,6 +237,10 @@ class IsolatedContextFactory extends BaseContextFactory {
232
237
  ...this.config.browser.contextOptions,
233
238
  bypassCSP: true,
234
239
  });
240
+ // Grant local-network-access permission to suppress LNA prompt (Chrome 142+)
241
+ await browserContext
242
+ .grantPermissions(["local-network-access"])
243
+ .catch(() => { });
235
244
  await applyInitScript(browserContext, this.config);
236
245
  await hidePlaywrightMarkers(browserContext);
237
246
  return browserContext;
@@ -248,6 +257,10 @@ class CdpContextFactory extends BaseContextFactory {
248
257
  const browserContext = this.config.browser.isolated
249
258
  ? await browser.newContext({ bypassCSP: true })
250
259
  : browser.contexts()[0];
260
+ // Grant local-network-access permission to suppress LNA prompt (Chrome 142+)
261
+ await browserContext
262
+ .grantPermissions(["local-network-access"])
263
+ .catch(() => { });
251
264
  await applyInitScript(browserContext, this.config);
252
265
  await hidePlaywrightMarkers(browserContext);
253
266
  return browserContext;
@@ -288,6 +301,20 @@ class PersistentContextFactory {
288
301
  const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
289
302
  this._userDataDirs.add(userDataDir);
290
303
  testDebug("lock user data dir", userDataDir);
304
+ // Try to connect to an existing browser via CDP first
305
+ // This enables concurrent chats to share the same browser instance
306
+ const cdpResult = await this._tryConnectViaCDP(userDataDir);
307
+ if (cdpResult) {
308
+ testDebug("connected to existing browser via CDP");
309
+ const { browserContext, initialPage } = cdpResult;
310
+ await applyInitScript(browserContext, this.config);
311
+ await hidePlaywrightMarkers(browserContext);
312
+ // Increment ref count for this CDP connection
313
+ await this._incrementRefCount(userDataDir);
314
+ // Close only this chat's page, not the entire shared context
315
+ const close = () => this._closeCdpContext(initialPage, userDataDir);
316
+ return { browserContext, close, initialPage };
317
+ }
291
318
  // Disable password manager in Chrome preferences before launch
292
319
  await disablePasswordManagerInPrefs(userDataDir);
293
320
  const browserType = playwright[this.config.browser.browserName];
@@ -301,19 +328,48 @@ class PersistentContextFactory {
301
328
  handleSIGINT: false,
302
329
  handleSIGTERM: false,
303
330
  });
331
+ // Write CDP port to file for other instances to connect
332
+ const cdpPort = this.config.browser.launchOptions?.cdpPort;
333
+ if (cdpPort) {
334
+ const cdpEndpointFile = path.join(userDataDir, "PlaywrightCdpPort");
335
+ await fs.promises.writeFile(cdpEndpointFile, String(cdpPort), "utf8");
336
+ }
337
+ // Get the initial page (persistent context creates one automatically)
338
+ const initialPage = browserContext.pages()[0];
339
+ // Grant local-network-access permission to suppress LNA prompt (Chrome 142+)
340
+ await browserContext
341
+ .grantPermissions(["local-network-access"])
342
+ .catch(() => { });
304
343
  await applyInitScript(browserContext, this.config);
305
344
  await hidePlaywrightMarkers(browserContext);
345
+ // Increment ref count for this persistent context
346
+ await this._incrementRefCount(userDataDir);
306
347
  // Start auto-close timer
307
348
  this._startAutoCloseTimer(browserContext);
308
- const close = () => this._closeBrowserContext(browserContext, userDataDir);
309
- return { browserContext, close };
349
+ // Only close the initial page, not the entire context
350
+ // This allows other chats to continue using the shared browser
351
+ const close = () => this._closePersistentPage(initialPage, browserContext, userDataDir);
352
+ return { browserContext, close, initialPage };
310
353
  }
311
354
  catch (error) {
312
355
  if (error.message.includes("Executable doesn't exist"))
313
356
  throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
314
357
  if (error.message.includes("ProcessSingleton") ||
315
358
  error.message.includes("Invalid URL")) {
316
- // User data directory is already in use, try again.
359
+ // User data directory is already in use, try CDP connection
360
+ testDebug("browser in use, trying CDP connection...");
361
+ const cdpRetry = await this._tryConnectViaCDP(userDataDir);
362
+ if (cdpRetry) {
363
+ testDebug("connected to existing browser via CDP (retry)");
364
+ const { browserContext, initialPage } = cdpRetry;
365
+ await applyInitScript(browserContext, this.config);
366
+ await hidePlaywrightMarkers(browserContext);
367
+ // Increment ref count for this CDP connection
368
+ await this._incrementRefCount(userDataDir);
369
+ // Close only this chat's page, not the entire shared context
370
+ const close = () => this._closeCdpContext(initialPage, userDataDir);
371
+ return { browserContext, close, initialPage };
372
+ }
317
373
  await new Promise((resolve) => setTimeout(resolve, 1000));
318
374
  continue;
319
375
  }
@@ -322,6 +378,89 @@ class PersistentContextFactory {
322
378
  }
323
379
  throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
324
380
  }
381
+ /**
382
+ * Try to connect to an existing browser via CDP.
383
+ * First tries PlaywrightCdpPort (written by our code), then falls back to DevToolsActivePort.
384
+ */
385
+ async _tryConnectViaCDP(userDataDir) {
386
+ const playwrightPortFile = path.join(userDataDir, "PlaywrightCdpPort");
387
+ const devToolsPortFile = path.join(userDataDir, "DevToolsActivePort");
388
+ try {
389
+ let port = null;
390
+ // Try PlaywrightCdpPort first (written by our code)
391
+ try {
392
+ const content = await fs.promises.readFile(playwrightPortFile, "utf8");
393
+ port = Number.parseInt(content.trim(), 10);
394
+ }
395
+ catch {
396
+ // Fall back to DevToolsActivePort (written by Chrome)
397
+ const content = await fs.promises.readFile(devToolsPortFile, "utf8");
398
+ const lines = content.trim().split("\n");
399
+ port = Number.parseInt(lines[0], 10);
400
+ }
401
+ if (!port || Number.isNaN(port)) {
402
+ testDebug("invalid port in CDP port file");
403
+ return null;
404
+ }
405
+ testDebug(`attempting CDP connection on port ${port}`);
406
+ const cdpEndpoint = `http://127.0.0.1:${port}`;
407
+ // Try to connect with a short timeout
408
+ const browser = await Promise.race([
409
+ playwright.chromium.connectOverCDP(cdpEndpoint),
410
+ new Promise((_, reject) => setTimeout(() => reject(new Error("CDP connection timeout")), 3000)),
411
+ ]);
412
+ // Reuse existing context to share login sessions
413
+ const existingContexts = browser.contexts();
414
+ const browserContext = existingContexts.length > 0
415
+ ? existingContexts[0]
416
+ : await browser.newContext({
417
+ ...this.config.browser.contextOptions,
418
+ bypassCSP: true,
419
+ });
420
+ // Get window size and position from launch args for the popup
421
+ const args = this.config.browser.launchOptions?.args || [];
422
+ const sizeArg = args.find((arg) => arg.startsWith("--window-size="));
423
+ const posArg = args.find((arg) => arg.startsWith("--window-position="));
424
+ const appArg = args.find((arg) => arg.startsWith("--app="));
425
+ const [width, height] = sizeArg
426
+ ? sizeArg.replace("--window-size=", "").split(",").map(Number)
427
+ : [600, 1070];
428
+ const [left, top] = posArg
429
+ ? posArg.replace("--window-position=", "").split(",").map(Number)
430
+ : [1200, 40];
431
+ const appUrl = appArg ? appArg.replace("--app=", "") : "about:blank";
432
+ // Use window.open with popup features to create an app-like window (no address bar)
433
+ const existingPage = browserContext.pages()[0];
434
+ let newPage;
435
+ if (existingPage) {
436
+ const popupFeatures = `popup=true,width=${width},height=${height},left=${left},top=${top}`;
437
+ // Create popup and wait for the new page
438
+ const [popupPage] = await Promise.all([
439
+ browserContext.waitForEvent("page", { timeout: 5000 }),
440
+ existingPage.evaluate(([url, features]) => window.open(url, "_blank", features), [appUrl, popupFeatures]),
441
+ ]);
442
+ newPage = popupPage;
443
+ }
444
+ return { browser, browserContext, initialPage: newPage };
445
+ }
446
+ catch (error) {
447
+ testDebug(`CDP connection failed: ${error}`);
448
+ return null;
449
+ }
450
+ }
451
+ async _closeCdpContext(page, userDataDir) {
452
+ testDebug("close CDP page");
453
+ // Only close the specific page belonging to this chat, not the entire context
454
+ // Other chats may still be using the shared browserContext
455
+ if (page && !page.isClosed()) {
456
+ await page.close().catch(logUnhandledError);
457
+ }
458
+ // Decrement reference count
459
+ const remainingRefs = await this._decrementRefCount(userDataDir);
460
+ testDebug("remaining refs after CDP close:", remainingRefs);
461
+ // Note: We don't close the browser from CDP connections - the original
462
+ // persistent context owner (Chat A) or auto-close timer will handle that
463
+ }
325
464
  _startAutoCloseTimer(browserContext) {
326
465
  this._clearAutoCloseTimer();
327
466
  testDebug(`schedule auto-close in ${TIMEOUT_STR} (persistent)`);
@@ -351,6 +490,103 @@ class PersistentContextFactory {
351
490
  this._userDataDirs.delete(userDataDir);
352
491
  testDebug("close browser context complete (persistent)");
353
492
  }
493
+ async _closePersistentPage(page, browserContext, userDataDir) {
494
+ testDebug("close persistent page");
495
+ // Decrement reference count first to check if others are using the browser
496
+ const remainingRefs = await this._decrementRefCount(userDataDir);
497
+ testDebug("remaining refs after close:", remainingRefs);
498
+ if (remainingRefs <= 0) {
499
+ // No other processes using the browser, safe to close everything
500
+ testDebug("no refs left, closing browser context");
501
+ this._clearAutoCloseTimer();
502
+ await browserContext.close().catch(() => { });
503
+ this._userDataDirs.delete(userDataDir);
504
+ }
505
+ else {
506
+ // Other processes are still using the browser
507
+ // Don't close the page - just navigate to blank to "release" it
508
+ // Closing the page would close popup windows opened from it (window.open behavior)
509
+ testDebug("other refs exist, navigating to blank instead of closing page");
510
+ if (page && !page.isClosed()) {
511
+ await page.goto("about:blank").catch(() => { });
512
+ }
513
+ }
514
+ }
515
+ /**
516
+ * Increment the reference count for a shared browser userDataDir.
517
+ * Each MCP process using the browser increments this count.
518
+ */
519
+ async _incrementRefCount(userDataDir) {
520
+ const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
521
+ const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
522
+ // Simple file-based locking
523
+ for (let i = 0; i < 50; i++) {
524
+ try {
525
+ await fs.promises.writeFile(lockFile, String(process.pid), {
526
+ flag: "wx",
527
+ });
528
+ break;
529
+ }
530
+ catch {
531
+ await new Promise((resolve) => setTimeout(resolve, 100));
532
+ }
533
+ }
534
+ try {
535
+ let count = 0;
536
+ try {
537
+ const content = await fs.promises.readFile(refCountFile, "utf8");
538
+ count = Number.parseInt(content, 10) || 0;
539
+ }
540
+ catch {
541
+ // File doesn't exist, start at 0
542
+ }
543
+ count++;
544
+ await fs.promises.writeFile(refCountFile, String(count), "utf8");
545
+ testDebug("incremented ref count to:", count);
546
+ return count;
547
+ }
548
+ finally {
549
+ await fs.promises.unlink(lockFile).catch(() => { });
550
+ }
551
+ }
552
+ /**
553
+ * Decrement the reference count for a shared browser userDataDir.
554
+ * Returns the new count after decrementing.
555
+ */
556
+ async _decrementRefCount(userDataDir) {
557
+ const refCountFile = path.join(userDataDir, "PlaywrightRefCount");
558
+ const lockFile = path.join(userDataDir, "PlaywrightRefCount.lock");
559
+ // Simple file-based locking
560
+ for (let i = 0; i < 50; i++) {
561
+ try {
562
+ await fs.promises.writeFile(lockFile, String(process.pid), {
563
+ flag: "wx",
564
+ });
565
+ break;
566
+ }
567
+ catch {
568
+ await new Promise((resolve) => setTimeout(resolve, 100));
569
+ }
570
+ }
571
+ try {
572
+ let count = 0;
573
+ try {
574
+ const content = await fs.promises.readFile(refCountFile, "utf8");
575
+ count = Number.parseInt(content, 10) || 0;
576
+ }
577
+ catch {
578
+ // File doesn't exist
579
+ return 0;
580
+ }
581
+ count = Math.max(0, count - 1);
582
+ await fs.promises.writeFile(refCountFile, String(count), "utf8");
583
+ testDebug("decremented ref count to:", count);
584
+ return count;
585
+ }
586
+ finally {
587
+ await fs.promises.unlink(lockFile).catch(() => { });
588
+ }
589
+ }
354
590
  async _createUserDataDir(rootPath) {
355
591
  const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
356
592
  const browserToken = this.config.browser.launchOptions?.channel ??
@@ -363,12 +599,10 @@ class PersistentContextFactory {
363
599
  }
364
600
  }
365
601
  async function injectCdpPort(browserConfig) {
366
- const isBunRuntime = "bun" in process.versions;
367
- const isWindows = process.platform === "win32";
368
- // On Windows, always use CDP port because Bun has issues with --remote-debugging-pipe
369
- // On other platforms, Bun handles pipes correctly
370
- if (!isWindows && isBunRuntime)
371
- return;
602
+ // Always use CDP port for Chromium to enable concurrent browser access.
603
+ // When using a shared user data directory, subsequent instances need to
604
+ // connect via CDP to the existing browser instead of launching a new one.
605
+ // Chrome writes the assigned port to DevToolsActivePort file.
372
606
  if (browserConfig.browserName !== "chromium")
373
607
  return;
374
608
  const launchOptions = browserConfig.launchOptions || {};
package/lib/config.js CHANGED
@@ -93,8 +93,10 @@ export function configFromCLIOptions(cliOptions) {
93
93
  "--hide-crash-restore-bubble",
94
94
  // First run & default browser
95
95
  "--no-first-run", "--no-default-browser-check",
96
- // Disable various Chrome features (Translate, PasswordManager, Autofill, Sync)
97
- "--disable-features=Translate,PasswordManager,PasswordManagerEnabled,PasswordManagerOnboarding,AutofillServerCommunication,CredentialManagerOnboarding", "--disable-sync",
96
+ // Disable Cast/Media Router and local network discovery
97
+ "--media-router=0", "--disable-cast", "--disable-cast-streaming-hw-encoding",
98
+ // Disable various Chrome features (Translate, PasswordManager, Autofill, Sync, MediaRouter, Cast)
99
+ "--disable-features=Translate,PasswordManager,PasswordManagerEnabled,PasswordManagerOnboarding,AutofillServerCommunication,CredentialManagerOnboarding,MediaRouter,GlobalMediaControls,CastMediaRouteProvider,DialMediaRouteProvider,CastAllowAllIPs,EnableCastDiscovery,LocalNetworkAccessCheck", "--disable-sync",
98
100
  // Disable password manager via experimental options
99
101
  "--enable-features=DisablePasswordManager");
100
102
  // --app was passed, add app mode argument
package/lib/context.js CHANGED
@@ -234,13 +234,22 @@ export class Context {
234
234
  throw new Error("Another browser context is being closed.");
235
235
  // TODO: move to the browser context factory to make it based on isolation mode.
236
236
  const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
237
- const { browserContext } = result;
237
+ const { browserContext, initialPage } = result;
238
238
  await this._setupRequestInterception(browserContext);
239
239
  if (this.sessionLog)
240
240
  await InputRecorder.create(this, browserContext);
241
241
  for (const page of browserContext.pages())
242
242
  this._onPageCreated(page);
243
243
  browserContext.on("page", (page) => this._onPageCreated(page));
244
+ // If an initialPage was provided (e.g., from CDP connection), set it as the current tab
245
+ // This ensures each chat controls its own browser window, not someone else's
246
+ if (initialPage) {
247
+ const tab = this._tabs.find((t) => t.page === initialPage);
248
+ if (tab) {
249
+ this._currentTab = tab;
250
+ await initialPage.bringToFront();
251
+ }
252
+ }
244
253
  if (this.config.saveTrace) {
245
254
  await browserContext.tracing.start({
246
255
  name: "trace",
package/lib/program.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { Option, program } from "commander";
17
+ import dotenv from "dotenv";
18
+ import fs from "fs";
17
19
  import { contextFactory } from "./browserContextFactory.js";
18
20
  import { BrowserServerBackend } from "./browserServerBackend.js";
19
21
  import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList, } from "./config.js";
@@ -24,6 +26,22 @@ import { ProxyBackend } from "./mcp/proxyBackend.js";
24
26
  import * as mcpServer from "./mcp/server.js";
25
27
  import * as mcpTransport from "./mcp/transport.js";
26
28
  import { packageJSON } from "./utils/package.js";
29
+ export function dotenvFileLoader(value) {
30
+ if (!value)
31
+ return undefined;
32
+ if (!fs.existsSync(value))
33
+ return undefined;
34
+ return dotenv.parse(fs.readFileSync(value, "utf8"));
35
+ }
36
+ function loadSecretsFromCli(options) {
37
+ const secrets = dotenvFileLoader(options.secret);
38
+ if (!secrets)
39
+ return;
40
+ for (const [key, value] of Object.entries(secrets)) {
41
+ if (process.env[key] === undefined)
42
+ process.env[key] = value;
43
+ }
44
+ }
27
45
  program
28
46
  .version("Version " + packageJSON.version)
29
47
  .name(packageJSON.name)
@@ -52,6 +70,7 @@ program
52
70
  .option("--storage-state <path>", "path to the storage state file for isolated sessions.")
53
71
  .option("--user-agent <ua string>", "specify user agent string")
54
72
  .option("--user-data-dir <path>", "path to the user data directory. If not specified, a temporary directory will be created.")
73
+ .option("--secret <path>", "path to a file containing secrets in the dotenv format")
55
74
  .option("--viewport-size <size>", 'specify browser viewport size in pixels, for example "1280, 720"')
56
75
  .option("--window-position <x,y>", 'specify Chrome window position in pixels, for example "100,200"')
57
76
  .option("--window-size <width,height>", 'specify Chrome window size in pixels, for example "1280,720"')
@@ -62,6 +81,7 @@ program
62
81
  .addOption(new Option("--vision", "Legacy option, use --caps=vision instead").hideHelp())
63
82
  .action(async (options) => {
64
83
  setupExitWatchdog();
84
+ loadSecretsFromCli(options);
65
85
  if (options.vision) {
66
86
  // eslint-disable-next-line no-console
67
87
  console.error("The --vision option is deprecated, use --caps=vision instead");
package/lib/response.js CHANGED
@@ -21,6 +21,7 @@ export class Response {
21
21
  _events = [];
22
22
  _code = [];
23
23
  _images = [];
24
+ _screenshotUrl = null;
24
25
  _context;
25
26
  _includeSnapshot = false;
26
27
  _includeTabs = false;
@@ -62,6 +63,9 @@ export class Response {
62
63
  images() {
63
64
  return this._images;
64
65
  }
66
+ addScreenshotUrl(url) {
67
+ this._screenshotUrl = url;
68
+ }
65
69
  // NOTE Wordbricks Disabled: Page state logging not needed
66
70
  setIncludeSnapshot(full) {
67
71
  // this._includeSnapshot = full ?? 'incremental';
@@ -125,7 +129,13 @@ ${this._code.join("\n")}
125
129
  }
126
130
  // Main response part
127
131
  const content = [
128
- { type: "text", text: response.join("\n") },
132
+ {
133
+ type: "text",
134
+ text: response.join("\n"),
135
+ ...(this._screenshotUrl && {
136
+ _meta: { screenShotUrl: this._screenshotUrl },
137
+ }),
138
+ },
129
139
  ];
130
140
  // Image attachments.
131
141
  if (this._context.config.imageResponses !== "omit") {
@@ -22,7 +22,7 @@ const getSnapshot = defineTool({
22
22
  lines.push(`- Page Title: ${snapshot.title}`);
23
23
  lines.push(`- Page Snapshot:`);
24
24
  lines.push("```yaml");
25
- let aria = snapshot.ariaSnapshot || "";
25
+ let aria = JSON.stringify(snapshot.ariaSnapshot) || ""; // HOTFIX: JSON.stringify is hotfix, Find root cause
26
26
  aria = String(truncate(aria, { maxStringLength: MAX_LENGTH }));
27
27
  lines.push(aria);
28
28
  lines.push("```");
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { z } from "zod";
17
+ import { captureAndUploadScreenshot } from "../utils/screenshot.js";
17
18
  import { defineTabTool, defineTool } from "./tool.js";
18
19
  const navigate = defineTool({
19
20
  capability: "core",
@@ -29,6 +30,10 @@ const navigate = defineTool({
29
30
  handle: async (context, params, response) => {
30
31
  const tab = await context.ensureTab();
31
32
  await tab.navigate(params.url);
33
+ const screenshotUrl = await captureAndUploadScreenshot(tab.page);
34
+ if (screenshotUrl) {
35
+ response.addScreenshotUrl(screenshotUrl);
36
+ }
32
37
  response.setIncludeSnapshot();
33
38
  response.addCode(`await page.goto('${params.url}');`);
34
39
  },
@@ -1,5 +1,6 @@
1
1
  import ms from "ms";
2
2
  import { z } from "zod";
3
+ import { captureAndUploadScreenshot } from "../utils/screenshot.js";
3
4
  import { defineTabTool } from "./tool.js";
4
5
  /**
5
6
  * Generate random number between min and max
@@ -126,6 +127,10 @@ const scrollWheel = defineTabTool({
126
127
  }));
127
128
  // Always show scroll position for scroll tool
128
129
  response.addResult(`Scroll position:\nBefore: x=${initialScrollPosition.x}, y=${initialScrollPosition.y}\nAfter: x=${finalScrollPosition.x}, y=${finalScrollPosition.y}`);
130
+ const screenshotUrl = await captureAndUploadScreenshot(tab.page);
131
+ if (screenshotUrl) {
132
+ response.addScreenshotUrl(screenshotUrl);
133
+ }
129
134
  },
130
135
  });
131
136
  export default [scrollWheel];
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { z } from "zod";
17
17
  import * as javascript from "../utils/codegen.js";
18
+ import { captureAndUploadScreenshot } from "../utils/screenshot.js";
18
19
  import { defineTabTool, defineTool } from "./tool.js";
19
20
  import { generateLocator } from "./utils.js";
20
21
  const snapshot = defineTool({
@@ -81,6 +82,10 @@ const click = defineTabTool({
81
82
  else
82
83
  await locator.click(options);
83
84
  });
85
+ const screenshotUrl = await captureAndUploadScreenshot(tab.page);
86
+ if (screenshotUrl) {
87
+ response.addScreenshotUrl(screenshotUrl);
88
+ }
84
89
  },
85
90
  });
86
91
  const drag = defineTabTool({
@@ -0,0 +1,43 @@
1
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+ import { createGuid } from "./guid.js";
3
+ const S3_REGION = "us-west-1";
4
+ const ENABLE_SCREENSHOT_VALUES = new Set(["1", "true", "yes", "on"]);
5
+ export const isScreenshotEnabled = () => ENABLE_SCREENSHOT_VALUES.has((process.env.ENABLE_SCREENSHOT_ON_NAVIGATE ?? "").toLowerCase());
6
+ /**
7
+ * Captures a screenshot, uploads it to S3, and returns the URL.
8
+ * Waits 1 second before taking the screenshot to let the page settle.
9
+ */
10
+ export async function captureAndUploadScreenshot(page) {
11
+ if (!isScreenshotEnabled())
12
+ return null;
13
+ const bucket = process.env.SCREENSHOT_S3_BUCKET;
14
+ const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
15
+ const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
16
+ const hasCredentials = Boolean(AWS_ACCESS_KEY_ID) && Boolean(AWS_SECRET_ACCESS_KEY);
17
+ if (!bucket || !hasCredentials)
18
+ return null;
19
+ await page.waitForLoadState("networkidle");
20
+ const s3Client = new S3Client({ region: S3_REGION });
21
+ const screenshotUrl = await page
22
+ .screenshot({
23
+ type: "jpeg",
24
+ quality: 80,
25
+ scale: "css",
26
+ timeout: 10000,
27
+ })
28
+ .then(async (screenshot) => {
29
+ const objectKey = `playwright-mcp/screenshot/${createGuid()}.jpg`;
30
+ await s3Client.send(new PutObjectCommand({
31
+ Bucket: bucket,
32
+ Key: objectKey,
33
+ Body: screenshot,
34
+ ContentType: "image/jpeg",
35
+ }));
36
+ return `https://${bucket}.s3.${S3_REGION}.amazonaws.com/${objectKey}`;
37
+ })
38
+ .catch((error) => {
39
+ console.error("Failed to capture and upload screenshot", error);
40
+ return null;
41
+ });
42
+ return screenshotUrl;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordbricks/playwright-mcp",
3
- "version": "0.1.26",
3
+ "version": "0.1.30",
4
4
  "description": "Playwright Tools for MCP",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,11 +18,11 @@
18
18
  "build": "tsc && bun run copy-filters",
19
19
  "copy-filters": "mkdir -p lib/filters && cp -r src/filters/*.txt lib/filters/ 2>/dev/null || true",
20
20
  "build:extension": "tsc --project extension",
21
- "dev": "concurrently --raw prefix none \"tsc --watch\" \"DEBUG=pw:mcp:* node --watch=lib cli.js --port 9224 --isolated\"",
22
- "dev:headless": "concurrently --raw prefix none \"tsc --watch\" \"DEBUG=pw:mcp:* node --watch=lib cli.js --port 9224 --isolated --headless\"",
23
- "start": "node cli.js --port 9224",
24
- "start:isolated": "node cli.js --port 9224 --isolated",
25
- "start:vision": "node cli.js --port 9224 --caps=vision",
21
+ "dev": "concurrently --raw prefix none \"tsc --watch\" \"DEBUG=pw:mcp:* node --watch=lib cli.js --port 9224 --isolated --secret .env.development\"",
22
+ "dev:headless": "concurrently --raw prefix none \"tsc --watch\" \"DEBUG=pw:mcp:* node --watch=lib cli.js --port 9224 --isolated --headless --secret .env.development\"",
23
+ "start": "node cli.js --port 9224 --secret .env.production",
24
+ "start:isolated": "node cli.js --port 9224 --isolated --secret .env.production",
25
+ "start:vision": "node cli.js --port 9224 --caps=vision --secret .env.production",
26
26
  "lint": "biome check --diagnostic-level=error",
27
27
  "format": "biome check --write --diagnostic-level=error",
28
28
  "check-types": "tsc --noEmit",
@@ -46,9 +46,10 @@
46
46
  "./cli.js": "./cli.js"
47
47
  },
48
48
  "dependencies": {
49
- "@fxts/core": "1.21.1",
50
- "@ghostery/adblocker": "2.13.0",
51
- "@modelcontextprotocol/sdk": "1.24.3",
49
+ "@fxts/core": "1.23.0",
50
+ "@ghostery/adblocker": "2.13.2",
51
+ "@aws-sdk/client-s3": "3.958.0",
52
+ "@modelcontextprotocol/sdk": "1.25.1",
52
53
  "cheerio": "1.1.2",
53
54
  "commander": "14.0.2",
54
55
  "content-type": "1.0.5",
@@ -60,7 +61,7 @@
60
61
  "lodash": "4.17.21",
61
62
  "mime": "4.1.0",
62
63
  "ms": "2.1.3",
63
- "playwright-core": "1.56.1",
64
+ "playwright-core": "1.57.0",
64
65
  "raw-body": "3.0.2",
65
66
  "typescript-parsec": "0.3.4",
66
67
  "ws": "8.18.3",
@@ -75,15 +76,15 @@
75
76
  "@types/content-type": "1.1.9",
76
77
  "@types/debug": "4.1.12",
77
78
  "@types/ms": "2.1.0",
78
- "@types/bun": "1.3.4",
79
- "@types/node": "24.10.1",
79
+ "@types/bun": "1.3.5",
80
+ "@types/node": "25.0.3",
80
81
  "@types/ws": "8.18.1",
81
82
  "concurrently": "9.2.1",
82
83
  "domelementtype": "2.3.0",
83
84
  "domhandler": "5.0.3",
84
- "devtools-protocol": "0.0.1558402",
85
- "esbuild": "0.27.1",
86
- "openai": "6.10.0",
85
+ "devtools-protocol": "0.0.1561482",
86
+ "esbuild": "0.27.2",
87
+ "openai": "6.15.0",
87
88
  "typescript": "5.9.3"
88
89
  },
89
90
  "bin": {