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.
Files changed (70) hide show
  1. package/build/src/BrowserManager.js +45 -34
  2. package/build/src/McpContext.js +51 -21
  3. package/build/src/McpPage.js +162 -33
  4. package/build/src/McpResponse.js +174 -60
  5. package/build/src/SlimMcpResponse.js +5 -1
  6. package/build/src/TextSnapshot.js +3 -2
  7. package/build/src/ToolHandler.js +57 -62
  8. package/build/src/bin/brave-devtools-mcp.js +1 -1
  9. package/build/src/bin/brave-devtools.js +15 -8
  10. package/build/src/config/ConfigLocator.js +5 -6
  11. package/build/src/config/ConfigParser.js +71 -20
  12. package/build/src/config/ConfigWatcher.js +93 -0
  13. package/build/src/config/category-options.js +5 -3
  14. package/build/src/config/cli-commands.js +162 -10
  15. package/build/src/config/cli-options.js +358 -40
  16. package/build/src/config/mcp-options.js +35 -13
  17. package/build/src/config/puppeteer-options.js +10 -13
  18. package/build/src/config/tool-options.js +4 -0
  19. package/build/src/daemon/client.js +35 -23
  20. package/build/src/daemon/daemon.js +4 -4
  21. package/build/src/daemon/utils.js +43 -1
  22. package/build/src/devtools/DevToolsCommentBridge.js +42 -5
  23. package/build/src/devtools/DevtoolsUtils.js +3 -3
  24. package/build/src/devtools/McpHostBindingAdapter.js +0 -1
  25. package/build/src/formatters/ConsoleFormatter.js +1 -1
  26. package/build/src/formatters/CssFormatter.js +3 -2
  27. package/build/src/formatters/HeapSnapshotFormatter.js +58 -53
  28. package/build/src/formatters/IssueFormatter.js +4 -3
  29. package/build/src/formatters/NetworkFormatter.js +12 -11
  30. package/build/src/index.js +7 -7
  31. package/build/src/processors/ChunkedTraceParser.js +11 -11
  32. package/build/src/processors/HeapSnapshotManager.js +118 -85
  33. package/build/src/processors/PerformanceTrace.js +9 -9
  34. package/build/src/telemetry/ClearcutLogger.js +1 -1
  35. package/build/src/telemetry/transformation.js +7 -11
  36. package/build/src/telemetry/watchdog/main.js +1 -1
  37. package/build/src/third_party/THIRD_PARTY_NOTICES +9 -9
  38. package/build/src/third_party/bundled-packages.json +5 -5
  39. package/build/src/third_party/devtools-formatter-worker.js +96 -16
  40. package/build/src/third_party/devtools-heap-snapshot-worker.js +30 -13
  41. package/build/src/third_party/index.js +4356 -2734
  42. package/build/src/third_party/issue-descriptions/genericGeolocationPromptWithoutUserGesture.md +5 -0
  43. package/build/src/third_party/lighthouse-devtools-mcp-bundle.js +3 -3
  44. package/build/src/tools/ToolDefinition.js +5 -7
  45. package/build/src/tools/comments.js +13 -0
  46. package/build/src/tools/emulation.js +17 -4
  47. package/build/src/tools/input.js +8 -3
  48. package/build/src/tools/lighthouse.js +113 -5
  49. package/build/src/tools/memory.js +87 -29
  50. package/build/src/tools/pages.js +2 -2
  51. package/build/src/tools/performance.js +21 -3
  52. package/build/src/tools/screencast.js +71 -32
  53. package/build/src/tools/screenshot.js +29 -14
  54. package/build/src/tools/script.js +1 -1
  55. package/build/src/tools/slim/tools.js +2 -7
  56. package/build/src/tools/thirdPartyDeveloper.js +3 -3
  57. package/build/src/tools/webmcp.js +6 -5
  58. package/build/src/types.js +11 -0
  59. package/build/src/utils/WaitForHelper.js +14 -1
  60. package/build/src/utils/check-for-updates.js +1 -1
  61. package/build/src/utils/errorHandling.js +3 -0
  62. package/build/src/utils/logger.js +6 -5
  63. package/build/src/utils/url.js +41 -10
  64. package/build/src/version.js +1 -1
  65. package/package.json +11 -8
  66. package/skills/a11y-debugging/SKILL.md +4 -4
  67. package/skills/brave-devtools-cli/SKILL.md +15 -13
  68. package/skills/cookie-debugging/SKILL.md +8 -5
  69. package/skills/debug-optimize-lcp/SKILL.md +1 -1
  70. 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 = Number(process.env.BRAVE_DEVTOOLS_PROTOCOL_TIMEOUT_MS ?? '600000');
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 _guard = __addDisposableResource(env_1, await this.#mutex.acquire(), false);
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
- if (this.#browser) {
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
- * Clears the cached browser handle if it still matches `candidate`, so the
482
- * next ensureBrowser() call establishes a fresh connection instead of
483
- * reusing a handle that looks connected but is actually dead (e.g. its CDP
484
- * transport died without ever emitting a `close`/`disconnected` event — as
485
- * happens when an adb port-forward is torn down mid-call rather than
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 actively tears `candidate` down in the background: closes it if it
489
- * was launched (so the Brave subprocess doesn't leak), or disconnects if
490
- * it was only connected to (so the transport and its listeners don't
491
- * leak). This also settles any CDP call still pending against it —
492
- * Puppeteer's connection disposal synchronously rejects in-flight
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
- forget(candidate) {
496
- if (this.#browser !== candidate) {
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(candidate);
515
+ void this.#closeLaunchedBrowser(browser);
504
516
  }
505
517
  else {
506
- void candidate.disconnect().catch(err => {
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 — so this shares forget()'s identity guard without its cleanup,
518
- * making a `disconnected` that fires late on an already-superseded browser
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 _guard = __addDisposableResource(env_2, await this.#mutex.acquire(), false);
585
+ const guard = __addDisposableResource(env_2, await this.#mutex.acquire(), false);
575
586
  try {
576
587
  await this.#closeBrowser();
577
588
  }
@@ -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 = new 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.#validateUrlAllowed(url);
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));
@@ -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
- import { DevToolsCommentBridge } from './devtools/DevToolsCommentBridge.js';
105
- import { createTargetUniverse, } from './devtools/DevtoolsUtils.js';
106
- import { ConsoleCollector, NetworkCollector, } from './collectors/PageCollector.js';
107
- import { TextSnapshot } from './TextSnapshot.js';
108
- import { PredefinedNetworkConditions, DevTools, } from './third_party/index.js';
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
- import { logger } from './utils/logger.js';
112
- import { getNetworkMultiplierFromString, WaitForHelper, } from './utils/WaitForHelper.js';
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
- thirdPartyDeveloperTools = [];
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 ? this.#pptrPage.url() : this.target.url();
206
+ return this.#pptrPage?.url() ?? this.target.url();
188
207
  }
189
- getTitle() {
208
+ get targetId() {
190
209
  // @ts-expect-error internal types
191
210
  const info = this.target._getTargetInfo();
192
- return info.title !== this.target.url() ? info.title : '';
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 this.thirdPartyDeveloperTools;
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
- return [];
372
+ this.eventBasedThirdPartyTools = [];
373
+ return this.getThirdPartyDeveloperTools();
311
374
  }
312
- const toolGroups = await this.pptrPage.evaluate(() => {
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 toolGroups) {
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.thirdPartyDeveloperTools = toolGroups;
386
- return toolGroups;
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.tools();
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.#pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
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.#pptrPage.setDefaultNavigationTimeout(this.#navigationTimeout * networkMultiplier * cpuMultiplier);
1078
+ this.pptrPage.setDefaultNavigationTimeout(this.#navigationTimeout * networkMultiplier * cpuMultiplier);
950
1079
  }
951
1080
  waitForTextOnPage(text, timeout) {
952
1081
  const frames = this.pptrPage.frames();