brave-mcp 1.10.1 → 1.11.0
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/build/src/BrowserManager.js +45 -34
- package/build/src/McpContext.js +51 -21
- package/build/src/McpPage.js +162 -33
- package/build/src/McpResponse.js +174 -60
- package/build/src/SlimMcpResponse.js +5 -1
- package/build/src/TextSnapshot.js +3 -2
- package/build/src/ToolHandler.js +57 -62
- package/build/src/bin/brave-devtools-mcp.js +1 -1
- package/build/src/bin/brave-devtools.js +15 -8
- package/build/src/config/ConfigLocator.js +5 -6
- package/build/src/config/ConfigParser.js +71 -20
- package/build/src/config/ConfigWatcher.js +93 -0
- package/build/src/config/category-options.js +5 -3
- package/build/src/config/cli-commands.js +162 -10
- package/build/src/config/cli-options.js +358 -40
- package/build/src/config/mcp-options.js +35 -13
- package/build/src/config/puppeteer-options.js +10 -13
- package/build/src/config/tool-options.js +4 -0
- package/build/src/daemon/client.js +35 -23
- package/build/src/daemon/daemon.js +4 -4
- package/build/src/daemon/utils.js +43 -1
- package/build/src/devtools/DevToolsCommentBridge.js +42 -5
- package/build/src/devtools/DevtoolsUtils.js +3 -3
- package/build/src/devtools/McpHostBindingAdapter.js +0 -1
- package/build/src/formatters/ConsoleFormatter.js +1 -1
- package/build/src/formatters/CssFormatter.js +3 -2
- package/build/src/formatters/HeapSnapshotFormatter.js +58 -53
- package/build/src/formatters/IssueFormatter.js +4 -3
- package/build/src/formatters/NetworkFormatter.js +12 -11
- package/build/src/index.js +7 -7
- package/build/src/processors/ChunkedTraceParser.js +11 -11
- package/build/src/processors/HeapSnapshotManager.js +118 -85
- package/build/src/processors/PerformanceTrace.js +9 -9
- package/build/src/telemetry/ClearcutLogger.js +1 -1
- package/build/src/telemetry/transformation.js +7 -11
- package/build/src/telemetry/watchdog/main.js +1 -1
- package/build/src/third_party/THIRD_PARTY_NOTICES +9 -9
- package/build/src/third_party/bundled-packages.json +5 -5
- package/build/src/third_party/devtools-formatter-worker.js +96 -16
- package/build/src/third_party/devtools-heap-snapshot-worker.js +30 -13
- package/build/src/third_party/index.js +4356 -2734
- package/build/src/third_party/issue-descriptions/genericGeolocationPromptWithoutUserGesture.md +5 -0
- package/build/src/third_party/lighthouse-devtools-mcp-bundle.js +3 -3
- package/build/src/tools/ToolDefinition.js +5 -7
- package/build/src/tools/comments.js +13 -0
- package/build/src/tools/emulation.js +17 -4
- package/build/src/tools/input.js +8 -3
- package/build/src/tools/lighthouse.js +113 -5
- package/build/src/tools/memory.js +87 -29
- package/build/src/tools/pages.js +2 -2
- package/build/src/tools/performance.js +21 -3
- package/build/src/tools/screencast.js +71 -32
- package/build/src/tools/screenshot.js +29 -14
- package/build/src/tools/script.js +1 -1
- package/build/src/tools/slim/tools.js +2 -7
- package/build/src/tools/thirdPartyDeveloper.js +3 -3
- package/build/src/tools/webmcp.js +6 -5
- package/build/src/types.js +11 -0
- package/build/src/utils/WaitForHelper.js +14 -1
- package/build/src/utils/check-for-updates.js +1 -1
- package/build/src/utils/errorHandling.js +3 -0
- package/build/src/utils/logger.js +6 -5
- package/build/src/utils/url.js +41 -10
- package/build/src/version.js +1 -1
- package/package.json +11 -8
- package/skills/a11y-debugging/SKILL.md +4 -4
- package/skills/brave-devtools-cli/SKILL.md +15 -13
- package/skills/cookie-debugging/SKILL.md +8 -5
- package/skills/debug-optimize-lcp/SKILL.md +1 -1
- package/skills/troubleshooting/SKILL.md +4 -5
|
@@ -63,17 +63,25 @@ import path from 'node:path';
|
|
|
63
63
|
import { Mutex, puppeteer } from './third_party/index.js';
|
|
64
64
|
import { logger, puppeteerLogger } from './utils/logger.js';
|
|
65
65
|
import { isAllowedUrl } from './utils/url.js';
|
|
66
|
+
// Node runs a timer whose delay exceeds 2^31-1 ms after 1 ms instead, which
|
|
67
|
+
// would make every CDP call and tool call time out at once.
|
|
68
|
+
const MAXIMUM_TIMER_DELAY_MILLISECONDS = 2_147_483_647;
|
|
69
|
+
export function parseProtocolTimeoutMilliseconds(rawValue) {
|
|
70
|
+
const timeoutMilliseconds = Number(rawValue ?? '600000');
|
|
71
|
+
if (!Number.isSafeInteger(timeoutMilliseconds) ||
|
|
72
|
+
timeoutMilliseconds <= 0 ||
|
|
73
|
+
timeoutMilliseconds > MAXIMUM_TIMER_DELAY_MILLISECONDS) {
|
|
74
|
+
throw new Error(`BRAVE_DEVTOOLS_PROTOCOL_TIMEOUT_MS must be a positive integer no greater than ${MAXIMUM_TIMER_DELAY_MILLISECONDS}.`);
|
|
75
|
+
}
|
|
76
|
+
return timeoutMilliseconds;
|
|
77
|
+
}
|
|
66
78
|
// Heavy pages (e.g. Studio module dev bundles >100MB) cannot ack
|
|
67
79
|
// `Network.enable` and other auto-attached domain calls within
|
|
68
80
|
// puppeteer's default 180s. Once that fires, the CDP connection is
|
|
69
81
|
// marked dead and every subsequent call throws — only daemon restart
|
|
70
82
|
// recovers. Bumping the ceiling to 10min covers realistic loads;
|
|
71
83
|
// override via env for power users.
|
|
72
|
-
export const PROTOCOL_TIMEOUT_MILLISECONDS =
|
|
73
|
-
if (!Number.isSafeInteger(PROTOCOL_TIMEOUT_MILLISECONDS) ||
|
|
74
|
-
PROTOCOL_TIMEOUT_MILLISECONDS <= 0) {
|
|
75
|
-
throw new Error('BRAVE_DEVTOOLS_PROTOCOL_TIMEOUT_MS must be a positive integer.');
|
|
76
|
-
}
|
|
84
|
+
export const PROTOCOL_TIMEOUT_MILLISECONDS = parseProtocolTimeoutMilliseconds(process.env.BRAVE_DEVTOOLS_PROTOCOL_TIMEOUT_MS);
|
|
77
85
|
export function resolveBraveExecutablePath(channel = 'release') {
|
|
78
86
|
const environmentPath = process.env['BRAVE_PATH'];
|
|
79
87
|
if (environmentPath) {
|
|
@@ -255,7 +263,7 @@ export class BrowserManager {
|
|
|
255
263
|
// a call silently adopt the freshly-rotated token as its own baseline
|
|
256
264
|
// once the lock frees up, defeating the abandonment check entirely.
|
|
257
265
|
const attempt = this.#browserAttempt;
|
|
258
|
-
const
|
|
266
|
+
const guard = __addDisposableResource(env_1, await this.#mutex.acquire(), false);
|
|
259
267
|
if (this.#closingCount > 0) {
|
|
260
268
|
throw new Error('Browser was closed while initializing.');
|
|
261
269
|
}
|
|
@@ -306,9 +314,7 @@ export class BrowserManager {
|
|
|
306
314
|
abandonPendingAttempt() {
|
|
307
315
|
this.#browserAttempt = new BrowserAttempt();
|
|
308
316
|
this.#initPromise = undefined;
|
|
309
|
-
|
|
310
|
-
this.forget(this.#browser);
|
|
311
|
-
}
|
|
317
|
+
this.#forgetCurrentBrowser();
|
|
312
318
|
}
|
|
313
319
|
async #initBrowser() {
|
|
314
320
|
if (this.#serverArgs.browserUrl ||
|
|
@@ -405,24 +411,31 @@ export class BrowserManager {
|
|
|
405
411
|
}
|
|
406
412
|
}
|
|
407
413
|
async #connect() {
|
|
408
|
-
const { browserUrl: browserURL, wsEndpoint, wsHeaders, autoConnect: isAutoConnect, userDataDir, blockedUrlPattern: blocklist, allowedUrlPattern: allowlist, } = this.#serverArgs;
|
|
414
|
+
const { browserUrl: browserURL, wsEndpoint, wsHeaders, autoConnect: isAutoConnect, userDataDir, blockedUrlPattern: blocklist, allowedUrlPattern: allowlist, categoryExtensions: enableExtensions, } = this.#serverArgs;
|
|
409
415
|
// Important: only pass channel, if autoConnect is true.
|
|
410
416
|
const channel = isAutoConnect ? this.#serverArgs.channel : undefined;
|
|
411
417
|
const connectOptions = {
|
|
412
|
-
targetFilter: _a.makeTargetFilter(),
|
|
418
|
+
targetFilter: _a.makeTargetFilter(enableExtensions),
|
|
413
419
|
defaultViewport: null,
|
|
414
420
|
handleDevToolsAsPage: true,
|
|
415
421
|
protocolTimeout: PROTOCOL_TIMEOUT_MILLISECONDS,
|
|
416
422
|
blocklist,
|
|
417
423
|
allowlist,
|
|
418
424
|
logger: puppeteerLogger,
|
|
425
|
+
// Detects a transport that died without a close frame (e.g. an adb
|
|
426
|
+
// port-forward torn down mid-call). Puppeteer pings the socket
|
|
427
|
+
// periodically and terminates it on a missed pong, which rejects the
|
|
428
|
+
// in-flight CDP calls and fires `disconnected` so #evictIfCurrent()
|
|
429
|
+
// drops the stale handle. Without this, such a call would hang until
|
|
430
|
+
// protocolTimeout while holding the tool mutex.
|
|
431
|
+
wsOptions: {
|
|
432
|
+
keepAlive: true,
|
|
433
|
+
headers: wsHeaders,
|
|
434
|
+
},
|
|
419
435
|
};
|
|
420
436
|
let autoConnect = false;
|
|
421
437
|
if (wsEndpoint) {
|
|
422
438
|
connectOptions.browserWSEndpoint = wsEndpoint;
|
|
423
|
-
if (wsHeaders) {
|
|
424
|
-
connectOptions.headers = wsHeaders;
|
|
425
|
-
}
|
|
426
439
|
}
|
|
427
440
|
else if (browserURL) {
|
|
428
441
|
connectOptions.browserURL = browserURL;
|
|
@@ -478,32 +491,31 @@ export class BrowserManager {
|
|
|
478
491
|
}
|
|
479
492
|
}
|
|
480
493
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* closed cleanly).
|
|
494
|
+
* Drops the cached browser handle so the next ensureBrowser() call
|
|
495
|
+
* establishes a fresh connection. Only called from abandonPendingAttempt()
|
|
496
|
+
* when a getContext()-level timeout fires after ensureBrowser() already
|
|
497
|
+
* resolved, which means the handle looks connected but its CDP
|
|
498
|
+
* initialization is hanging.
|
|
487
499
|
*
|
|
488
|
-
* Also
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* callbacks — instead of leaving a hung call to leak forever.
|
|
500
|
+
* Also tears the browser down in the background: closes it if it was
|
|
501
|
+
* launched (so the Brave subprocess doesn't leak), or disconnects if it
|
|
502
|
+
* was only connected to (so the transport and its listeners don't leak).
|
|
503
|
+
* Puppeteer's connection disposal rejects any in-flight CDP call, so a hung
|
|
504
|
+
* call settles instead of leaking forever.
|
|
494
505
|
*/
|
|
495
|
-
|
|
496
|
-
|
|
506
|
+
#forgetCurrentBrowser() {
|
|
507
|
+
const browser = this.#browser;
|
|
508
|
+
if (!browser) {
|
|
497
509
|
return;
|
|
498
510
|
}
|
|
499
511
|
const mode = this.#browserMode;
|
|
500
512
|
this.#browser = undefined;
|
|
501
513
|
this.#browserMode = undefined;
|
|
502
514
|
if (mode === 'launched') {
|
|
503
|
-
void this.#closeLaunchedBrowser(
|
|
515
|
+
void this.#closeLaunchedBrowser(browser);
|
|
504
516
|
}
|
|
505
517
|
else {
|
|
506
|
-
void
|
|
518
|
+
void browser.disconnect().catch(err => {
|
|
507
519
|
logger?.('Failed to disconnect forgotten browser', err);
|
|
508
520
|
});
|
|
509
521
|
}
|
|
@@ -514,9 +526,8 @@ export class BrowserManager {
|
|
|
514
526
|
* call reconnects proactively rather than only discovering the staleness
|
|
515
527
|
* lazily on the next call's `!this.#browser?.connected` check. No teardown
|
|
516
528
|
* here — the browser is already disconnected/closed, that's why this
|
|
517
|
-
* fired
|
|
518
|
-
*
|
|
519
|
-
* a safe no-op.
|
|
529
|
+
* fired. The identity guard makes a `disconnected` that fires late on an
|
|
530
|
+
* already-superseded browser a safe no-op.
|
|
520
531
|
*/
|
|
521
532
|
#evictIfCurrent(candidate) {
|
|
522
533
|
if (this.#browser !== candidate) {
|
|
@@ -571,7 +582,7 @@ export class BrowserManager {
|
|
|
571
582
|
try {
|
|
572
583
|
this.#initPromise = undefined;
|
|
573
584
|
this.#closingCount++;
|
|
574
|
-
const
|
|
585
|
+
const guard = __addDisposableResource(env_2, await this.#mutex.acquire(), false);
|
|
575
586
|
try {
|
|
576
587
|
await this.#closeBrowser();
|
|
577
588
|
}
|
package/build/src/McpContext.js
CHANGED
|
@@ -8,7 +8,6 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
10
|
import { overrideDevToolsGlobals } from './devtools/DevtoolsUtils.js';
|
|
11
|
-
import { HeapSnapshotManager } from './processors/HeapSnapshotManager.js';
|
|
12
11
|
import { McpPage } from './McpPage.js';
|
|
13
12
|
import { ServiceWorkerConsoleCollector } from './collectors/ServiceWorkerCollector.js';
|
|
14
13
|
import { Locator, } from './third_party/index.js';
|
|
@@ -37,12 +36,11 @@ export class McpContext {
|
|
|
37
36
|
#selectedPageFallback;
|
|
38
37
|
#serviceWorkerConsoleCollector;
|
|
39
38
|
#isRunningTrace = false;
|
|
40
|
-
#screenRecorderData = null;
|
|
41
39
|
#reconnectNotice = false;
|
|
42
40
|
#traceResults = [];
|
|
43
41
|
#locatorClass;
|
|
44
42
|
#options;
|
|
45
|
-
#heapSnapshotManager
|
|
43
|
+
#heapSnapshotManager;
|
|
46
44
|
#roots = undefined;
|
|
47
45
|
#allowUnrestrictedPaths;
|
|
48
46
|
constructor(browser, logger, options, locatorClass) {
|
|
@@ -55,6 +53,7 @@ export class McpContext {
|
|
|
55
53
|
this.logger = logger;
|
|
56
54
|
this.#locatorClass = locatorClass;
|
|
57
55
|
this.#options = options;
|
|
56
|
+
this.#heapSnapshotManager = options.heapSnapshotManager;
|
|
58
57
|
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
|
|
59
58
|
this.#reconnectNotice = options.reconnected ?? false;
|
|
60
59
|
this.#serviceWorkerConsoleCollector = new ServiceWorkerConsoleCollector(this.browser);
|
|
@@ -70,7 +69,6 @@ export class McpContext {
|
|
|
70
69
|
this.browser.off('targetcreated', this.#onTargetCreated);
|
|
71
70
|
this.browser.off('targetdestroyed', this.#onTargetDestroyed);
|
|
72
71
|
this.#serviceWorkerConsoleCollector.dispose();
|
|
73
|
-
this.#heapSnapshotManager.dispose();
|
|
74
72
|
for (const mcpPage of this.#mcpPages.values()) {
|
|
75
73
|
mcpPage.dispose();
|
|
76
74
|
}
|
|
@@ -212,10 +210,10 @@ export class McpContext {
|
|
|
212
210
|
return mcpPage;
|
|
213
211
|
}
|
|
214
212
|
async closePage(pageId) {
|
|
213
|
+
const page = this.getPageById(pageId);
|
|
215
214
|
if (this.#mcpPages.size === 1) {
|
|
216
215
|
throw new Error(CLOSE_PAGE_ERROR);
|
|
217
216
|
}
|
|
218
|
-
const page = this.getPageById(pageId);
|
|
219
217
|
this.#mcpPages.delete(page.target);
|
|
220
218
|
await page.close();
|
|
221
219
|
}
|
|
@@ -259,18 +257,30 @@ export class McpContext {
|
|
|
259
257
|
isRunningPerformanceTrace() {
|
|
260
258
|
return this.#isRunningTrace;
|
|
261
259
|
}
|
|
262
|
-
getScreenRecorder() {
|
|
263
|
-
return this.#screenRecorderData;
|
|
264
|
-
}
|
|
265
|
-
setScreenRecorder(data) {
|
|
266
|
-
this.#screenRecorderData = data;
|
|
267
|
-
}
|
|
268
260
|
isCruxEnabled() {
|
|
269
261
|
return this.#options.performanceCrux;
|
|
270
262
|
}
|
|
271
263
|
getPages() {
|
|
272
264
|
return Array.from(this.#mcpPages.values());
|
|
273
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Fetches current target info for all targets from the browser, keyed by
|
|
268
|
+
* target id. Unlike Puppeteer's cached target info, this reflects title
|
|
269
|
+
* changes, which Chrome does not report via `Target.targetInfoChanged`.
|
|
270
|
+
* Returns an empty map if the browser cannot be queried.
|
|
271
|
+
*/
|
|
272
|
+
async getTargetInfos() {
|
|
273
|
+
try {
|
|
274
|
+
// @ts-expect-error use existing browser connection (internal Puppeteer API).
|
|
275
|
+
const connection = this.browser._connection;
|
|
276
|
+
const { targetInfos } = await connection.send('Target.getTargets');
|
|
277
|
+
return new Map(targetInfos.map(info => [info.targetId, info]));
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
this.logger?.('Failed to fetch target infos', err);
|
|
281
|
+
return new Map();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
274
284
|
getSelectedMcpPage() {
|
|
275
285
|
const page = this.#selectedPage;
|
|
276
286
|
if (!page) {
|
|
@@ -594,10 +604,7 @@ export class McpContext {
|
|
|
594
604
|
return await this.#heapSnapshotManager.analyzeContexts(filePath);
|
|
595
605
|
}
|
|
596
606
|
async closeHeapSnapshot(filePath) {
|
|
597
|
-
return this.#heapSnapshotManager.disposeSnapshot(filePath);
|
|
598
|
-
}
|
|
599
|
-
hasHeapSnapshots() {
|
|
600
|
-
return this.#heapSnapshotManager.hasSnapshots();
|
|
607
|
+
return await this.#heapSnapshotManager.disposeSnapshot(filePath);
|
|
601
608
|
}
|
|
602
609
|
async getHeapSnapshotRetainingPaths(filePath, nodeId, maxDepth, maxNodes, maxSiblings) {
|
|
603
610
|
return await this.#heapSnapshotManager.getRetainingPaths(filePath, nodeId, maxDepth, maxNodes, maxSiblings);
|
|
@@ -628,18 +635,41 @@ export class McpContext {
|
|
|
628
635
|
}
|
|
629
636
|
throw new Error(`Not allowed by allowlist: ${url}`);
|
|
630
637
|
}
|
|
638
|
+
async #loadNetworkResource(url) {
|
|
639
|
+
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
|
|
640
|
+
const maxRedirects = 20;
|
|
641
|
+
let currentUrl = url;
|
|
642
|
+
for (let redirectCount = 0;; redirectCount++) {
|
|
643
|
+
this.#validateUrlNotBlocked(currentUrl);
|
|
644
|
+
this.#validateUrlAllowed(currentUrl);
|
|
645
|
+
const response = await fetch(currentUrl, { redirect: 'manual' });
|
|
646
|
+
if (!redirectStatuses.has(response.status)) {
|
|
647
|
+
if (!response.ok) {
|
|
648
|
+
throw new Error(`Failed to load resource: ${currentUrl}`);
|
|
649
|
+
}
|
|
650
|
+
return await response.text();
|
|
651
|
+
}
|
|
652
|
+
const location = response.headers.get('location');
|
|
653
|
+
if (!location) {
|
|
654
|
+
throw new Error(`Failed to load resource: ${currentUrl}`);
|
|
655
|
+
}
|
|
656
|
+
if (redirectCount >= maxRedirects) {
|
|
657
|
+
throw new Error(`Too many redirects loading resource: ${url}`);
|
|
658
|
+
}
|
|
659
|
+
await response.body?.cancel();
|
|
660
|
+
currentUrl = new URL(location, currentUrl);
|
|
661
|
+
if (currentUrl.protocol !== 'https:' && currentUrl.protocol !== 'http:') {
|
|
662
|
+
throw new Error(`Unsupported protocol for: ${currentUrl}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
631
666
|
async loadResource(path) {
|
|
632
667
|
const url = new URL(path);
|
|
633
668
|
this.#validateUrlNotBlocked(url);
|
|
634
669
|
switch (url.protocol) {
|
|
635
670
|
case 'https:':
|
|
636
671
|
case 'http:': {
|
|
637
|
-
this.#
|
|
638
|
-
const response = await fetch(url);
|
|
639
|
-
if (!response.ok) {
|
|
640
|
-
throw new Error(`Failed to load resource: ${url}`);
|
|
641
|
-
}
|
|
642
|
-
return response.text();
|
|
672
|
+
return await this.#loadNetworkResource(url);
|
|
643
673
|
}
|
|
644
674
|
case 'file:': {
|
|
645
675
|
const resolved = await this.validatePath(fileURLToPath(url));
|
package/build/src/McpPage.js
CHANGED
|
@@ -55,6 +55,13 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
55
55
|
var e = new Error(message);
|
|
56
56
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
57
57
|
});
|
|
58
|
+
import { DevToolsCommentBridge } from './devtools/DevToolsCommentBridge.js';
|
|
59
|
+
import { createTargetUniverse, } from './devtools/DevtoolsUtils.js';
|
|
60
|
+
import { ConsoleCollector, NetworkCollector, } from './collectors/PageCollector.js';
|
|
61
|
+
import { TextSnapshot } from './TextSnapshot.js';
|
|
62
|
+
import { PredefinedNetworkConditions, DevTools, } from './third_party/index.js';
|
|
63
|
+
import { logger } from './utils/logger.js';
|
|
64
|
+
import { getNetworkMultiplierFromString, WaitForHelper, } from './utils/WaitForHelper.js';
|
|
58
65
|
export function replaceHtmlElementsWithUids(schema) {
|
|
59
66
|
if (typeof schema === 'boolean') {
|
|
60
67
|
return;
|
|
@@ -101,15 +108,25 @@ export function replaceHtmlElementsWithUids(schema) {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
function isDebuggingWebMcpTool(tool) {
|
|
112
|
+
return Boolean(tool.annotations &&
|
|
113
|
+
'debugging' in tool.annotations &&
|
|
114
|
+
tool.annotations.debugging === true);
|
|
115
|
+
}
|
|
109
116
|
const DEFAULT_TIMEOUT = 5_000;
|
|
110
117
|
const NAVIGATION_TIMEOUT = 10_000;
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Chrome reports a URL-derived placeholder as the target title until the
|
|
120
|
+
* document provides one. The placeholder omits the `http://` / `https://`
|
|
121
|
+
* scheme and the trailing slash of a bare hostname.
|
|
122
|
+
*/
|
|
123
|
+
function isPlaceholderTitle(title, url) {
|
|
124
|
+
if (title === url) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const withoutScheme = url.replace(/^https?:\/\//, '');
|
|
128
|
+
return title === withoutScheme || title === withoutScheme.replace(/\/$/, '');
|
|
129
|
+
}
|
|
113
130
|
function isBackendNodeId(id) {
|
|
114
131
|
return typeof id === 'number';
|
|
115
132
|
}
|
|
@@ -140,7 +157,9 @@ export class McpPage {
|
|
|
140
157
|
// Dialog
|
|
141
158
|
#dialog;
|
|
142
159
|
#dialogHandler;
|
|
143
|
-
|
|
160
|
+
// Screencast
|
|
161
|
+
#screenRecorder = null;
|
|
162
|
+
eventBasedThirdPartyTools = [];
|
|
144
163
|
#networkCollector;
|
|
145
164
|
#consoleCollector;
|
|
146
165
|
#hasNetworkBlockOrAllowlist;
|
|
@@ -184,12 +203,27 @@ export class McpPage {
|
|
|
184
203
|
return this.#consoleCollector;
|
|
185
204
|
}
|
|
186
205
|
url() {
|
|
187
|
-
return this.#pptrPage
|
|
206
|
+
return this.#pptrPage?.url() ?? this.target.url();
|
|
188
207
|
}
|
|
189
|
-
|
|
208
|
+
get targetId() {
|
|
190
209
|
// @ts-expect-error internal types
|
|
191
210
|
const info = this.target._getTargetInfo();
|
|
192
|
-
return info.
|
|
211
|
+
return info.targetId;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Returns the page title or an empty string if the target only has Chrome's
|
|
215
|
+
* URL-based placeholder title.
|
|
216
|
+
*
|
|
217
|
+
* Puppeteer's cached target info is only refreshed by
|
|
218
|
+
* `Target.targetInfoChanged`, which Chrome emits on navigation commit (before
|
|
219
|
+
* the document title is known) but not on title changes. Callers should pass
|
|
220
|
+
* fresh target info (e.g., from `Target.getTargets`) when available.
|
|
221
|
+
*/
|
|
222
|
+
getTitle(freshTargetInfo) {
|
|
223
|
+
// @ts-expect-error internal types
|
|
224
|
+
const cachedInfo = this.target._getTargetInfo();
|
|
225
|
+
const info = freshTargetInfo ?? cachedInfo;
|
|
226
|
+
return isPlaceholderTitle(info.title, info.url) ? '' : info.title;
|
|
193
227
|
}
|
|
194
228
|
isClosed() {
|
|
195
229
|
if (this.#closed) {
|
|
@@ -213,7 +247,7 @@ export class McpPage {
|
|
|
213
247
|
throw new Error(`McpPage (id=${this.id}) has already been disposed.`);
|
|
214
248
|
}
|
|
215
249
|
if (this.#initPromise) {
|
|
216
|
-
return this.#initPromise;
|
|
250
|
+
return await this.#initPromise;
|
|
217
251
|
}
|
|
218
252
|
this.#initPromise = this.#doInit();
|
|
219
253
|
try {
|
|
@@ -288,13 +322,41 @@ export class McpPage {
|
|
|
288
322
|
clearDialog() {
|
|
289
323
|
this.#dialog = undefined;
|
|
290
324
|
}
|
|
325
|
+
getScreenRecorder() {
|
|
326
|
+
return this.#screenRecorder;
|
|
327
|
+
}
|
|
328
|
+
setScreenRecorder(data) {
|
|
329
|
+
this.#screenRecorder = data;
|
|
330
|
+
}
|
|
291
331
|
throwIfDialogOpen() {
|
|
292
332
|
if (this.#dialog && !this.#dialog.handled) {
|
|
293
333
|
throw new Error(`A dialog is open (${this.#dialog.type()}: ${this.#dialog.message()}).`);
|
|
294
334
|
}
|
|
295
335
|
}
|
|
336
|
+
#getDebuggingWebMcpToolGroups() {
|
|
337
|
+
const webmcpTools = this.pptrPage.webmcp
|
|
338
|
+
.tools()
|
|
339
|
+
.filter(isDebuggingWebMcpTool);
|
|
340
|
+
if (webmcpTools.length === 0) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
return [
|
|
344
|
+
{
|
|
345
|
+
name: 'WebMCP Tools',
|
|
346
|
+
description: 'Tools exposed via WebMCP',
|
|
347
|
+
tools: webmcpTools.map(tool => ({
|
|
348
|
+
name: tool.name,
|
|
349
|
+
description: tool.description,
|
|
350
|
+
inputSchema: structuredClone(tool.inputSchema ?? {}),
|
|
351
|
+
})),
|
|
352
|
+
},
|
|
353
|
+
];
|
|
354
|
+
}
|
|
296
355
|
getThirdPartyDeveloperTools() {
|
|
297
|
-
return
|
|
356
|
+
return [
|
|
357
|
+
...this.#getDebuggingWebMcpToolGroups(),
|
|
358
|
+
...this.eventBasedThirdPartyTools,
|
|
359
|
+
];
|
|
298
360
|
}
|
|
299
361
|
async getToolGroups() {
|
|
300
362
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
@@ -303,13 +365,14 @@ export class McpPage {
|
|
|
303
365
|
const windowHandle = __addDisposableResource(env_1, await this.pptrPage.evaluateHandle(() => window), false);
|
|
304
366
|
// @ts-expect-error internal API
|
|
305
367
|
const client = this.pptrPage._client();
|
|
306
|
-
const { listeners } = await client.send('DOMDebugger.getEventListeners', {
|
|
368
|
+
const { listeners = [] } = await client.send('DOMDebugger.getEventListeners', {
|
|
307
369
|
objectId: windowHandle.remoteObject().objectId,
|
|
308
370
|
});
|
|
309
371
|
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
|
|
310
|
-
|
|
372
|
+
this.eventBasedThirdPartyTools = [];
|
|
373
|
+
return this.getThirdPartyDeveloperTools();
|
|
311
374
|
}
|
|
312
|
-
const
|
|
375
|
+
const eventToolGroups = await this.pptrPage.evaluate(() => {
|
|
313
376
|
if (window.__dtmcp) {
|
|
314
377
|
window.__dtmcp.toolGroups = [];
|
|
315
378
|
}
|
|
@@ -377,13 +440,13 @@ export class McpPage {
|
|
|
377
440
|
}
|
|
378
441
|
});
|
|
379
442
|
});
|
|
380
|
-
for (const group of
|
|
443
|
+
for (const group of eventToolGroups) {
|
|
381
444
|
for (const tool of group.tools ?? []) {
|
|
382
445
|
replaceHtmlElementsWithUids(tool.inputSchema);
|
|
383
446
|
}
|
|
384
447
|
}
|
|
385
|
-
this.
|
|
386
|
-
return
|
|
448
|
+
this.eventBasedThirdPartyTools = eventToolGroups;
|
|
449
|
+
return this.getThirdPartyDeveloperTools();
|
|
387
450
|
}
|
|
388
451
|
catch (e_1) {
|
|
389
452
|
env_1.error = e_1;
|
|
@@ -394,7 +457,9 @@ export class McpPage {
|
|
|
394
457
|
}
|
|
395
458
|
}
|
|
396
459
|
getWebMcpTools() {
|
|
397
|
-
return this.pptrPage.webmcp
|
|
460
|
+
return this.pptrPage.webmcp
|
|
461
|
+
.tools()
|
|
462
|
+
.filter(tool => !isDebuggingWebMcpTool(tool));
|
|
398
463
|
}
|
|
399
464
|
resolveCdpRequestId(cdpRequestId) {
|
|
400
465
|
if (!cdpRequestId) {
|
|
@@ -425,14 +490,18 @@ export class McpPage {
|
|
|
425
490
|
get commentBridge() {
|
|
426
491
|
return this.#commentBridge;
|
|
427
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Returns the comment bridge attached to the DevTools page, or undefined if
|
|
495
|
+
* the DevTools page does not support DevTools comments.
|
|
496
|
+
*/
|
|
428
497
|
async ensureDevToolsCommentBridge(devtoolsPage) {
|
|
429
498
|
if (!this.#commentBridge) {
|
|
430
499
|
this.#commentBridge = new DevToolsCommentBridge({
|
|
431
500
|
onNotification: this.#onNotification,
|
|
432
501
|
});
|
|
433
502
|
}
|
|
434
|
-
await this.#commentBridge.attach(devtoolsPage);
|
|
435
|
-
return this.#commentBridge;
|
|
503
|
+
const attached = await this.#commentBridge.attach(devtoolsPage);
|
|
504
|
+
return attached ? this.#commentBridge : undefined;
|
|
436
505
|
}
|
|
437
506
|
async getDevToolsPage() {
|
|
438
507
|
try {
|
|
@@ -480,6 +549,15 @@ export class McpPage {
|
|
|
480
549
|
get colorScheme() {
|
|
481
550
|
return this.emulationSettings.colorScheme ?? null;
|
|
482
551
|
}
|
|
552
|
+
get forcedColors() {
|
|
553
|
+
return this.emulationSettings.forcedColors ?? null;
|
|
554
|
+
}
|
|
555
|
+
get prefersContrast() {
|
|
556
|
+
return this.emulationSettings.prefersContrast ?? null;
|
|
557
|
+
}
|
|
558
|
+
get prefersReducedMotion() {
|
|
559
|
+
return this.emulationSettings.prefersReducedMotion ?? null;
|
|
560
|
+
}
|
|
483
561
|
// Public for testability: tests spy on this method to verify throttle multipliers.
|
|
484
562
|
createWaitForHelper(cpuMultiplier, networkMultiplier) {
|
|
485
563
|
return new WaitForHelper(this.pptrPage, cpuMultiplier, networkMultiplier);
|
|
@@ -492,6 +570,13 @@ export class McpPage {
|
|
|
492
570
|
this.#disposed = true;
|
|
493
571
|
this.#commentBridge?.dispose();
|
|
494
572
|
this.#commentBridge = undefined;
|
|
573
|
+
if (this.#screenRecorder) {
|
|
574
|
+
const { recorder } = this.#screenRecorder;
|
|
575
|
+
this.#screenRecorder = null;
|
|
576
|
+
void recorder.stop().catch(e => {
|
|
577
|
+
logger?.('Failed to stop screencast recording on dispose', e);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
495
580
|
this.#pptrPage?.off('dialog', this.#dialogHandler);
|
|
496
581
|
this.#networkCollector?.dispose();
|
|
497
582
|
this.#consoleCollector?.dispose();
|
|
@@ -503,6 +588,23 @@ export class McpPage {
|
|
|
503
588
|
});
|
|
504
589
|
}
|
|
505
590
|
async executeThirdPartyDeveloperTool(toolName, params, response) {
|
|
591
|
+
const webmcpTool = this.pptrPage.webmcp
|
|
592
|
+
.tools()
|
|
593
|
+
.find(t => t.name === toolName && isDebuggingWebMcpTool(t));
|
|
594
|
+
if (webmcpTool) {
|
|
595
|
+
const { status, output, errorText, exception } = await webmcpTool.execute(params);
|
|
596
|
+
if (status !== 'Completed') {
|
|
597
|
+
const error = exception?.description ?? exception?.value ?? errorText;
|
|
598
|
+
throw new Error(error ? String(error) : `Tool execution failed: ${status}`);
|
|
599
|
+
}
|
|
600
|
+
if (output !== undefined) {
|
|
601
|
+
response.appendResponseLine(JSON.stringify(output, null, 2));
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
response.appendResponseLine('Tool returned no result.');
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
506
608
|
// Creates array of ElementHandles from the UIDs in the params.
|
|
507
609
|
// We do not replace the uids with the ElementsHandles yet, because
|
|
508
610
|
// the `evaluate` function only turns them into DOM elements if they
|
|
@@ -677,7 +779,7 @@ export class McpPage {
|
|
|
677
779
|
typeof node.stashedId === 'string' &&
|
|
678
780
|
node.stashedId.startsWith('stashed-') &&
|
|
679
781
|
Object.keys(node).length === 1) {
|
|
680
|
-
const index = parseInt(node.stashedId.split('-')[1]);
|
|
782
|
+
const index = parseInt(node.stashedId.split('-')[1], 10);
|
|
681
783
|
return { uid: cdpElementIds[index] };
|
|
682
784
|
}
|
|
683
785
|
const resultObj = {};
|
|
@@ -705,7 +807,7 @@ export class McpPage {
|
|
|
705
807
|
if (!node) {
|
|
706
808
|
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
|
|
707
809
|
}
|
|
708
|
-
return this.#resolveElementHandle(node, uid);
|
|
810
|
+
return await this.#resolveElementHandle(node, uid);
|
|
709
811
|
}
|
|
710
812
|
async #resolveElementHandle(node, uid) {
|
|
711
813
|
const message = `Element with uid ${uid} no longer exists on the page.`;
|
|
@@ -812,7 +914,6 @@ export class McpPage {
|
|
|
812
914
|
}
|
|
813
915
|
await this.ensureDevToolsCommentBridge(devtoolsPage);
|
|
814
916
|
const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
|
|
815
|
-
window.universe?.cd4aBridge?.setAgentAttached(true);
|
|
816
917
|
// @ts-expect-error no types
|
|
817
918
|
const UI = await import('/bundled/ui/legacy/legacy.js');
|
|
818
919
|
// @ts-expect-error no types
|
|
@@ -835,6 +936,20 @@ export class McpPage {
|
|
|
835
936
|
const currentSetting = this.emulationSettings;
|
|
836
937
|
await this.emulate(currentSetting);
|
|
837
938
|
}
|
|
939
|
+
async #emulateMediaFeatures(settings) {
|
|
940
|
+
// `page.emulateMediaFeatures` replaces all previously emulated media
|
|
941
|
+
// features, so every supported feature is sent on each call. An empty
|
|
942
|
+
// value disables the override for that feature.
|
|
943
|
+
await this.pptrPage.emulateMediaFeatures([
|
|
944
|
+
{ name: 'prefers-color-scheme', value: settings.colorScheme ?? '' },
|
|
945
|
+
{ name: 'forced-colors', value: settings.forcedColors ?? '' },
|
|
946
|
+
{ name: 'prefers-contrast', value: settings.prefersContrast ?? '' },
|
|
947
|
+
{
|
|
948
|
+
name: 'prefers-reduced-motion',
|
|
949
|
+
value: settings.prefersReducedMotion ?? '',
|
|
950
|
+
},
|
|
951
|
+
]);
|
|
952
|
+
}
|
|
838
953
|
async emulate(options) {
|
|
839
954
|
const page = this.pptrPage;
|
|
840
955
|
const newSettings = { ...this.emulationSettings };
|
|
@@ -898,17 +1013,31 @@ export class McpPage {
|
|
|
898
1013
|
newSettings.userAgent = options.userAgent;
|
|
899
1014
|
}
|
|
900
1015
|
if (!options.colorScheme || options.colorScheme === 'auto') {
|
|
901
|
-
await page.emulateMediaFeatures([
|
|
902
|
-
{ name: 'prefers-color-scheme', value: '' },
|
|
903
|
-
]);
|
|
904
1016
|
delete newSettings.colorScheme;
|
|
905
1017
|
}
|
|
906
1018
|
else {
|
|
907
|
-
await page.emulateMediaFeatures([
|
|
908
|
-
{ name: 'prefers-color-scheme', value: options.colorScheme },
|
|
909
|
-
]);
|
|
910
1019
|
newSettings.colorScheme = options.colorScheme;
|
|
911
1020
|
}
|
|
1021
|
+
if (!options.forcedColors || options.forcedColors === 'auto') {
|
|
1022
|
+
delete newSettings.forcedColors;
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
newSettings.forcedColors = options.forcedColors;
|
|
1026
|
+
}
|
|
1027
|
+
if (!options.prefersContrast || options.prefersContrast === 'auto') {
|
|
1028
|
+
delete newSettings.prefersContrast;
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
newSettings.prefersContrast = options.prefersContrast;
|
|
1032
|
+
}
|
|
1033
|
+
if (!options.prefersReducedMotion ||
|
|
1034
|
+
options.prefersReducedMotion === 'auto') {
|
|
1035
|
+
delete newSettings.prefersReducedMotion;
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
newSettings.prefersReducedMotion = options.prefersReducedMotion;
|
|
1039
|
+
}
|
|
1040
|
+
await this.#emulateMediaFeatures(newSettings);
|
|
912
1041
|
if (!options.viewport) {
|
|
913
1042
|
delete newSettings.viewport;
|
|
914
1043
|
}
|
|
@@ -941,12 +1070,12 @@ export class McpPage {
|
|
|
941
1070
|
// For waiters 5sec timeout should be sufficient.
|
|
942
1071
|
// Increased in case we throttle the CPU
|
|
943
1072
|
const cpuMultiplier = this.cpuThrottlingRate;
|
|
944
|
-
this
|
|
1073
|
+
this.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
|
|
945
1074
|
// 10sec should be enough for the load event to be emitted during
|
|
946
1075
|
// navigations.
|
|
947
1076
|
// Increased in case we throttle the network requests or the CPU
|
|
948
1077
|
const networkMultiplier = getNetworkMultiplierFromString(this.networkConditions);
|
|
949
|
-
this
|
|
1078
|
+
this.pptrPage.setDefaultNavigationTimeout(this.#navigationTimeout * networkMultiplier * cpuMultiplier);
|
|
950
1079
|
}
|
|
951
1080
|
waitForTextOnPage(text, timeout) {
|
|
952
1081
|
const frames = this.pptrPage.frames();
|