@mozilla/firefox-devtools-mcp-moz 0.10.0 → 0.10.1

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/dist.moz/index.js CHANGED
@@ -14841,7 +14841,7 @@ var init_constants = __esm({
14841
14841
  "src/config/constants.ts"() {
14842
14842
  "use strict";
14843
14843
  SERVER_NAME = true ? "@mozilla/firefox-devtools-mcp" : "firefox-devtools";
14844
- SERVER_VERSION = true ? "0.10.0" : "dev";
14844
+ SERVER_VERSION = true ? "0.10.1" : "dev";
14845
14845
  }
14846
14846
  });
14847
14847
 
@@ -15348,68 +15348,6 @@ var init_core3 = __esm({
15348
15348
  getOptions() {
15349
15349
  return this.options;
15350
15350
  }
15351
- /**
15352
- * Wait for WebSocket to be in OPEN state
15353
- */
15354
- async waitForWebSocketOpen(ws, timeout = 5e3) {
15355
- if (ws.readyState === 1) {
15356
- return;
15357
- }
15358
- if (ws.readyState === 0) {
15359
- return new Promise((resolve4, reject) => {
15360
- const timeoutId = setTimeout(() => {
15361
- ws.off("open", onOpen);
15362
- reject(new Error("Timeout waiting for WebSocket to open"));
15363
- }, timeout);
15364
- const onOpen = () => {
15365
- clearTimeout(timeoutId);
15366
- ws.off("open", onOpen);
15367
- resolve4();
15368
- };
15369
- ws.on("open", onOpen);
15370
- });
15371
- }
15372
- throw new Error(`WebSocket is not open: readyState ${ws.readyState}`);
15373
- }
15374
- /**
15375
- * Send raw BiDi command and get response
15376
- */
15377
- async sendBiDiCommand(method, params = {}) {
15378
- if (!this.driver) {
15379
- throw new Error("Driver not connected");
15380
- }
15381
- const bidi = await this.driver.getBidi();
15382
- const ws = bidi.socket;
15383
- await this.waitForWebSocketOpen(ws);
15384
- const id = Math.floor(Math.random() * 1e6);
15385
- return new Promise((resolve4, reject) => {
15386
- const messageHandler = (data) => {
15387
- try {
15388
- const payload = JSON.parse(data.toString());
15389
- if (payload.id === id) {
15390
- ws.off("message", messageHandler);
15391
- if (payload.error) {
15392
- reject(new Error(`BiDi error: ${JSON.stringify(payload.error)}`));
15393
- } else {
15394
- resolve4(payload.result);
15395
- }
15396
- }
15397
- } catch {
15398
- }
15399
- };
15400
- ws.on("message", messageHandler);
15401
- const command = {
15402
- id,
15403
- method,
15404
- params
15405
- };
15406
- ws.send(JSON.stringify(command));
15407
- setTimeout(() => {
15408
- ws.off("message", messageHandler);
15409
- reject(new Error(`BiDi command timeout: ${method}`));
15410
- }, 1e4);
15411
- });
15412
- }
15413
15351
  /**
15414
15352
  * Close driver and cleanup.
15415
15353
  * - Tries graceful quit() with a timeout; on timeout, force-kills via onQuit_().
@@ -15479,6 +15417,107 @@ var init_core3 = __esm({
15479
15417
  }
15480
15418
  });
15481
15419
 
15420
+ // src/firefox/bidi.ts
15421
+ import EventEmitter from "events";
15422
+ var BiDiFacade;
15423
+ var init_bidi = __esm({
15424
+ "src/firefox/bidi.ts"() {
15425
+ "use strict";
15426
+ init_logger();
15427
+ BiDiFacade = class extends EventEmitter {
15428
+ constructor(driver) {
15429
+ super();
15430
+ this.driver = driver;
15431
+ }
15432
+ listening = false;
15433
+ nextCommandId = 1;
15434
+ async subscribe(events) {
15435
+ const bidi = await this.driver.getBidi();
15436
+ if (!this.listening) {
15437
+ this.listenForEvents(bidi.socket);
15438
+ this.listening = true;
15439
+ }
15440
+ await bidi.subscribe(events);
15441
+ }
15442
+ async sendCommand(method, params = {}) {
15443
+ const bidi = await this.driver.getBidi();
15444
+ const ws = bidi.socket;
15445
+ await this.waitForWebSocketOpen(ws);
15446
+ const id = this.nextCommandId++;
15447
+ return new Promise((resolve4, reject) => {
15448
+ const messageHandler = (data) => {
15449
+ try {
15450
+ const payload = JSON.parse(data.toString());
15451
+ if (payload.id === id) {
15452
+ ws.off("message", messageHandler);
15453
+ if (payload.error) {
15454
+ reject(new Error(`BiDi error: ${JSON.stringify(payload.error)}`));
15455
+ } else {
15456
+ resolve4(payload.result);
15457
+ }
15458
+ }
15459
+ } catch {
15460
+ }
15461
+ };
15462
+ ws.on("message", messageHandler);
15463
+ const command = {
15464
+ id,
15465
+ method,
15466
+ params
15467
+ };
15468
+ ws.send(JSON.stringify(command));
15469
+ setTimeout(() => {
15470
+ ws.off("message", messageHandler);
15471
+ reject(new Error(`BiDi command timeout: ${method}`));
15472
+ }, 1e4);
15473
+ });
15474
+ }
15475
+ listenForEvents(ws) {
15476
+ ws.on("message", (data) => {
15477
+ let payload;
15478
+ try {
15479
+ payload = JSON.parse(data.toString());
15480
+ } catch {
15481
+ return;
15482
+ }
15483
+ if (payload?.type === "event" && payload.method) {
15484
+ try {
15485
+ this.emit(payload.method, payload.params);
15486
+ } catch (error2) {
15487
+ logDebug(
15488
+ `Error emitting ${payload.method} event: ${error2 instanceof Error ? error2.message : String(error2)}`
15489
+ );
15490
+ }
15491
+ }
15492
+ });
15493
+ }
15494
+ /**
15495
+ * Wait for WebSocket to be in OPEN state
15496
+ */
15497
+ async waitForWebSocketOpen(ws, timeout = 5e3) {
15498
+ if (ws.readyState === 1) {
15499
+ return;
15500
+ }
15501
+ if (ws.readyState === 0) {
15502
+ return new Promise((resolve4, reject) => {
15503
+ const timeoutId = setTimeout(() => {
15504
+ ws.off("open", onOpen);
15505
+ reject(new Error("Timeout waiting for WebSocket to open"));
15506
+ }, timeout);
15507
+ const onOpen = () => {
15508
+ clearTimeout(timeoutId);
15509
+ ws.off("open", onOpen);
15510
+ resolve4();
15511
+ };
15512
+ ws.on("open", onOpen);
15513
+ });
15514
+ }
15515
+ throw new Error(`WebSocket is not open: readyState ${ws.readyState}`);
15516
+ }
15517
+ };
15518
+ }
15519
+ });
15520
+
15482
15521
  // src/utils/remote-value.ts
