@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.
- package/lib/browserContextFactory.js +248 -14
- package/lib/config.js +4 -2
- package/lib/context.js +10 -1
- package/lib/program.js +20 -0
- package/lib/response.js +11 -1
- package/lib/tools/getSnapshot.js +1 -1
- package/lib/tools/navigate.js +5 -0
- package/lib/tools/scroll.js +5 -0
- package/lib/tools/snapshot.js +5 -0
- package/lib/utils/screenshot.js +43 -0
- package/package.json +16 -15
|
@@ -109,11 +109,16 @@ async function hidePlaywrightMarkers(browserContext) {
|
|
|
109
109
|
},
|
|
110
110
|
};
|
|
111
111
|
const proxiedWindow = new Proxy(window, windowHandler);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
//
|
|
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
|
|
97
|
-
"--
|
|
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
|
-
{
|
|
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") {
|
package/lib/tools/getSnapshot.js
CHANGED
|
@@ -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("```");
|
package/lib/tools/navigate.js
CHANGED
|
@@ -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
|
},
|
package/lib/tools/scroll.js
CHANGED
|
@@ -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];
|
package/lib/tools/snapshot.js
CHANGED
|
@@ -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.
|
|
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.
|
|
50
|
-
"@ghostery/adblocker": "2.13.
|
|
51
|
-
"@
|
|
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.
|
|
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.
|
|
79
|
-
"@types/node": "
|
|
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.
|
|
85
|
-
"esbuild": "0.27.
|
|
86
|
-
"openai": "6.
|
|
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": {
|