15483
15522
  function remoteValueToNative(rv) {
15484
15523
  if (!rv || typeof rv !== "object") {
@@ -15549,8 +15588,8 @@ var init_console = __esm({
15549
15588
  MAX_CONSOLE_MESSAGES = 1e3;
15550
15589
  CONSOLE_TTL_MS = 5 * 60 * 1e3;
15551
15590
  ConsoleEvents = class {
15552
- constructor(driver, options = {}) {
15553
- this.driver = driver;
15591
+ constructor(bidi, options = {}) {
15592
+ this.bidi = bidi;
15554
15593
  this.options = {
15555
15594
  autoClearOnNavigate: false,
15556
15595
  // Changed default to false to preserve logs across tabs
@@ -15563,44 +15602,27 @@ var init_console = __esm({
15563
15602
  /**
15564
15603
  * Subscribe to BiDi console events and navigation lifecycle
15565
15604
  */
15566
- async subscribe(contextId) {
15605
+ async subscribe() {
15567
15606
  if (this.subscribed) {
15568
15607
  return;
15569
15608
  }
15570
- const bidi = await this.driver.getBidi();
15571
- await bidi.subscribe("log.entryAdded", contextId ? [contextId] : void 0);
15572
- try {
15573
- await bidi.subscribe("browsingContext.load", contextId ? [contextId] : void 0);
15574
- await bidi.subscribe("browsingContext.domContentLoaded", contextId ? [contextId] : void 0);
15575
- } catch {
15576
- logDebug(
15577
- "Navigation events subscription skipped (may not be available in this Firefox version)"
15578
- );
15579
- }
15580
- const ws = bidi.socket;
15581
- ws.on("message", (data) => {
15582
- try {
15583
- const payload = JSON.parse(data.toString());
15584
- if (payload?.method === "log.entryAdded") {
15585
- const entry = payload.params;
15586
- const message = {
15587
- level: entry.level || "info",
15588
- text: entry.text || (entry.args ? JSON.stringify(entry.args) : ""),
15589
- timestamp: entry.timestamp || Date.now(),
15590
- source: entry.source?.realm,
15591
- args: entry.args
15592
- };
15593
- this.consoleMessages.push(message);
15594
- logDebug(`Console [${message.level}]: ${message.text}`);
15595
- }
15596
- if (payload?.method === "browsingContext.load" || payload?.method === "browsingContext.domContentLoaded") {
15597
- if (this.options.autoClearOnNavigate) {
15598
- this.clearMessages();
15599
- }
15600
- }
15601
- } catch {
15602
- }
15609
+ await this.bidi.subscribe("log.entryAdded");
15610
+ await this.bidi.subscribe(["browsingContext.load", "browsingContext.domContentLoaded"]);
15611
+ this.bidi.on("log.entryAdded", (entry) => {
15612
+ const message = {
15613
+ level: entry.level || "info",
15614
+ text: entry.text || (entry.args ? JSON.stringify(entry.args) : ""),
15615
+ timestamp: entry.timestamp || Date.now(),
15616
+ source: entry.source?.realm,
15617
+ args: entry.args
15618
+ };
15619
+ this.consoleMessages.push(message);
15620
+ logDebug(`Console [${message.level}]: ${message.text}`);
15603
15621
  });
15622
+ if (this.options.autoClearOnNavigate) {
15623
+ this.bidi.on("browsingContext.load", () => this.clearMessages());
15624
+ this.bidi.on("browsingContext.domContentLoaded", () => this.clearMessages());
15625
+ }
15604
15626
  this.subscribed = true;
15605
15627
  logDebug("Console listener active with lifecycle hooks");
15606
15628
  }
@@ -15648,9 +15670,8 @@ var init_network = __esm({
15648
15670
  NETWORK_TTL_MS = 5 * 60 * 1e3;
15649
15671
  MAX_ENCODED_DATA_SIZE = 10 * 1e3 * 1e3;
15650
15672
  NetworkEvents = class {
15651
- constructor(driver, options = {}, sendCommand) {
15652
- this.driver = driver;
15653
- this.sendCommand = sendCommand;
15673
+ constructor(bidi, options = {}) {
15674
+ this.bidi = bidi;
15654
15675
  this.options = {
15655
15676
  autoClearOnNavigate: true,
15656
15677
  ...options
@@ -15666,90 +15687,81 @@ var init_network = __esm({
15666
15687
  * Subscribe to BiDi network events and navigation lifecycle
15667
15688
  * Enables monitoring by default (always-on capture)
15668
15689
  */
15669
- async subscribe(contextId) {
15690
+ async subscribe() {
15670
15691
  if (this.subscribed) {
15671
15692
  return;
15672
15693
  }
15673
- const bidi = await this.driver.getBidi();
15674
- await bidi.subscribe("network.beforeRequestSent", contextId ? [contextId] : void 0);
15675
- await bidi.subscribe("network.responseStarted", contextId ? [contextId] : void 0);
15676
- await bidi.subscribe("network.responseCompleted", contextId ? [contextId] : void 0);
15677
- try {
15678
- await bidi.subscribe("browsingContext.load", contextId ? [contextId] : void 0);
15679
- await bidi.subscribe("browsingContext.domContentLoaded", contextId ? [contextId] : void 0);
15680
- } catch {
15681
- logDebug(
15682
- "Navigation events subscription skipped (may not be available in this Firefox version)"
15683
- );
15684
- }
15685
- const ws = bidi.socket;
15686
- ws.on("message", (data) => {
15687
- try {
15688
- const payload = JSON.parse(data.toString());
15689
- if (payload?.method === "browsingContext.load" || payload?.method === "browsingContext.domContentLoaded") {
15690
- if (this.enabled && this.options.autoClearOnNavigate) {
15691
- this.clearRequests();
15692
- }
15693
- return;
15694
- }
15695
- if (!this.enabled) {
15696
- return;
15697
- }
15698
- if (payload?.method === "network.beforeRequestSent") {
15699
- const req = payload.params;
15700
- const requestId = req.request?.request || req.requestId;
15701
- if (!requestId) {
15702
- return;
15703
- }
15704
- this.requestStartTimes.set(requestId, Date.now());
15705
- const record2 = {
15706
- id: requestId,
15707
- url: req.request?.url || "",
15708
- method: req.request?.method || "GET",
15709
- timestamp: Date.now(),
15710
- resourceType: this.guessResourceType(req.request?.url || ""),
15711
- isXHR: req.initiator?.type === "xmlhttprequest" || req.initiator?.type === "fetch",
15712
- requestHeaders: this.parseHeaders(req.request?.headers || []),
15713
- timings: {
15714
- requestTime: Date.now()
15715
- }
15716
- };
15717
- this.networkRecords.set(requestId, record2);
15718
- logDebug(`Network request [${record2.method}]: ${record2.url}`);
15719
- }
15720
- if (payload?.method === "network.responseStarted") {
15721
- const resp = payload.params;
15722
- const requestId = resp.request?.request || resp.requestId;
15723
- if (!requestId) {
15724
- return;
15725
- }
15726
- const existing = this.networkRecords.get(requestId);
15727
- if (existing) {
15728
- existing.status = resp.response?.status;
15729
- existing.statusText = resp.response?.statusText || "";
15730
- existing.responseHeaders = this.parseHeaders(resp.response?.headers || []);
15731
- }
15694
+ await this.bidi.subscribe([
15695
+ "network.beforeRequestSent",
15696
+ "network.responseStarted",
15697
+ "network.responseCompleted"
15698
+ ]);
15699
+ await this.bidi.subscribe(["browsingContext.load", "browsingContext.domContentLoaded"]);
15700
+ const onLoadEvent = () => {
15701
+ if (this.enabled && this.options.autoClearOnNavigate) {
15702
+ this.clearRequests();
15703
+ }
15704
+ };
15705
+ this.bidi.on("browsingContext.domContentLoaded", onLoadEvent);
15706
+ this.bidi.on("browsingContext.load", onLoadEvent);
15707
+ this.bidi.on("network.beforeRequestSent", (req) => {
15708
+ if (!this.enabled) {
15709
+ return;
15710
+ }
15711
+ const requestId = req.request?.request || req.requestId;
15712
+ if (!requestId) {
15713
+ return;
15714
+ }
15715
+ this.requestStartTimes.set(requestId, Date.now());
15716
+ const record2 = {
15717
+ id: requestId,
15718
+ url: req.request?.url || "",
15719
+ method: req.request?.method || "GET",
15720
+ timestamp: Date.now(),
15721
+ resourceType: this.guessResourceType(req.request?.url || ""),
15722
+ isXHR: req.initiator?.type === "xmlhttprequest" || req.initiator?.type === "fetch",
15723
+ requestHeaders: this.parseHeaders(req.request?.headers || []),
15724
+ timings: {
15725
+ requestTime: Date.now()
15732
15726
  }
15733
- if (payload?.method === "network.responseCompleted") {
15734
- const resp = payload.params;
15735
- const requestId = resp.request?.request || resp.requestId;
15736
- if (!requestId) {
15737
- return;
15738
- }
15739
- const existing = this.networkRecords.get(requestId);
15740
- const startTime = this.requestStartTimes.get(requestId);
15741
- if (existing && startTime) {
15742
- existing.timings.responseTime = Date.now();
15743
- existing.timings.duration = Date.now() - startTime;
15744
- if (!existing.status && resp.response?.status) {
15745
- existing.status = resp.response.status;
15746
- existing.statusText = resp.response.statusText || "";
15747
- }
15748
- }
15749
- this.requestStartTimes.delete(requestId);
15727
+ };
15728
+ this.networkRecords.set(requestId, record2);
15729
+ logDebug(`Network request [${record2.method}]: ${record2.url}`);
15730
+ });
15731
+ this.bidi.on("network.responseStarted", (resp) => {
15732
+ if (!this.enabled) {
15733
+ return;
15734
+ }
15735
+ const requestId = resp.request?.request || resp.requestId;
15736
+ if (!requestId) {
15737
+ return;
15738
+ }
15739
+ const existing = this.networkRecords.get(requestId);
15740
+ if (existing) {
15741
+ existing.status = resp.response?.status;
15742
+ existing.statusText = resp.response?.statusText || "";
15743
+ existing.responseHeaders = this.parseHeaders(resp.response?.headers || []);
15744
+ }
15745
+ });
15746
+ this.bidi.on("network.responseCompleted", (resp) => {
15747
+ if (!this.enabled) {
15748
+ return;
15749
+ }
15750
+ const requestId = resp.request?.request || resp.requestId;
15751
+ if (!requestId) {
15752
+ return;
15753
+ }
15754
+ const existing = this.networkRecords.get(requestId);
15755
+ const startTime = this.requestStartTimes.get(requestId);
15756
+ if (existing && startTime) {
15757
+ existing.timings.responseTime = Date.now();
15758
+ existing.timings.duration = Date.now() - startTime;
15759
+ if (!existing.status && resp.response?.status) {
15760
+ existing.status = resp.response.status;
15761
+ existing.statusText = resp.response.statusText || "";
15750
15762
  }
15751
- } catch {
15752
15763
  }
15764
+ this.requestStartTimes.delete(requestId);
15753
15765
  });
15754
15766
  await this.registerDataCollector();
15755
15767
  this.subscribed = true;
@@ -15765,11 +15777,8 @@ var init_network = __esm({
15765
15777
  logDebug("Network body capture disabled, skipping data collector registration");
15766
15778
  return;
15767
15779
  }
15768
- if (!this.sendCommand) {
15769
- return;
15770
- }
15771
15780
  try {
15772
- const result = await this.sendCommand("network.addDataCollector", {
15781
+ const result = await this.bidi.sendCommand("network.addDataCollector", {
15773
15782
  dataTypes: ["request", "response"],
15774
15783
  maxEncodedDataSize: MAX_ENCODED_DATA_SIZE
15775
15784
  });
@@ -15789,11 +15798,11 @@ var init_network = __esm({
15789
15798
  * when the body was never collected, evicted, or the browser lacks support.
15790
15799
  */
15791
15800
  async fetchBody(requestId, dataType) {
15792
- if (!this.sendCommand || !this.collectorId) {
15801
+ if (!this.collectorId) {
15793
15802
  return { ok: false, reason: "unsupported" };
15794
15803
  }
15795
15804
  try {
15796
- const result = await this.sendCommand("network.getData", {
15805
+ const result = await this.bidi.sendCommand("network.getData", {
15797
15806
  request: requestId,
15798
15807
  dataType
15799
15808
  });
@@ -15975,46 +15984,35 @@ var init_debugging = __esm({
15975
15984
  init_logger();
15976
15985
  MAX_LOGPOINT_RESULTS = 100;
15977
15986
  DebuggingEvents = class {
15978
- constructor(driver, sendBiDiCommand) {
15979
- this.driver = driver;
15980
- this.sendBiDiCommand = sendBiDiCommand;
15987
+ constructor(bidi) {
15988
+ this.bidi = bidi;
15981
15989
  }
15982
15990
  logpoints = /* @__PURE__ */ new Map();
15983
15991
  subscribed = false;
15984
15992
  /**
15985
15993
  * Subscribe to moz:debugging events
15986
15994
  */
15987
- async subscribe(contextId) {
15995
+ async subscribe() {
15988
15996
  if (this.subscribed) {
15989
15997
  return;
15990
15998
  }
15991
- const bidi = await this.driver.getBidi();
15992
15999
  try {
15993
- await bidi.subscribe("moz:debugging.paused", contextId ? [contextId] : void 0);
15994
- await bidi.subscribe("moz:debugging.resumed", contextId ? [contextId] : void 0);
16000
+ await this.bidi.subscribe(["moz:debugging.paused", "moz:debugging.resumed"]);
15995
16001
  } catch {
15996
16002
  logDebug(
15997
16003
  "Debugging events subscription skipped (may not be available in this Firefox version)"
15998
16004
  );
15999
16005
  }
16000
- const ws = bidi.socket;
16001
- ws.on("message", (data) => {
16002
- try {
16003
- const payload = JSON.parse(data.toString());
16004
- if (payload?.method === "moz:debugging.paused") {
16005
- const { context, url, line, column } = payload.params;
16006
- const logpointId = this.findLogpointByLocation(url, line);
16007
- if (logpointId) {
16008
- void this.handleLogpointPause(context, logpointId);
16009
- return;
16010
- }
16011
- logDebug(`moz:Debugging paused in context: ${context} at ${url}:${line}:${column}`);
16012
- }
16013
- if (payload?.method === "moz:debugging.resumed") {
16014
- logDebug(`moz:Debugging resumed in context: ${payload.params.context}`);
16015
- }
16016
- } catch {
16006
+ this.bidi.on("moz:debugging.paused", ({ context, url, line, column }) => {
16007
+ const logpointId = this.findLogpointByLocation(url, line);
16008
+ if (logpointId) {
16009
+ void this.handleLogpointPause(context, logpointId);
16010
+ return;
16017
16011
  }
16012
+ logDebug(`moz:Debugging paused in context: ${context} at ${url}:${line}:${column}`);
16013
+ });
16014
+ this.bidi.on("moz:debugging.resumed", (entry) => {
16015
+ logDebug(`moz:Debugging resumed in context: ${entry.context}`);
16018
16016
  });
16019
16017
  this.subscribed = true;
16020
16018
  logDebug("moz:debugging listener active");
@@ -16048,7 +16046,7 @@ var init_debugging = __esm({
16048
16046
  }
16049
16047
  logDebug(`Logpoint hit: ${logpointId} in context ${contextId}`);
16050
16048
  try {
16051
- const result = await this.sendBiDiCommand("script.evaluate", {
16049
+ const result = await this.bidi.sendCommand("script.evaluate", {
16052
16050
  expression: entry.expression,
16053
16051
  target: { context: contextId },
16054
16052
  awaitPromise: false
@@ -16080,7 +16078,7 @@ var init_debugging = __esm({
16080
16078
  logDebug(`Logpoint ${logpointId}: result buffer capped at ${MAX_LOGPOINT_RESULTS}`);
16081
16079
  }
16082
16080
  }
16083
- await this.sendBiDiCommand("moz:debugging.resume", { context: contextId }).catch((err) => {
16081
+ await this.bidi.sendCommand("moz:debugging.resume", { context: contextId }).catch((err) => {
16084
16082
  logDebug(`Failed to resume after logpoint: ${String(err)}`);
16085
16083
  });
16086
16084
  }
@@ -16098,8 +16096,8 @@ var init_downloads = __esm({
16098
16096
  MAX_DOWNLOADS = 500;
16099
16097
  DOWNLOAD_TTL_MS = 30 * 60 * 1e3;
16100
16098
  DownloadEvents = class {
16101
- constructor(driver) {
16102
- this.driver = driver;
16099
+ constructor(bidi) {
16100
+ this.bidi = bidi;
16103
16101
  }
16104
16102
  downloads = /* @__PURE__ */ new Map();
16105
16103
  subscribed = false;
@@ -16111,65 +16109,53 @@ var init_downloads = __esm({
16111
16109
  /**
16112
16110
  * Subscribe to BiDi download events.
16113
16111
  */
16114
- async subscribe(contextId) {
16112
+ async subscribe() {
16115
16113
  if (this.subscribed) {
16116
16114
  return;
16117
16115
  }
16118
- const bidi = await this.driver.getBidi();
16119
- const contexts = contextId ? [contextId] : void 0;
16120
- await bidi.subscribe("browsingContext.downloadWillBegin", contexts);
16121
- await bidi.subscribe("browsingContext.downloadEnd", contexts);
16122
- const ws = bidi.socket;
16123
- ws.on("message", (data) => {
16124
- try {
16125
- const payload = JSON.parse(data.toString());
16126
- if (payload?.method === "browsingContext.downloadWillBegin") {
16127
- const p = payload.params;
16128
- let key = p.download ?? p.navigation;
16129
- if (!key) {
16130
- key = `download-${this.fallbackCounter++}`;
16131
- this.pendingFallbackKey = key;
16132
- }
16133
- this.downloads.set(key, {
16134
- id: key,
16135
- context: p.context,
16136
- navigation: p.navigation ?? null,
16137
- url: p.url || "",
16138
- suggestedFilename: p.suggestedFilename || "",
16139
- status: "in_progress",
16140
- startTimestamp: p.timestamp ?? Date.now()
16141
- });
16142
- logDebug(`Download started: filename=${p.suggestedFilename}, url=${p.url}, id=${key}`);
16143
- }
16144
- if (payload?.method === "browsingContext.downloadEnd") {
16145
- const p = payload.params;
16146
- let key = p?.download ?? p?.navigation;
16147
- if (!key) {
16148
- key = this.pendingFallbackKey ?? `download-${this.fallbackCounter++}`;
16149
- this.pendingFallbackKey = null;
16150
- }
16151
- const existing = this.downloads.get(key) ?? {
16152
- id: key,
16153
- context: p.context,
16154
- navigation: p.navigation ?? null,
16155
- url: p.url || "",
16156
- suggestedFilename: "",
16157
- status: "in_progress",
16158
- startTimestamp: p.timestamp ?? Date.now()
16159
- };
16160
- existing.status = p.status;
16161
- existing.endTimestamp = p.timestamp ?? Date.now();
16162
- existing.durationMs = existing.endTimestamp - existing.startTimestamp;
16163
- if (p.status === "complete" && p.filepath) {
16164
- existing.filepath = p.filepath;
16165
- }
16166
- this.downloads.set(key, existing);
16167
- logDebug(
16168
- `Download ${p.status}: filepath=${existing.filepath}, url=${existing.url}, id=${key}`
16169
- );
16170
- }
16171
- } catch {
16116
+ await this.bidi.subscribe(["browsingContext.downloadWillBegin", "browsingContext.downloadEnd"]);
16117
+ this.bidi.on("browsingContext.downloadWillBegin", (p) => {
16118
+ let key = p.download ?? p.navigation;
16119
+ if (!key) {
16120
+ key = `download-${this.fallbackCounter++}`;
16121
+ this.pendingFallbackKey = key;
16122
+ }
16123
+ this.downloads.set(key, {
16124
+ id: key,
16125
+ context: p.context,
16126
+ navigation: p.navigation ?? null,
16127
+ url: p.url || "",
16128
+ suggestedFilename: p.suggestedFilename || "",
16129
+ status: "in_progress",
16130
+ startTimestamp: p.timestamp ?? Date.now()
16131
+ });
16132
+ logDebug(`Download started: filename=${p.suggestedFilename}, url=${p.url}, id=${key}`);
16133
+ });
16134
+ this.bidi.on("browsingContext.downloadEnd", (p) => {
16135
+ let key = p?.download ?? p?.navigation;
16136
+ if (!key) {
16137
+ key = this.pendingFallbackKey ?? `download-${this.fallbackCounter++}`;
16138
+ this.pendingFallbackKey = null;
16139
+ }
16140
+ const existing = this.downloads.get(key) ?? {
16141
+ id: key,
16142
+ context: p.context,
16143
+ navigation: p.navigation ?? null,
16144
+ url: p.url || "",
16145
+ suggestedFilename: "",
16146
+ status: "in_progress",
16147
+ startTimestamp: p.timestamp ?? Date.now()
16148
+ };
16149
+ existing.status = p.status;
16150
+ existing.endTimestamp = p.timestamp ?? Date.now();
16151
+ existing.durationMs = existing.endTimestamp - existing.startTimestamp;
16152
+ if (p.status === "complete" && p.filepath) {
16153
+ existing.filepath = p.filepath;
16172
16154
  }
16155
+ this.downloads.set(key, existing);
16156
+ logDebug(
16157
+ `Download ${p.status}: filepath=${existing.filepath}, url=${existing.url}, id=${key}`
16158
+ );
16173
16159
  });
16174
16160
  this.subscribed = true;
16175
16161
  logDebug("Download listener ready");
@@ -17052,6 +17038,7 @@ var init_firefox = __esm({
17052
17038
  "src/firefox/index.ts"() {
17053
17039
  "use strict";
17054
17040
  init_core3();
17041
+ init_bidi();
17055
17042
  init_logger();
17056
17043
  init_remote_value();
17057
17044
  init_events();
@@ -17060,6 +17047,7 @@ var init_firefox = __esm({
17060
17047
  init_snapshot();
17061
17048
  FirefoxClient = class {
17062
17049
  core;
17050
+ bidi = null;
17063
17051
  consoleEvents = null;
17064
17052
  networkEvents = null;
17065
17053
  debuggingEvents = null;
@@ -17070,31 +17058,52 @@ var init_firefox = __esm({
17070
17058
  constructor(options) {
17071
17059
  this.core = new FirefoxCore(options);
17072
17060
  }
17061
+ getBidi() {
17062
+ if (!this.bidi) {
17063
+ throw new Error("Not connected");
17064
+ }
17065
+ return this.bidi;
17066
+ }
17073
17067
  /**
17074
17068
  * Connect and initialize all modules
17075
17069
  */
17076
17070
  async connect() {
17077
17071
  await this.core.connect();
17078
17072
  const driver = this.core.getDriver();
17073
+ this.bidi = new BiDiFacade(driver);
17079
17074
  this.snapshot = new SnapshotManager(driver);
17080
- const hasBidi = "getBidi" in driver && typeof driver.getBidi === "function";
17081
- if (hasBidi) {
17082
- this.consoleEvents = new ConsoleEvents(driver, {
17083
- autoClearOnNavigate: false
17084
- });
17085
- this.networkEvents = new NetworkEvents(
17086
- driver,
17087
- {
17088
- autoClearOnNavigate: false,
17089
- captureBodies: this.core.getOptions().captureNetworkBodies !== false
17090
- },
17091
- (method, params) => this.core.sendBiDiCommand(method, params ?? {})
17092
- );
17093
- this.debuggingEvents = new DebuggingEvents(
17094
- driver,
17095
- (method, params) => this.core.sendBiDiCommand(method, params)
17096
- );
17097
- this.downloadEvents = new DownloadEvents(driver);
17075
+ this.consoleEvents = new ConsoleEvents(this.bidi, {
17076
+ autoClearOnNavigate: false
17077
+ });
17078
+ try {
17079
+ await this.consoleEvents.subscribe();
17080
+ } catch {
17081
+ logDebug("Unable to subscribe to console events");
17082
+ this.consoleEvents = null;
17083
+ }
17084
+ this.networkEvents = new NetworkEvents(this.bidi, {
17085
+ autoClearOnNavigate: false,
17086
+ captureBodies: this.core.getOptions().captureNetworkBodies !== false
17087
+ });
17088
+ try {
17089
+ await this.networkEvents.subscribe();
17090
+ } catch {
17091
+ logDebug("Unable to subscribe to network events");
17092
+ this.networkEvents = null;
17093
+ }
17094
+ this.debuggingEvents = new DebuggingEvents(this.bidi);
17095
+ try {
17096
+ await this.debuggingEvents.subscribe();
17097
+ } catch {
17098
+ logDebug("Unable to subscribe to debugging events");
17099
+ this.debuggingEvents = null;
17100
+ }
17101
+ this.downloadEvents = new DownloadEvents(this.bidi);
17102
+ try {
17103
+ await this.downloadEvents.subscribe();
17104
+ } catch {
17105
+ logDebug("Unable to subscribe to download events");
17106
+ this.downloadEvents = null;
17098
17107
  }
17099
17108
  this.dom = new DomInteractions(
17100
17109
  driver,
@@ -17104,40 +17113,8 @@ var init_firefox = __esm({
17104
17113
  driver,
17105
17114
  () => this.core.getCurrentContextId(),
17106
17115
  (id) => this.core.setCurrentContextId(id),
17107
- (method, params) => this.core.sendBiDiCommand(method, params)
17116
+ (method, params) => this.getBidi().sendCommand(method, params)
17108
17117
  );
17109
- if (this.consoleEvents) {
17110
- try {
17111
- await this.consoleEvents.subscribe(void 0);
17112
- } catch {
17113
- logDebug("Unable to subscribe to console events");
17114
- this.consoleEvents = null;
17115
- }
17116
- }
17117
- if (this.networkEvents) {
17118
- try {
17119
- await this.networkEvents.subscribe(void 0);
17120
- } catch {
17121
- logDebug("Unable to subscribe to network events");
17122
- this.networkEvents = null;
17123
- }
17124
- }
17125
- if (this.debuggingEvents) {
17126
- try {
17127
- await this.debuggingEvents.subscribe();
17128
- } catch {
17129
- logDebug("Unable to subscribe to debugging events");
17130
- this.debuggingEvents = null;
17131
- }
17132
- }
17133
- if (this.downloadEvents) {
17134
- try {
17135
- await this.downloadEvents.subscribe(void 0);
17136
- } catch {
17137
- logDebug("Unable to subscribe to download events");
17138
- this.downloadEvents = null;
17139
- }
17140
- }
17141
17118
  }
17142
17119
  // ============================================================================
17143
17120
  // DOM / Evaluate
@@ -17149,7 +17126,7 @@ var init_firefox = __esm({
17149
17126
  * native value; throws on a script exception.
17150
17127
  */
17151
17128
  async evaluate(expression) {
17152
- const result = await this.core.sendBiDiCommand("script.evaluate", {
17129
+ const result = await this.getBidi().sendCommand("script.evaluate", {
17153
17130
  expression,
17154
17131
  awaitPromise: true,
17155
17132
  target: { context: this.core.getCurrentContextId() }
@@ -17394,7 +17371,7 @@ var init_firefox = __esm({
17394
17371
  */
17395
17372
  async setDownloadBehavior(behavior) {
17396
17373
  const downloadBehavior = behavior === "default" ? null : behavior === "allowed" ? { type: "allowed" } : { type: "denied" };
17397
- await this.core.sendBiDiCommand("browser.setDownloadBehavior", { downloadBehavior });
17374
+ await this.getBidi().sendCommand("browser.setDownloadBehavior", { downloadBehavior });
17398
17375
  }
17399
17376
  // ============================================================================
17400
17377
  // Snapshot
@@ -17446,7 +17423,7 @@ var init_firefox = __esm({
17446
17423
  * @internal
17447
17424
  */
17448
17425
  async sendBiDiCommand(method, params = {}) {
17449
- return await this.core.sendBiDiCommand(method, params);
17426
+ return await this.getBidi().sendCommand(method, params);
17450
17427
  }
17451
17428
  /**
17452
17429
  * Get WebDriver instance (for advanced operations)
@@ -17491,7 +17468,7 @@ var init_firefox = __esm({
17491
17468
  if (!this.debuggingEvents) {
17492
17469
  throw new Error("Debugging events not available");
17493
17470
  }
17494
- const result = await this.core.sendBiDiCommand("moz:debugging.setBreakpoint", {
17471
+ const result = await this.getBidi().sendCommand("moz:debugging.setBreakpoint", {
17495
17472
  location: { url, line }
17496
17473
  });
17497
17474
  const logpointId = result.breakpoint;
@@ -17505,7 +17482,7 @@ var init_firefox = __esm({
17505
17482
  if (!this.debuggingEvents) {
17506
17483
  throw new Error("Debugging events not available");
17507
17484
  }
17508
- await this.core.sendBiDiCommand("moz:debugging.removeBreakpoint", {
17485
+ await this.getBidi().sendCommand("moz:debugging.removeBreakpoint", {
17509
17486
  breakpoint: logpointId
17510
17487
  });
17511
17488
  this.debuggingEvents.removeLogpoint(logpointId);
@@ -17559,11 +17536,56 @@ var init_firefox = __esm({
17559
17536
  }
17560
17537
  });
17561
17538
 
17539
+ // src/tools/instructions.ts
17540
+ function buildInstructions(moduleNames, toolNames) {
17541
+ const byName = new Map(MODULES.map((m) => [m.name, m]));
17542
+ const capabilities = moduleNames.flatMap((name) => {
17543
+ const module17 = byName.get(name);
17544
+ return module17 ? [`- ${module17.name}: ${module17.description}`] : [];
17545
+ });
17546
+ const sections = [INSTRUCTIONS_INTRO, ["Enabled capabilities:", ...capabilities].join("\n")];
17547
+ const core = CORE_TOOLS.filter((name) => toolNames.has(name));
17548
+ if (core.length > 0) {
17549
+ const groups = TASK_TOOLS.flatMap(({ purpose, tools }) => {
17550
+ const available = tools.filter((name) => toolNames.has(name));
17551
+ return available.length > 0 ? [`${available.join(" / ")} for ${purpose}`] : [];
17552
+ });
17553
+ const extra = groups.length > 0 ? ` Add task-specific tools to the same search: ${groups.join(", ")}.` : "";
17554
+ sections.push(
17555
+ `When these tools are deferred, load the core set in one search rather than one at a time: ${core.join(", ")}.${extra}`
17556
+ );
17557
+ }
17558
+ return sections.join("\n\n");
17559
+ }
17560
+ var INSTRUCTIONS_INTRO, CORE_TOOLS, TASK_TOOLS;
17561
+ var init_instructions = __esm({
17562
+ "src/tools/instructions.ts"() {
17563
+ "use strict";
17564
+ init_tools();
17565
+ INSTRUCTIONS_INTRO = [
17566
+ "Browse and debug the web with Firefox.",
17567
+ "Use this for any browser or web page task: opening a URL, automating a web flow,",
17568
+ "or debugging a page (console errors, network requests, DOM state).",
17569
+ "The task does not need to name Firefox, reach for these tools unless another",
17570
+ "browser is explicitly requested.",
17571
+ "Chrome-only browser tools do not control Firefox, do not assume they cover this."
17572
+ ].join(" ");
17573
+ CORE_TOOLS = ["list_pages", "new_page", "navigate_page", "take_snapshot", "get_page_text"];
17574
+ TASK_TOOLS = [
17575
+ { purpose: "interaction", tools: ["click_by_uid", "fill_by_uid", "hover_by_uid"] },
17576
+ { purpose: "debugging", tools: ["list_console_messages", "list_network_requests"] },
17577
+ { purpose: "visuals", tools: ["screenshot_page", "screenshot_by_uid"] },
17578
+ { purpose: "one-off JS", tools: ["evaluate_script"] }
17579
+ ];
17580
+ }
17581
+ });
17582
+
17562
17583
  // src/tools/registry.ts
17563
17584
  function buildToolset(options) {
17564
17585
  const { moduleNames, warnings } = selectModules(options);
17565
17586
  const { toolDefinitions, handlers } = collectTools(moduleNames);
17566
- return { moduleNames, warnings, toolDefinitions, handlers };
17587
+ const instructions = buildInstructions(moduleNames, new Set(handlers.keys()));
17588
+ return { moduleNames, warnings, toolDefinitions, handlers, instructions };
17567
17589
  }
17568
17590
  function selectModules(options) {
17569
17591
  const { tools: requested, preset, enableScript, enablePrivilegedContext } = options;
@@ -17651,6 +17673,7 @@ var init_registry = __esm({
17651
17673
  "src/tools/registry.ts"() {
17652
17674
  "use strict";
17653
17675
  init_tools();
17676
+ init_instructions();
17654
17677
  privilegedModuleNames = new Set(MODULES.filter((m) => m.privileged).map((m) => m.name));
17655
17678
  }
17656
17679
  });
@@ -17771,7 +17794,8 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17771
17794
  moduleNames,
17772
17795
  warnings,
17773
17796
  toolDefinitions: allTools,
17774
- handlers: toolHandlers
17797
+ handlers: toolHandlers,
17798
+ instructions
17775
17799
  } = buildToolset({
17776
17800
  tools: args.tools,
17777
17801
  preset: args.toolPreset,
@@ -17801,7 +17825,8 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17801
17825
  {
17802
17826
  capabilities: {
17803
17827
  tools: {}
17804
- }
17828
+ },
17829
+ instructions
17805
17830
  }
17806
17831
  );
17807
17832
  server.setRequestHandler(ListToolsRequestSchema, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mozilla/firefox-devtools-mcp-moz",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Model Context Protocol (MCP) server for Firefox DevTools automation (moz build with privileged context support)",
5
5
  "author": "Mozilla",
6
6
  "license": "MIT OR Apache-2.0",
@@ -1,6 +1,8 @@
1
1
  ---
2
+ name: debug
2
3
  description: Show console errors and failed network requests
3
4
  argument-hint: [console|network|all]
5
+ disable-model-invocation: true
4
6
  ---
5
7
 
6
8
  # /firefox-devtools-mcp:debug
@@ -2,6 +2,7 @@
2
2
  name: navigate
3
3
  description: Navigate Firefox to a URL and take a DOM snapshot for interaction
4
4
  argument-hint: <url>
5
+ disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # /firefox-devtools-mcp:navigate
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: screenshot
3
- description: Take a screenshot of a URL or the current page. Use when the user asks to capture, screenshot, or photograph a web page or URL.
3
+ description: Take a screenshot of a URL or the current page
4
4
  argument-hint: [url or uid]
5
+ disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # /firefox-devtools-mcp:screenshot
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: web-performance
3
+ description: Find and fix why a website is slow by capturing and analyzing a Firefox performance profile, or by analyzing a profile the user already has (a saved file or a profiler.firefox.com share link). Use for slow page loads, janky scrolling or animation, slow interactions or a slow STR, long tasks, layout thrashing, or heavy JavaScript.
4
+ ---
5
+
6
+ Work from a real profile, find the cause in the user's code, report it honestly, and if asked, fix it and prove the fix worked. The firefox-devtools MCP drives Firefox and records a profile when there is none yet; `profiler-cli` queries the profile, whether you captured it or the user brought it. Report in web-platform terms (LCP, main-thread blocking, reflow, render-blocking resources), not Gecko internals - unless the target is Firefox itself rather than a page, in which case platform frames are the subject and the fix lands in mozilla-central.
7
+
8
+ ## Step 0: Prerequisites
9
+
10
+ profiler-cli needs Node.js >= 24:
11
+ ```bash
12
+ command -v profiler-cli >/dev/null 2>&1 || npm install -g @firefox-devtools/profiler-cli@latest
13
+ ```
14
+ `npx @firefox-devtools/profiler-cli@latest` also works but re-resolves on every call.
15
+
16
+ The rest of this step is only for capturing. Skip it when the user already has a profile.
17
+
18
+ The profiler tools need **Firefox 154+**. Check with `get_firefox_info`, which also launches it. If Firefox is missing or older, stop and ask the user to install a current release or point the server at one with `--firefox-path`. The profiler tools also need the `developer` tool preset: if `profiler_start` is absent, ask the user to restart the server with `--tool-preset developer`, since the default `basic` preset has no profiler.
19
+
20
+ ## Step 1: Pick the scenario
21
+
22
+ **If the user already has a profile** - a saved `.json.gz`, or a `share.firefox.dev` / profiler.firefox.com link - there is nothing to capture: skip to Step 3 and `load` it directly. Ask what they were doing while it recorded, since the capture type decides the entry point in the playbook. Verifying a fix (Step 6) still needs a capture, so if you cannot reach their setup, hand them the recipe and ask for an after profile.
23
+
24
+ Otherwise ask if unclear: **page load**, **interaction / STR** (one slow action), or **ongoing jank** (scroll stutter, dropped frames, CPU pinning). Each has a recipe in `references/capture-recipes.md`.
25
+
26
+ ## Step 2: Capture
27
+
28
+ Follow that recipe. Keep the window tight - start late, stop early - and keep the profile path `profiler_stop` returns.
29
+
30
+ ## Step 3: Analyze
31
+
32
+ 1. Run `profiler-cli guide` and read the **entire** output; it is the command reference for everything below. Do not skim, and make sure that Bash did not truncate it.
33
+ 2. `profiler-cli load <path>`, or `profiler-cli load <share-url>` for a profile the user shared.
34
+ 3. Work through `references/analysis-playbook.md`: thread selection, the entry point for each capture type, and a symptom-to-command map.
35
+
36
+ Run the commands and interpret the output yourself; do not print commands for the user to run.
37
+
38
+ ## Step 4: Find the cause in the source
39
+
40
+ `Grep`/`Glob`/`Read` the project for the code behind a hot function or request, and confirm the mechanism. If you cannot connect a finding to real source, say so instead of inventing a code path.
41
+
42
+ ## Step 5: Report with explicit confidence
43
+
44
+ Label every finding:
45
+ - **Confirmed** - in the profile and cross-checked (stack traced to source, a measured before/after, a `performance.measure` you captured). State the evidence.
46
+ - **Likely** - strong single-source evidence, not cross-checked. Say what would confirm it.
47
+ - **Hypothesis** - a plausible reading of ambiguous data. Say so, and how to validate it.
48
+
49
+ If sampling is sparse, the window was wrong, or the time is mostly idle, call it inconclusive and re-capture instead of guessing.
50
+
51
+ Per finding: what is slow -> evidence (function/marker/request and its cost) plus confidence -> why -> the fix in the developer's terms. Biggest confirmed wins first.
52
+
53
+ When a "likely" or "hypothesis" finding is worth acting on, get more evidence first. `references/validation.md` covers instrumenting the page with User Timing and reading navigation, resource, paint, LCP and Event Timing entries.
54
+
55
+ ## Step 6: Fix and verify
56
+
57
+ Implement only if asked, keeping the change minimal and tied to the confirmed finding. Then re-capture like-for-like (see the before/after section of the recipes) and compare the metric that was slow. Quantify the gain; if it did not improve, or something else regressed, say so and reconsider.
58
+
59
+ ## Step 7: Clean up
60
+
61
+ `profiler-cli stop` - the daemon holds a port and memory until stopped. Stop the Firefox profiler if `profiler_is_active`, and remove instrumentation you injected unless the user wants it kept.
@@ -0,0 +1,62 @@
1
+ # Analysis playbook
2
+
3
+ Turning a loaded profile into web-platform findings. Assumes `profiler-cli guide` has been read in full (it documents the flags used here) and the profile is loaded.
4
+
5
+ ## Select the right thread
6
+
7
+ Start on the content process main thread: the `GeckoMain` thread of the `Isolated Web Content [<url>]` process serving the page. Most of the script, layout and paint work a user feels is there. Cross-origin iframes get their own content process, so third-party work (ads, embeds) sits under a different one; worker cost sits on `DOM Worker` threads.
8
+
9
+ That is where to start, not where to stop. Depending on the problem:
10
+
11
+ - **Network.** The request is not content-process work at all. `GeckoMain` in the parent process drives navigation and channel setup, and `Socket Thread` does connection, TLS and transfer, with `DNS Resolver`, `TRR Background` and `Cache2 I/O` next to it - in the parent, or in a separate `Socket` process when the build runs one. A content thread sitting idle while requests are in flight means the answer is on those threads.
12
+ - **Scrolling, animation, dropped frames.** `Compositor`, `Renderer` and the GPU-process threads, alongside the content main thread.
13
+ - **Firefox itself as the target.** Any platform thread can be the subject, most often parent `GeckoMain`.
14
+
15
+ A thread is only in the profile if the capture recorded it: the `web-developer` preset leaves out the networking threads, so a network question usually needs a re-capture with `preset="networking"`.
16
+
17
+ ## Start here
18
+
19
+ **Page-load captures:** `thread page-load` gives navigation timing, FCP/LCP, resources, CPU and jank periods in one view, and its marker handles feed `zoom push` and `marker info`. "No page load markers found in this thread" means the capture has no navigation or the thread is wrong - it is not a verdict about the page.
20
+
21
+ **Interaction and jank captures** never navigate, so `page-load` returns nothing for them. Enter through markers:
22
+
23
+ ```
24
+ profiler-cli thread markers --min-duration 50 --list # long intervals, chronological
25
+ profiler-cli zoom push m-<handle> # onto the worst one
26
+ profiler-cli thread samples-top-down # what ran during it
27
+ ```
28
+
29
+ Blocking main-thread work appears as a long `Runnable`, or `Perform microtasks`. `DOMEvent` markers locate the interaction. Ignore long-lived span markers that are not blocking work: `Image Animation` (one animating GIF spans the whole recording), `IPC Accumulator`. `--has-stack` with `marker stack <handle>` gives the stack that belongs to a marker.
30
+
31
+ ## Minified bundles: apply the source map first
32
+
33
+ If JS frames show mangled names (`a`, `t.exports`), de-minify before reading stacks.
34
+
35
+ ```
36
+ profiler-cli sourcemap sources # bundles with a source map, as src-N, plus their sourceMapURL
37
+ profiler-cli sourcemap apply dist/bundle.js.map
38
+ ```
39
+
40
+ `apply` reads a local file, it does not fetch: take the `.map` from the user's build output, and if `sources` shows a remote `sourceMapURL`, download it first. It rewrites stacks in place, so apply before reading call trees. The guide's SOURCE MAPS section covers `--to src-N` and ambiguous matches. With no map, keep findings at function level - mangled names do not support line-level claims.
41
+
42
+ ## Symptom -> where to look
43
+
44
+ **Do not limit yourself to the categories below.** They are the common cases, not a list of the problems a profile can answer. The data decides what the problem is; if the symptom does not match any of them, or the profile points somewhere else, follow the profile. Forcing a finding into one of these buckets is how you end up reporting the wrong cause.
45
+
46
+ **Slow first paint / LCP / content appears late.** `thread page-load` for which phase dominates. Before first byte is server or network; between response and paint is client work. `thread markers` shows when paint, DOMContentLoaded and load actually fired.
47
+
48
+ **Long tasks / blocked main thread / unresponsive UI.** Take long tasks from the profile: `page-load` jank periods for a navigation capture, the marker route above otherwise. `samples-bottom-up` complements the top-down tree by showing hot leaf functions and their callers. If the hot frames are framework internals (render, reconcile, hydrate), look for too many or too expensive components rather than one slow function.
49
+
50
+ **Heavy JavaScript.** `thread functions` for a flat list by CPU percentage, `samples-top-down` for the tree, `function annotate <handle>` for per-line timing, `function expand <handle>` for a truncated name.
51
+
52
+ **Layout thrashing / forced reflow.** In `samples-top-down`, look for reflow/layout/style-recalc frames interleaved with script - that pattern is a script reading layout, writing, then reading again, forcing synchronous reflow. `thread markers` shows how often layout and reflow markers fire. Then find the read-write-read loop in the source.
53
+
54
+ **Slow or render-blocking network.** `thread network` gives per-request timing phases (DNS, connect, TLS, wait, download) for the selected thread. Look for render-blocking CSS/JS in the head, long TTFB, request chains where each waits on the previous, and duplicate downloads. When the cost is in the transfer itself rather than in how the page requested it, follow it onto the parent's `GeckoMain` and `Socket Thread` as described above. The profile has timing but not response headers, so for `content-encoding`, `cache-control` and `content-type` - what tells you whether an asset is actually compressed or cacheable - use the MCP's `list_network_requests` and `get_network_request`.
55
+
56
+ **User Timing.** `thread markers` shows the developer's `performance.mark`/`measure` alongside platform markers; `marker info` and `marker stack` give one marker's detail and its stack.
57
+
58
+ **Anything else.** Let the profile pick the direction: `profile info` for which process and thread actually burned CPU or stalled, then markers and samples on that thread.
59
+
60
+ ## Before recommending a fix
61
+
62
+ Correlate the hot stack with markers and network timing. A stack alone says what ran, rarely why it ran.
@@ -0,0 +1,53 @@
1
+ # Capture recipes
2
+
3
+ Record the right profile per scenario. If a capture misses the slow moment or is mostly idle, re-capture instead of salvaging it.
4
+
5
+ Applies to every recipe:
6
+ - Firefox launches lazily on the first MCP call (`list_pages`, `get_firefox_info`). Reuse the existing session and tab; only `new_page` / `navigate_page` for a different page or a clean state.
7
+ - `preset="web-developer"` is the usual choice for `profiler_start`, but it does not record the networking threads. A network-shaped problem (slow TTFB, connection or TLS setup, DNS, cache misses) might want `networking` instead. Use `firefox-platform` when the target is Firefox itself, another preset when the problem sits squarely in its domain, or explicit `entries`/`interval`/`features`/`threads` when none of them fit.
8
+ - `profiler_stop` saves to Firefox's downloads directory and returns the path. Keep it.
9
+ - If nothing records, check `profiler_is_active` and confirm Firefox is 154+.
10
+
11
+ ## Page load
12
+
13
+ The recording should span one navigation with nothing before it.
14
+
15
+ 1. `navigate_page url="about:blank"`, so the navigation is captured from the start.
16
+ 2. `profiler_start`.
17
+ 3. `navigate_page url="https://the-page"` - this navigation is the whole recording.
18
+ 4. Wait for the page to finish, then stop promptly without recording an idle tail. There is no wait tool, so poll: `evaluate_script function="() => [document.readyState, performance.getEntriesByType('navigation')[0]?.loadEventEnd]"`, or `screenshot_page`.
19
+ 5. `profiler_stop`.
20
+
21
+ Caching caveat: this is a clean navigation but not a cold load. The session shares Firefox's HTTP cache, and `about:blank` or a new tab does not clear it. Neither can you: there is no cache-clearing or private-window tool. So either:
22
+ - label the finding a warm-cache load, or
23
+ - for a real first visit, `restart_firefox` with `profilePath` set to a fresh empty directory - a new profile starts with an empty cache, but it closes every tab and drops cookies and logins, so it is no good for authenticated pages.
24
+
25
+ Confirm which one you got instead of assuming: in `thread network`, a cold load shows real DNS/connect/download phases for subresources, a warm one near-zero fetch time.
26
+
27
+ Warm variant (repeat visit): navigate to the URL and let it settle first, then start the profiler and navigate to it again. The second navigation is the measured one.
28
+
29
+ ## Interaction or STR
30
+
31
+ Record the action, not the setup.
32
+
33
+ 1. `navigate_page` to the state right before the slow action. Do NOT perform it yet.
34
+ 2. `take_snapshot` to resolve the UIDs you will need, so the recorded window is only the action.
35
+ 3. `profiler_start`.
36
+ 4. Perform exactly the steps the user reports as slow, in order, with the automation tools.
37
+ 5. `profiler_stop` as soon as the slow result is visible.
38
+
39
+ If the DOM changed and you need a fresh snapshot mid-recording, `take_snapshot` again; it does not meaningfully pollute the profile.
40
+
41
+ ## Ongoing jank (scroll, animation, CPU pinning)
42
+
43
+ 1. `navigate_page` to the janky page and state.
44
+ 2. `profiler_start`.
45
+ 3. Reproduce the jank for a few representative seconds. Drive the scrolling if you can, otherwise ask the user to scroll while recording.
46
+ 4. `profiler_stop`.
47
+
48
+ ## Re-capturing to verify a fix
49
+
50
+ The second capture must be like-for-like or the comparison is meaningless:
51
+ - Same recipe, URL, STR steps and window length.
52
+ - Same cache warmth and page state; a cold-vs-warm difference will swamp the fix.
53
+ - Ideally the same `performance.mark`/`measure` instrumentation (see `validation.md`), so you compare the same measured span rather than two eyeballed windows.
@@ -0,0 +1,35 @@
1
+ # Validation: turning guesses into measurements
2
+
3
+ Use this when a "likely" or "hypothesis" finding matters enough to act on, or to get a reliable metric for a before/after comparison. Instrumentation goes into the user's source where there is source to edit, and through `evaluate_script` otherwise.
4
+
5
+ ## Bracket the operation with User Timing
6
+
7
+ Marks and measures appear both as markers in the next captured profile and via `performance.getEntriesByType('measure')`.
8
+
9
+ **If the user's source is available, put the marks there instead.** They then measure only the operation, can wrap code you cannot reach from the outside, survive a navigation, and are still in place for the after-fix capture, so both runs measure the same span.
10
+
11
+ With no source to edit, bracket from outside while the profiler records:
12
+
13
+ 1. `performance.mark('before')`, then perform the STR step with the automation tools.
14
+ 2. `performance.mark('after')` and `performance.measure('op', 'before', 'after')`.
15
+ 3. Read back with `evaluate_script function="() => performance.getEntriesByType('measure').map(m => ({name: m.name, dur: m.duration}))"`, and find the same span in the profile with `thread markers --search op`.
16
+
17
+ Those are three separate tool calls, so the measure also contains the MCP round-trips and your own thinking time between them. Treat it as a way to locate the span in the profile, not as the operation's cost.
18
+
19
+ A measured duration matching the sampled cost confirms the finding; a mismatch means sampling misattributed it, which is common with inlined or minified code.
20
+
21
+ ## LCP and Event Timing
22
+
23
+ Firefox might not support every entry type Chrome does, and `observe()` ignores an unsupported one with a console warning and no exception - you get an empty result that reads like a clean bill of health. Check before relying on one, rather than assuming the Chrome set:
24
+
25
+ `evaluate_script function="() => PerformanceObserver.supportedEntryTypes"`
26
+
27
+ Anything missing from that list has to come from the profile instead.
28
+
29
+ An observer needs a document, and `evaluate_script` runs in the *current* one - a navigation destroys it along with anything on `window`. For a page load, install the observer *after* the navigation settles; `buffered: true` replays entries that already fired, so nothing is missed. For an STR, install it before the action.
30
+
31
+ ```
32
+ evaluate_script function="() => { window.__perf = {lcp: 0, events: []}; new PerformanceObserver(l => { for (const e of l.getEntries()) window.__perf.lcp = e.startTime; }).observe({type: 'largest-contentful-paint', buffered: true}); new PerformanceObserver(l => { for (const e of l.getEntries()) window.__perf.events.push({name: e.name, dur: e.duration}); }).observe({type: 'event', buffered: true, durationThreshold: 16}); }"
33
+ ```
34
+
35
+ Read back with `evaluate_script function="() => window.__perf"`. The last LCP entry wins; `event` entries give per-interaction latency.