@mozilla/firefox-devtools-mcp 0.9.15 → 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/index.js CHANGED
@@ -51,6 +51,13 @@ function previewExcerpt(text, preview) {
51
51
  }
52
52
  return truncateText(text, Math.min(preview, TOKEN_LIMITS.MAX_RESPONSE_CHARS), "...");
53
53
  }
54
+ function truncationFooter(hiddenCount, unit, hints) {
55
+ if (hiddenCount <= 0) {
56
+ return "";
57
+ }
58
+ const head = `[+${hiddenCount} ${unit} hidden`;
59
+ return hints.length > 0 ? `${head}; ${hints.join(", ")}]` : `${head}]`;
60
+ }
54
61
  function truncateHeaders(headers) {
55
62
  if (!headers) {
56
63
  return null;
@@ -124,21 +131,6 @@ var init_response_helpers = __esm({
124
131
  }
125
132
  });
126
133
 
127
- // src/tools/module.ts
128
- function defineModule(config2) {
129
- return {
130
- name: config2.name,
131
- description: config2.description,
132
- ...config2.privileged ? { privileged: true } : {},
133
- tools: config2.tools.map(([definition, handler]) => ({ definition, handler }))
134
- };
135
- }
136
- var init_module = __esm({
137
- "src/tools/module.ts"() {
138
- "use strict";
139
- }
140
- });
141
-
142
134
  // node_modules/zod/v4/core/core.js
143
135
  // @__NO_SIDE_EFFECTS__
144
136
  function $constructor(name, initializer3, params) {
@@ -14849,7 +14841,7 @@ var init_constants = __esm({
14849
14841
  "src/config/constants.ts"() {
14850
14842
  "use strict";
14851
14843
  SERVER_NAME = true ? "@mozilla/firefox-devtools-mcp" : "firefox-devtools";
14852
- SERVER_VERSION = true ? "0.9.15" : "dev";
14844
+ SERVER_VERSION = true ? "0.10.1" : "dev";
14853
14845
  }
14854
14846
  });
14855
14847
 
@@ -14970,7 +14962,16 @@ var init_profile = __esm({
14970
14962
  // src/firefox/core.ts
14971
14963
  import { Builder, Browser, Capabilities } from "selenium-webdriver";
14972
14964
  import firefox from "selenium-webdriver/firefox.js";
14973
- import { mkdirSync as mkdirSync2, openSync, closeSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
14965
+ import {
14966
+ mkdirSync as mkdirSync2,
14967
+ openSync,
14968
+ closeSync,
14969
+ existsSync as existsSync2,
14970
+ readdirSync,
14971
+ statSync,
14972
+ readFileSync
14973
+ } from "fs";
14974
+ import { connect as netConnect } from "net";
14974
14975
  import { homedir } from "os";
14975
14976
  import { join as join2, delimiter } from "path";
14976
14977
  function findGeckodriverInPath(binaryName) {
@@ -15016,6 +15017,57 @@ async function findGeckodriverInNpmPackage() {
15016
15017
  return null;
15017
15018
  }
15018
15019
  }
15020
+ function lookupMarionettePort() {
15021
+ const instancesDir = join2(homedir(), ".firefox-devtools-mcp", "instances");
15022
+ if (!existsSync2(instancesDir)) {
15023
+ logDebug(`Failed to lookup Marionette port: ${instancesDir} doesn't exist.`);
15024
+ return;
15025
+ }
15026
+ const files = readdirSync(instancesDir);
15027
+ const portFiles = files.filter((f) => /^\d+\.port$/.test(f));
15028
+ const mostRecent = portFiles.map((f) => ({
15029
+ name: f,
15030
+ path: join2(instancesDir, f),
15031
+ mtime: statSync(join2(instancesDir, f)).mtimeMs
15032
+ })).sort((a, b) => b.mtime - a.mtime)[0];
15033
+ if (mostRecent) {
15034
+ const pid = Number(mostRecent.name.substring(0, mostRecent.name.length - 5));
15035
+ if (!checkProcess(pid)) {
15036
+ logDebug(`Failed to lookup Marionette port: No process with PID ${pid} is running.`);
15037
+ return;
15038
+ }
15039
+ logDebug(`Reading Marionette port from ${mostRecent.path}`);
15040
+ const content = readFileSync(mostRecent.path, "utf-8").trim();
15041
+ if (/^\d+$/.test(content)) {
15042
+ return Number(content);
15043
+ } else {
15044
+ logDebug(`Failed to lookup Marionette port: "${content}" is not a number.`);
15045
+ }
15046
+ } else {
15047
+ logDebug(`Failed to lookup Marionette port: No port file found in ${instancesDir}.`);
15048
+ }
15049
+ }
15050
+ function checkProcess(pid) {
15051
+ try {
15052
+ process.kill(pid, 0);
15053
+ return true;
15054
+ } catch {
15055
+ return false;
15056
+ }
15057
+ }
15058
+ function checkPort(port, timeoutMs = 1e3) {
15059
+ return new Promise((resolve4) => {
15060
+ const socket = netConnect({ host: "127.0.0.1", port });
15061
+ const done = (reason) => {
15062
+ socket.destroy();
15063
+ resolve4(reason);
15064
+ };
15065
+ socket.setTimeout(timeoutMs);
15066
+ socket.once("connect", () => done(null));
15067
+ socket.once("timeout", () => done(`connection to 127.0.0.1:${port} timed out`));
15068
+ socket.once("error", (error2) => done(error2.message));
15069
+ });
15070
+ }
15019
15071
  async function findGeckodriver() {
15020
15072
  const ext = process.platform === "win32" ? ".exe" : "";
15021
15073
  const binaryName = `geckodriver${ext}`;
@@ -15047,8 +15099,15 @@ var init_core3 = __esm({
15047
15099
  */
15048
15100
  async connect() {
15049
15101
  const isAndroid = this.options.androidDevice !== void 0;
15102
+ const androidPackage = this.options.androidPackage ?? "org.mozilla.firefox";
15103
+ if (isAndroid && !this.options.androidWipeAppData) {
15104
+ throw new Error(
15105
+ `Firefox for Android mode wipes all data of ${androidPackage} on the device: tabs, history, bookmarks, passwords, cookies and settings are all lost, because geckodriver clears the app data before every session and cannot be configured to skip it. Pass --android-wipe-app-data (or ANDROID_WIPE_APP_DATA=true) to confirm. Prefer a build dedicated to automation, for instance --android-package org.mozilla.fenix for Nightly.`
15106
+ );
15107
+ }
15050
15108
  if (isAndroid) {
15051
15109
  log("Launching Firefox for Android via ADB...");
15110
+ log(`Wiping all data of ${androidPackage} on the device`);
15052
15111
  } else if (this.options.connectExisting) {
15053
15112
  log("Connecting to existing Firefox via Marionette...");
15054
15113
  } else {
@@ -15057,8 +15116,7 @@ var init_core3 = __esm({
15057
15116
  if (isAndroid) {
15058
15117
  const geckodriverPath = await findGeckodriver();
15059
15118
  logDebug(`Using geckodriver: ${geckodriverPath}`);
15060
- const pkg = this.options.androidPackage ?? "org.mozilla.firefox";
15061
- const mozOptions = { androidPackage: pkg };
15119
+ const mozOptions = { androidPackage };
15062
15120
  const deviceSerial = this.options.androidDevice;
15063
15121
  if (deviceSerial && deviceSerial !== "auto") {
15064
15122
  mozOptions.androidDeviceSerial = deviceSerial;
@@ -15075,7 +15133,31 @@ var init_core3 = __esm({
15075
15133
  const serviceBuilder = new firefox.ServiceBuilder(geckodriverPath);
15076
15134
  this.driver = firefox.Driver.createSession(caps, serviceBuilder.build());
15077
15135
  } else if (this.options.connectExisting) {
15078
- const port = this.options.marionettePort ?? 2828;
15136
+ let port = this.options.marionettePort ?? 2828;
15137
+ if (this.options.lookupMarionettePort) {
15138
+ logDebug("Looking up Marionette port");
15139
+ const lookedUpPort = lookupMarionettePort();
15140
+ if (lookedUpPort !== void 0) {
15141
+ port = lookedUpPort;
15142
+ } else {
15143
+ throw new Error(
15144
+ "Marionette port not found: please enable Firefox remote control for AI tooling using the AI assistant companion button."
15145
+ );
15146
+ }
15147
+ }
15148
+ logDebug(`Using Marionette port ${port}`);
15149
+ const failure = await checkPort(port);
15150
+ if (failure) {
15151
+ if (this.options.lookupMarionettePort) {
15152
+ throw new Error(
15153
+ `No Marionette listener on 127.0.0.1:${port} (${failure}). Please enable Firefox remote control for AI tooling using the AI assistant companion button.`
15154
+ );
15155
+ } else {
15156
+ throw new Error(
15157
+ `No Marionette listener on 127.0.0.1:${port} (${failure}). Start Firefox with both flags: firefox --marionette --remote-debugging-port, or pass the port it is actually using via --marionette-port.`
15158
+ );
15159
+ }
15160
+ }
15079
15161
  const geckodriverPath = await findGeckodriver();
15080
15162
  logDebug(`Using geckodriver: ${geckodriverPath}`);
15081
15163
  const serviceBuilder = new firefox.ServiceBuilder(geckodriverPath);
@@ -15266,68 +15348,6 @@ var init_core3 = __esm({
15266
15348
  getOptions() {
15267
15349
  return this.options;
15268
15350
  }
15269
- /**
15270
- * Wait for WebSocket to be in OPEN state
15271
- */
15272
- async waitForWebSocketOpen(ws, timeout = 5e3) {
15273
- if (ws.readyState === 1) {
15274
- return;
15275
- }
15276
- if (ws.readyState === 0) {
15277
- return new Promise((resolve4, reject) => {
15278
- const timeoutId = setTimeout(() => {
15279
- ws.off("open", onOpen);
15280
- reject(new Error("Timeout waiting for WebSocket to open"));
15281
- }, timeout);
15282
- const onOpen = () => {
15283
- clearTimeout(timeoutId);
15284
- ws.off("open", onOpen);
15285
- resolve4();
15286
- };
15287
- ws.on("open", onOpen);
15288
- });
15289
- }
15290
- throw new Error(`WebSocket is not open: readyState ${ws.readyState}`);
15291
- }
15292
- /**
15293
- * Send raw BiDi command and get response
15294
- */
15295
- async sendBiDiCommand(method, params = {}) {
15296
- if (!this.driver) {
15297
- throw new Error("Driver not connected");
15298
- }
15299
- const bidi = await this.driver.getBidi();
15300
- const ws = bidi.socket;
15301
- await this.waitForWebSocketOpen(ws);
15302
- const id = Math.floor(Math.random() * 1e6);
15303
- return new Promise((resolve4, reject) => {
15304
- const messageHandler = (data) => {
15305
- try {
15306
- const payload = JSON.parse(data.toString());
15307
- if (payload.id === id) {
15308
- ws.off("message", messageHandler);
15309
- if (payload.error) {
15310
- reject(new Error(`BiDi error: ${JSON.stringify(payload.error)}`));
15311
- } else {
15312
- resolve4(payload.result);
15313
- }
15314
- }
15315
- } catch {
15316
- }
15317
- };
15318
- ws.on("message", messageHandler);
15319
- const command = {
15320
- id,
15321
- method,
15322
- params
15323
- };
15324
- ws.send(JSON.stringify(command));
15325
- setTimeout(() => {
15326
- ws.off("message", messageHandler);
15327
- reject(new Error(`BiDi command timeout: ${method}`));
15328
- }, 1e4);
15329
- });
15330
- }
15331
15351
  /**
15332
15352
  * Close driver and cleanup.
15333
15353
  * - Tries graceful quit() with a timeout; on timeout, force-kills via onQuit_().
@@ -15397,6 +15417,168 @@ var init_core3 = __esm({
15397
15417
  }
15398
15418
  });
15399
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
+
15521
+ // src/utils/remote-value.ts
15522
+ function remoteValueToNative(rv) {
15523
+ if (!rv || typeof rv !== "object") {
15524
+ return rv;
15525
+ }
15526
+ const { type, value } = rv;
15527
+ switch (type) {
15528
+ case "undefined":
15529
+ return void 0;
15530
+ case "null":
15531
+ return null;
15532
+ case "string":
15533
+ case "boolean":
15534
+ return value;
15535
+ case "number":
15536
+ if (value === "NaN") {
15537
+ return "NaN";
15538
+ }
15539
+ if (value === "Infinity") {
15540
+ return "Infinity";
15541
+ }
15542
+ if (value === "-Infinity") {
15543
+ return "-Infinity";
15544
+ }
15545
+ if (value === "-0") {
15546
+ return "-0";
15547
+ }
15548
+ return value;
15549
+ case "bigint":
15550
+ return `${value}n`;
15551
+ case "array":
15552
+ return value.map(remoteValueToNative);
15553
+ case "object":
15554
+ return Object.fromEntries(
15555
+ value.map(([k, v]) => [k, remoteValueToNative(v)])
15556
+ );
15557
+ case "map":
15558
+ return Object.fromEntries(
15559
+ value.map(([k, v]) => [
15560
+ typeof k === "object" ? JSON.stringify(remoteValueToNative(k)) : String(k),
15561
+ remoteValueToNative(v)
15562
+ ])
15563
+ );
15564
+ case "set":
15565
+ return value.map(remoteValueToNative);
15566
+ case "regexp": {
15567
+ const { pattern, flags } = value;
15568
+ return `/${pattern}/${flags ?? ""}`;
15569
+ }
15570
+ case "date":
15571
+ return value;
15572
+ default:
15573
+ return `[${type}]`;
15574
+ }
15575
+ }
15576
+ var init_remote_value = __esm({
15577
+ "src/utils/remote-value.ts"() {
15578
+ "use strict";
15579
+ }
15580
+ });
15581
+
15400
15582
  // src/firefox/events/console.ts
15401
15583
  var MAX_CONSOLE_MESSAGES, CONSOLE_TTL_MS, ConsoleEvents;
15402
15584
  var init_console = __esm({
@@ -15406,8 +15588,8 @@ var init_console = __esm({
15406
15588
  MAX_CONSOLE_MESSAGES = 1e3;
15407
15589
  CONSOLE_TTL_MS = 5 * 60 * 1e3;
15408
15590
  ConsoleEvents = class {
15409
- constructor(driver, options = {}) {
15410
- this.driver = driver;
15591
+ constructor(bidi, options = {}) {
15592
+ this.bidi = bidi;
15411
15593
  this.options = {
15412
15594
  autoClearOnNavigate: false,
15413
15595
  // Changed default to false to preserve logs across tabs
@@ -15420,47 +15602,27 @@ var init_console = __esm({
15420
15602
  /**
15421
15603
  * Subscribe to BiDi console events and navigation lifecycle
15422
15604
  */
15423
- async subscribe(contextId) {
15605
+ async subscribe() {
15424
15606
  if (this.subscribed) {
15425
15607
  return;
15426
15608
  }
15427
- const bidi = await this.driver.getBidi();
15428
- await bidi.subscribe("log.entryAdded", contextId ? [contextId] : void 0);
15429
- try {
15430
- await bidi.subscribe("browsingContext.load", contextId ? [contextId] : void 0);
15431
- await bidi.subscribe("browsingContext.domContentLoaded", contextId ? [contextId] : void 0);
15432
- } catch {
15433
- logDebug(
15434
- "Navigation events subscription skipped (may not be available in this Firefox version)"
15435
- );
15436
- }
15437
- const ws = bidi.socket;
15438
- ws.on("message", (data) => {
15439
- try {
15440
- const payload = JSON.parse(data.toString());
15441
- if (payload?.method === "log.entryAdded") {
15442
- const entry = payload.params;
15443
- const message = {
15444
- level: entry.level || "info",
15445
- text: entry.text || (entry.args ? JSON.stringify(entry.args) : ""),
15446
- timestamp: entry.timestamp || Date.now(),
15447
- source: entry.source?.realm,
15448
- args: entry.args
15449
- };
15450
- this.consoleMessages.push(message);
15451
- logDebug(`Console [${message.level}]: ${message.text}`);
15452
- }
15453
- if (payload?.method === "browsingContext.load" || payload?.method === "browsingContext.domContentLoaded") {
15454
- if (this.options.autoClearOnNavigate) {
15455
- this.clearMessages();
15456
- }
15457
- if (this.options.onNavigate) {
15458
- this.options.onNavigate();
15459
- }
15460
- }
15461
- } catch {
15462
- }
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}`);
15463
15621
  });
15622
+ if (this.options.autoClearOnNavigate) {
15623
+ this.bidi.on("browsingContext.load", () => this.clearMessages());
15624
+ this.bidi.on("browsingContext.domContentLoaded", () => this.clearMessages());
15625
+ }
15464
15626
  this.subscribed = true;
15465
15627
  logDebug("Console listener active with lifecycle hooks");
15466
15628
  }
@@ -15499,16 +15661,17 @@ var init_console = __esm({
15499
15661
  });
15500
15662
 
15501
15663
  // src/firefox/events/network.ts
15502
- var MAX_NETWORK_REQUESTS, NETWORK_TTL_MS, NetworkEvents;
15664
+ var MAX_NETWORK_REQUESTS, NETWORK_TTL_MS, MAX_ENCODED_DATA_SIZE, NetworkEvents;
15503
15665
  var init_network = __esm({
15504
15666
  "src/firefox/events/network.ts"() {
15505
15667
  "use strict";
15506
15668
  init_logger();
15507
15669
  MAX_NETWORK_REQUESTS = 1e3;
15508
15670
  NETWORK_TTL_MS = 5 * 60 * 1e3;
15671
+ MAX_ENCODED_DATA_SIZE = 10 * 1e3 * 1e3;
15509
15672
  NetworkEvents = class {
15510
- constructor(driver, options = {}) {
15511
- this.driver = driver;
15673
+ constructor(bidi, options = {}) {
15674
+ this.bidi = bidi;
15512
15675
  this.options = {
15513
15676
  autoClearOnNavigate: true,
15514
15677
  ...options
@@ -15519,102 +15682,155 @@ var init_network = __esm({
15519
15682
  enabled = false;
15520
15683
  requestStartTimes = /* @__PURE__ */ new Map();
15521
15684
  options;
15685
+ collectorId = null;
15522
15686
  /**
15523
15687
  * Subscribe to BiDi network events and navigation lifecycle
15524
15688
  * Enables monitoring by default (always-on capture)
15525
15689
  */
15526
- async subscribe(contextId) {
15690
+ async subscribe() {
15527
15691
  if (this.subscribed) {
15528
15692
  return;
15529
15693
  }
15530
- const bidi = await this.driver.getBidi();
15531
- await bidi.subscribe("network.beforeRequestSent", contextId ? [contextId] : void 0);
15532
- await bidi.subscribe("network.responseStarted", contextId ? [contextId] : void 0);
15533
- await bidi.subscribe("network.responseCompleted", contextId ? [contextId] : void 0);
15534
- try {
15535
- await bidi.subscribe("browsingContext.load", contextId ? [contextId] : void 0);
15536
- await bidi.subscribe("browsingContext.domContentLoaded", contextId ? [contextId] : void 0);
15537
- } catch {
15538
- logDebug(
15539
- "Navigation events subscription skipped (may not be available in this Firefox version)"
15540
- );
15541
- }
15542
- const ws = bidi.socket;
15543
- ws.on("message", (data) => {
15544
- try {
15545
- const payload = JSON.parse(data.toString());
15546
- if (payload?.method === "browsingContext.load" || payload?.method === "browsingContext.domContentLoaded") {
15547
- if (this.enabled && this.options.autoClearOnNavigate) {
15548
- this.clearRequests();
15549
- }
15550
- if (this.options.onNavigate) {
15551
- this.options.onNavigate();
15552
- }
15553
- return;
15554
- }
15555
- if (!this.enabled) {
15556
- return;
15557
- }
15558
- if (payload?.method === "network.beforeRequestSent") {
15559
- const req = payload.params;
15560
- const requestId = req.request?.request || req.requestId;
15561
- if (!requestId) {
15562
- return;
15563
- }
15564
- this.requestStartTimes.set(requestId, Date.now());
15565
- const record2 = {
15566
- id: requestId,
15567
- url: req.request?.url || "",
15568
- method: req.request?.method || "GET",
15569
- timestamp: Date.now(),
15570
- resourceType: this.guessResourceType(req.request?.url || ""),
15571
- isXHR: req.initiator?.type === "xmlhttprequest" || req.initiator?.type === "fetch",
15572
- requestHeaders: this.parseHeaders(req.request?.headers || []),
15573
- timings: {
15574
- requestTime: Date.now()
15575
- }
15576
- };
15577
- this.networkRecords.set(requestId, record2);
15578
- logDebug(`Network request [${record2.method}]: ${record2.url}`);
15579
- }
15580
- if (payload?.method === "network.responseStarted") {
15581
- const resp = payload.params;
15582
- const requestId = resp.request?.request || resp.requestId;
15583
- if (!requestId) {
15584
- return;
15585
- }
15586
- const existing = this.networkRecords.get(requestId);
15587
- if (existing) {
15588
- existing.status = resp.response?.status;
15589
- existing.statusText = resp.response?.statusText || "";
15590
- existing.responseHeaders = this.parseHeaders(resp.response?.headers || []);
15591
- }
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()
15592
15726
  }
15593
- if (payload?.method === "network.responseCompleted") {
15594
- const resp = payload.params;
15595
- const requestId = resp.request?.request || resp.requestId;
15596
- if (!requestId) {
15597
- return;
15598
- }
15599
- const existing = this.networkRecords.get(requestId);
15600
- const startTime = this.requestStartTimes.get(requestId);
15601
- if (existing && startTime) {
15602
- existing.timings.responseTime = Date.now();
15603
- existing.timings.duration = Date.now() - startTime;
15604
- if (!existing.status && resp.response?.status) {
15605
- existing.status = resp.response.status;
15606
- existing.statusText = resp.response.statusText || "";
15607
- }
15608
- }
15609
- 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 || "";
15610
15762
  }
15611
- } catch {
15612
15763
  }
15764
+ this.requestStartTimes.delete(requestId);
15613
15765
  });
15766
+ await this.registerDataCollector();
15614
15767
  this.subscribed = true;
15615
15768
  this.enabled = true;
15616
15769
  logDebug("Network listener ready with lifecycle hooks (monitoring enabled by default)");
15617
15770
  }
15771
+ /**
15772
+ * Register a BiDi network data collector for request and response bodies.
15773
+ * Failures are non-fatal and simply leave body capture disabled.
15774
+ */
15775
+ async registerDataCollector() {
15776
+ if (this.options.captureBodies === false) {
15777
+ logDebug("Network body capture disabled, skipping data collector registration");
15778
+ return;
15779
+ }
15780
+ try {
15781
+ const result = await this.bidi.sendCommand("network.addDataCollector", {
15782
+ dataTypes: ["request", "response"],
15783
+ maxEncodedDataSize: MAX_ENCODED_DATA_SIZE
15784
+ });
15785
+ this.collectorId = result?.collector ?? null;
15786
+ if (this.collectorId) {
15787
+ logDebug(`Network data collector registered (${this.collectorId})`);
15788
+ }
15789
+ } catch (error2) {
15790
+ logDebug(
15791
+ `Network data collector unavailable, response bodies will not be captured: ${error2 instanceof Error ? error2.message : String(error2)}`
15792
+ );
15793
+ }
15794
+ }
15795
+ /**
15796
+ * Fetch a captured request or response body via network.getData.
15797
+ * Returns a structured result so callers can render an appropriate marker
15798
+ * when the body was never collected, evicted, or the browser lacks support.
15799
+ */
15800
+ async fetchBody(requestId, dataType) {
15801
+ if (!this.collectorId) {
15802
+ return { ok: false, reason: "unsupported" };
15803
+ }
15804
+ try {
15805
+ const result = await this.bidi.sendCommand("network.getData", {
15806
+ request: requestId,
15807
+ dataType
15808
+ });
15809
+ const bytes = result?.bytes;
15810
+ if (!bytes || typeof bytes.value !== "string") {
15811
+ return { ok: false, reason: "not-collected" };
15812
+ }
15813
+ return {
15814
+ ok: true,
15815
+ type: bytes.type === "base64" ? "base64" : "string",
15816
+ value: bytes.value
15817
+ };
15818
+ } catch (error2) {
15819
+ const message = (error2 instanceof Error ? error2.message : String(error2)).toLowerCase();
15820
+ if (message.includes("no such network data")) {
15821
+ return { ok: false, reason: "not-collected" };
15822
+ }
15823
+ if (message.includes("unavailable network data")) {
15824
+ if (message.includes("evicted")) {
15825
+ return { ok: false, reason: "evicted" };
15826
+ }
15827
+ if (message.includes("aborted")) {
15828
+ return { ok: false, reason: "aborted" };
15829
+ }
15830
+ }
15831
+ return { ok: false, reason: "error" };
15832
+ }
15833
+ }
15618
15834
  /**
15619
15835
  * Start collecting network requests
15620
15836
  */
@@ -15768,46 +15984,35 @@ var init_debugging = __esm({
15768
15984
  init_logger();
15769
15985
  MAX_LOGPOINT_RESULTS = 100;
15770
15986
  DebuggingEvents = class {
15771
- constructor(driver, sendBiDiCommand) {
15772
- this.driver = driver;
15773
- this.sendBiDiCommand = sendBiDiCommand;
15987
+ constructor(bidi) {
15988
+ this.bidi = bidi;
15774
15989
  }
15775
15990
  logpoints = /* @__PURE__ */ new Map();
15776
15991
  subscribed = false;
15777
15992
  /**
15778
15993
  * Subscribe to moz:debugging events
15779
15994
  */
15780
- async subscribe(contextId) {
15995
+ async subscribe() {
15781
15996
  if (this.subscribed) {
15782
15997
  return;
15783
15998
  }
15784
- const bidi = await this.driver.getBidi();
15785
15999
  try {
15786
- await bidi.subscribe("moz:debugging.paused", contextId ? [contextId] : void 0);
15787
- await bidi.subscribe("moz:debugging.resumed", contextId ? [contextId] : void 0);
16000
+ await this.bidi.subscribe(["moz:debugging.paused", "moz:debugging.resumed"]);
15788
16001
  } catch {
15789
16002
  logDebug(
15790
16003
  "Debugging events subscription skipped (may not be available in this Firefox version)"
15791
16004
  );
15792
16005
  }
15793
- const ws = bidi.socket;
15794
- ws.on("message", (data) => {
15795
- try {
15796
- const payload = JSON.parse(data.toString());
15797
- if (payload?.method === "moz:debugging.paused") {
15798
- const { context, url, line, column } = payload.params;
15799
- const logpointId = this.findLogpointByLocation(url, line);
15800
- if (logpointId) {
15801
- void this.handleLogpointPause(context, logpointId);
15802
- return;
15803
- }
15804
- logDebug(`moz:Debugging paused in context: ${context} at ${url}:${line}:${column}`);
15805
- }
15806
- if (payload?.method === "moz:debugging.resumed") {
15807
- logDebug(`moz:Debugging resumed in context: ${payload.params.context}`);
15808
- }
15809
- } 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;
15810
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}`);
15811
16016
  });
15812
16017
  this.subscribed = true;
15813
16018
  logDebug("moz:debugging listener active");
@@ -15841,7 +16046,7 @@ var init_debugging = __esm({
15841
16046
  }
15842
16047
  logDebug(`Logpoint hit: ${logpointId} in context ${contextId}`);
15843
16048
  try {
15844
- const result = await this.sendBiDiCommand("script.evaluate", {
16049
+ const result = await this.bidi.sendCommand("script.evaluate", {
15845
16050
  expression: entry.expression,
15846
16051
  target: { context: contextId },
15847
16052
  awaitPromise: false
@@ -15873,7 +16078,7 @@ var init_debugging = __esm({
15873
16078
  logDebug(`Logpoint ${logpointId}: result buffer capped at ${MAX_LOGPOINT_RESULTS}`);
15874
16079
  }
15875
16080
  }
15876
- await this.sendBiDiCommand("moz:debugging.resume", { context: contextId }).catch((err) => {
16081
+ await this.bidi.sendCommand("moz:debugging.resume", { context: contextId }).catch((err) => {
15877
16082
  logDebug(`Failed to resume after logpoint: ${String(err)}`);
15878
16083
  });
15879
16084
  }
@@ -15891,8 +16096,8 @@ var init_downloads = __esm({
15891
16096
  MAX_DOWNLOADS = 500;
15892
16097
  DOWNLOAD_TTL_MS = 30 * 60 * 1e3;
15893
16098
  DownloadEvents = class {
15894
- constructor(driver) {
15895
- this.driver = driver;
16099
+ constructor(bidi) {
16100
+ this.bidi = bidi;
15896
16101
  }
15897
16102
  downloads = /* @__PURE__ */ new Map();
15898
16103
  subscribed = false;
@@ -15904,65 +16109,53 @@ var init_downloads = __esm({
15904
16109
  /**
15905
16110
  * Subscribe to BiDi download events.
15906
16111
  */
15907
- async subscribe(contextId) {
16112
+ async subscribe() {
15908
16113
  if (this.subscribed) {
15909
16114
  return;
15910
16115
  }
15911
- const bidi = await this.driver.getBidi();
15912
- const contexts = contextId ? [contextId] : void 0;
15913
- await bidi.subscribe("browsingContext.downloadWillBegin", contexts);
15914
- await bidi.subscribe("browsingContext.downloadEnd", contexts);
15915
- const ws = bidi.socket;
15916
- ws.on("message", (data) => {
15917
- try {
15918
- const payload = JSON.parse(data.toString());
15919
- if (payload?.method === "browsingContext.downloadWillBegin") {
15920
- const p = payload.params;
15921
- let key = p.download ?? p.navigation;
15922
- if (!key) {
15923
- key = `download-${this.fallbackCounter++}`;
15924
- this.pendingFallbackKey = key;
15925
- }
15926
- this.downloads.set(key, {
15927
- id: key,
15928
- context: p.context,
15929
- navigation: p.navigation ?? null,
15930
- url: p.url || "",
15931
- suggestedFilename: p.suggestedFilename || "",
15932
- status: "in_progress",
15933
- startTimestamp: p.timestamp ?? Date.now()
15934
- });
15935
- logDebug(`Download started: filename=${p.suggestedFilename}, url=${p.url}, id=${key}`);
15936
- }
15937
- if (payload?.method === "browsingContext.downloadEnd") {
15938
- const p = payload.params;
15939
- let key = p?.download ?? p?.navigation;
15940
- if (!key) {
15941
- key = this.pendingFallbackKey ?? `download-${this.fallbackCounter++}`;
15942
- this.pendingFallbackKey = null;
15943
- }
15944
- const existing = this.downloads.get(key) ?? {
15945
- id: key,
15946
- context: p.context,
15947
- navigation: p.navigation ?? null,
15948
- url: p.url || "",
15949
- suggestedFilename: "",
15950
- status: "in_progress",
15951
- startTimestamp: p.timestamp ?? Date.now()
15952
- };
15953
- existing.status = p.status;
15954
- existing.endTimestamp = p.timestamp ?? Date.now();
15955
- existing.durationMs = existing.endTimestamp - existing.startTimestamp;
15956
- if (p.status === "complete" && p.filepath) {
15957
- existing.filepath = p.filepath;
15958
- }
15959
- this.downloads.set(key, existing);
15960
- logDebug(
15961
- `Download ${p.status}: filepath=${existing.filepath}, url=${existing.url}, id=${key}`
15962
- );
15963
- }
15964
- } 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;
15965
16154
  }
16155
+ this.downloads.set(key, existing);
16156
+ logDebug(
16157
+ `Download ${p.status}: filepath=${existing.filepath}, url=${existing.url}, id=${key}`
16158
+ );
15966
16159
  });
15967
16160
  this.subscribed = true;
15968
16161
  logDebug("Download listener ready");
@@ -16033,19 +16226,6 @@ var init_dom = __esm({
16033
16226
  this.driver = driver;
16034
16227
  this.resolveUid = resolveUid;
16035
16228
  }
16036
- /**
16037
- * Evaluate JavaScript - direct passthrough to executeScript
16038
- */
16039
- async evaluate(script) {
16040
- return await this.driver.executeScript(script);
16041
- }
16042
- /**
16043
- * Get page HTML content
16044
- */
16045
- async getContent() {
16046
- const html = await this.evaluate("return document.documentElement.outerHTML");
16047
- return String(html);
16048
- }
16049
16229
  // ============================================================================
16050
16230
  // Element polling helpers
16051
16231
  // ============================================================================
@@ -16509,7 +16689,12 @@ __export(formatter_exports, {
16509
16689
  formatSnapshotTree: () => formatSnapshotTree
16510
16690
  });
16511
16691
  function formatSnapshotTree(node, depth = 0, options = {}) {
16512
- const { includeAttributes = true, includeText = true, maxDepth } = options;
16692
+ const {
16693
+ includeAttributes = true,
16694
+ includeText = true,
16695
+ maxDepth,
16696
+ maxAttrLength = MAX_ATTR_LENGTH
16697
+ } = options;
16513
16698
  if (maxDepth !== void 0 && depth >= maxDepth) {
16514
16699
  return "";
16515
16700
  }
@@ -16519,22 +16704,22 @@ function formatSnapshotTree(node, depth = 0, options = {}) {
16519
16704
  const role = node.role || node.tag;
16520
16705
  attrs.push(role);
16521
16706
  if (node.name) {
16522
- attrs.push(`"${truncate(node.name, MAX_ATTR_LENGTH)}"`);
16707
+ attrs.push(`"${truncate(node.name, maxAttrLength)}"`);
16523
16708
  }
16524
16709
  if (node.role && node.role !== node.tag) {
16525
16710
  attrs.push(`tag=${node.tag}`);
16526
16711
  }
16527
16712
  if (node.value) {
16528
- attrs.push(`value="${truncate(node.value, MAX_ATTR_LENGTH)}"`);
16713
+ attrs.push(`value="${truncate(node.value, maxAttrLength)}"`);
16529
16714
  }
16530
16715
  if (node.href) {
16531
- attrs.push(`href="${truncate(node.href, MAX_ATTR_LENGTH)}"`);
16716
+ attrs.push(`href="${truncate(node.href, maxAttrLength)}"`);
16532
16717
  }
16533
16718
  if (node.src) {
16534
- attrs.push(`src="${truncate(node.src, MAX_ATTR_LENGTH)}"`);
16719
+ attrs.push(`src="${truncate(node.src, maxAttrLength)}"`);
16535
16720
  }
16536
16721
  if (includeText && node.text) {
16537
- attrs.push(`text="${truncate(node.text, MAX_ATTR_LENGTH)}"`);
16722
+ attrs.push(`text="${truncate(node.text, maxAttrLength)}"`);
16538
16723
  }
16539
16724
  if (includeAttributes && node.aria) {
16540
16725
  if (node.aria.disabled) {
@@ -16593,7 +16778,7 @@ function formatSnapshotTree(node, depth = 0, options = {}) {
16593
16778
  if (node.isIframe) {
16594
16779
  attrs.push("[iframe");
16595
16780
  if (node.frameSrc) {
16596
- attrs.push(`src="${truncate(node.frameSrc, MAX_ATTR_LENGTH)}"`);
16781
+ attrs.push(`src="${truncate(node.frameSrc, maxAttrLength)}"`);
16597
16782
  }
16598
16783
  if (node.crossOrigin) {
16599
16784
  attrs.push("cross-origin");
@@ -16621,140 +16806,60 @@ var init_formatter = __esm({
16621
16806
  });
16622
16807
 
16623
16808
  // src/firefox/snapshot/resolver.ts
16624
- import { By as By2 } from "selenium-webdriver";
16625
- var UidResolver;
16809
+ function notFoundMessage(uid) {
16810
+ return `UID not found: ${uid}. The element is gone from the page, or the page was reloaded. Take a fresh snapshot first.`;
16811
+ }
16812
+ var RESOLVE_SCRIPT, SELECTOR_SCRIPT, CLEAR_SCRIPT, UidResolver;
16626
16813
  var init_resolver = __esm({
16627
16814
  "src/firefox/snapshot/resolver.ts"() {
16628
16815
  "use strict";
16629
16816
  init_logger();
16817
+ RESOLVE_SCRIPT = "return window.__resolveUid ? window.__resolveUid(arguments[0]) : null;";
16818
+ SELECTOR_SCRIPT = "return window.__uidToSelector ? window.__uidToSelector(arguments[0]) : null;";
16819
+ CLEAR_SCRIPT = "if (window.__clearUidRegistry) { window.__clearUidRegistry(); }";
16630
16820
  UidResolver = class {
16631
16821
  constructor(driver) {
16632
16822
  this.driver = driver;
16633
16823
  }
16634
- uidToEntry = /* @__PURE__ */ new Map();
16635
- elementCache = /* @__PURE__ */ new Map();
16636
- currentSnapshotId = 0;
16637
- /**
16638
- * Update current snapshot ID
16639
- */
16640
- setSnapshotId(snapshotId) {
16641
- this.currentSnapshotId = snapshotId;
16642
- }
16643
16824
  /**
16644
- * Get current snapshot ID
16825
+ * Forget all UID associations in the page, making existing UIDs unresolvable.
16826
+ * Best effort: the registry dies with the page anyway.
16645
16827
  */
16646
- getSnapshotId() {
16647
- return this.currentSnapshotId;
16648
- }
16649
- /**
16650
- * Store UID mappings from snapshot result
16651
- */
16652
- storeUidMappings(uidMap) {
16653
- this.uidToEntry.clear();
16654
- for (const entry of uidMap) {
16655
- this.uidToEntry.set(entry.uid, entry);
16656
- }
16657
- }
16658
- /**
16659
- * Clear all UID mappings and cache
16660
- */
16661
- clear() {
16662
- this.uidToEntry.clear();
16663
- this.elementCache.clear();
16664
- logDebug("Snapshot UIDs cleared");
16665
- }
16666
- /**
16667
- * Validate UID (staleness check)
16668
- */
16669
- validateUid(uid) {
16670
- const parts = uid.split("_");
16671
- if (parts.length < 2 || !parts[0]) {
16672
- throw new Error(`Invalid UID format: ${uid}`);
16673
- }
16674
- const uidSnapshotId = parseInt(parts[0], 10);
16675
- if (isNaN(uidSnapshotId)) {
16676
- throw new Error(`Invalid UID format: ${uid}`);
16677
- }
16678
- if (uidSnapshotId !== this.currentSnapshotId) {
16679
- throw new Error(
16680
- `This uid is from a stale snapshot (snapshot ${uidSnapshotId}, current ${this.currentSnapshotId}). Take a fresh snapshot.`
16681
- );
16828
+ async clear() {
16829
+ try {
16830
+ await this.driver.executeScript(CLEAR_SCRIPT);
16831
+ logDebug("Snapshot UIDs cleared");
16832
+ } catch {
16833
+ logDebug("Unable to clear snapshot UIDs (page may be navigating)");
16682
16834
  }
16683
16835
  }
16684
16836
  /**
16685
- * Resolve UID to CSS selector (with staleness check)
16837
+ * Resolve UID to a CSS selector, generated on demand from the element it points at
16686
16838
  */
16687
- resolveUidToSelector(uid) {
16688
- this.validateUid(uid);
16689
- const entry = this.uidToEntry.get(uid);
16690
- if (!entry) {
16691
- throw new Error(`UID not found: ${uid}. Take a fresh snapshot first.`);
16839
+ async resolveUidToSelector(uid) {
16840
+ const selector = await this.driver.executeScript(SELECTOR_SCRIPT, uid);
16841
+ if (!selector) {
16842
+ throw new Error(notFoundMessage(uid));
16692
16843
  }
16693
- return entry.css;
16844
+ return selector;
16694
16845
  }
16695
16846
  /**
16696
- * Resolve UID to element (with staleness check and caching)
16697
- * Tries CSS first, falls back to XPath
16847
+ * Resolve UID to the element it was assigned to during the snapshot
16698
16848
  */
16699
16849
  async resolveUidToElement(uid) {
16700
- this.validateUid(uid);
16701
- const entry = this.uidToEntry.get(uid);
16702
- if (!entry) {
16703
- throw new Error(`UID not found: ${uid}. Take a fresh snapshot first.`);
16704
- }
16705
- const cached2 = this.elementCache.get(uid);
16706
- if (cached2?.cachedElement) {
16707
- try {
16708
- await cached2.cachedElement.isDisplayed();
16709
- logDebug(`Using cached element for UID: ${uid}`);
16710
- return cached2.cachedElement;
16711
- } catch {
16712
- logDebug(`Cached element stale for UID: ${uid}, re-finding...`);
16713
- }
16714
- }
16715
- try {
16716
- const element = await this.driver.findElement(By2.css(entry.css));
16717
- this.elementCache.set(uid, {
16718
- selector: entry.css,
16719
- ...entry.xpath && { xpath: entry.xpath },
16720
- cachedElement: element,
16721
- snapshotId: this.currentSnapshotId,
16722
- timestamp: Date.now()
16723
- });
16724
- logDebug(`Found element by CSS for UID: ${uid}`);
16725
- return element;
16726
- } catch {
16727
- logDebug(`CSS selector failed for UID: ${uid}, trying XPath fallback...`);
16728
- const xpathSelector = entry.xpath;
16729
- if (xpathSelector) {
16730
- try {
16731
- const element = await this.driver.findElement(By2.xpath(xpathSelector));
16732
- this.elementCache.set(uid, {
16733
- selector: entry.css,
16734
- ...xpathSelector && { xpath: xpathSelector },
16735
- cachedElement: element,
16736
- snapshotId: this.currentSnapshotId,
16737
- timestamp: Date.now()
16738
- });
16739
- logDebug(`Found element by XPath for UID: ${uid}`);
16740
- return element;
16741
- } catch {
16742
- throw new Error(
16743
- `Element not found for UID: ${uid}. The element may have changed. Take a fresh snapshot.`
16744
- );
16745
- }
16746
- }
16747
- throw new Error(
16748
- `Element not found for UID: ${uid}. The element may have changed. Take a fresh snapshot.`
16749
- );
16850
+ const element = await this.driver.executeScript(RESOLVE_SCRIPT, uid);
16851
+ if (!element) {
16852
+ throw new Error(notFoundMessage(uid));
16750
16853
  }
16854
+ logDebug(`Resolved element for UID: ${uid}`);
16855
+ return element;
16751
16856
  }
16752
16857
  };
16753
16858
  }
16754
16859
  });
16755
16860
 
16756
16861
  // src/firefox/snapshot/manager.ts
16757
- import { readFileSync } from "fs";
16862
+ import { readFileSync as readFileSync2 } from "fs";
16758
16863
  import { dirname, resolve } from "path";
16759
16864
  import { fileURLToPath } from "url";
16760
16865
  var SnapshotManager;
@@ -16768,7 +16873,8 @@ var init_manager = __esm({
16768
16873
  driver;
16769
16874
  resolver;
16770
16875
  injectedScript = null;
16771
- currentSnapshotId = 0;
16876
+ /** Counter handed to the injected script so UIDs stay unique across snapshots */
16877
+ nextElementId = 0;
16772
16878
  constructor(driver) {
16773
16879
  this.driver = driver;
16774
16880
  this.resolver = new UidResolver(driver);
@@ -16796,7 +16902,7 @@ var init_manager = __esm({
16796
16902
  for (const path of possiblePaths) {
16797
16903
  attemptedPaths.push(path);
16798
16904
  try {
16799
- this.injectedScript = readFileSync(path, "utf-8");
16905
+ this.injectedScript = readFileSync2(path, "utf-8");
16800
16906
  const sizeKB = (this.injectedScript.length / 1024).toFixed(1);
16801
16907
  logDebug(`\u2713 Loaded snapshot bundle: ${path.split("/").pop()} (${sizeKB} KB)`);
16802
16908
  return this.injectedScript;
@@ -16815,14 +16921,25 @@ ${attemptedPaths.map((p) => ` - ${p}`).join("\n")}`
16815
16921
  }
16816
16922
  /**
16817
16923
  * Take a snapshot of the current page
16818
- * Returns text and JSON with snapshotId, no DOM mutations
16924
+ * Returns text and JSON, no DOM mutations
16819
16925
  */
16820
16926
  async takeSnapshot(options) {
16821
- const snapshotId = ++this.currentSnapshotId;
16822
- this.resolver.setSnapshotId(snapshotId);
16823
- this.resolver.clear();
16824
- logDebug(`Taking snapshot (ID: ${snapshotId})...`);
16825
- const result = await this.executeInjectedScript(snapshotId, options);
16927
+ if (options?.selector || options?.includeAll) {
16928
+ const optionsOutput = [];
16929
+ if (options.selector) {
16930
+ optionsOutput.push(`selector: ${options.selector}`);
16931
+ }
16932
+ if (options.includeAll) {
16933
+ optionsOutput.push("include all");
16934
+ }
16935
+ logDebug(`Taking snapshot (${optionsOutput.join(", ")})...`);
16936
+ } else {
16937
+ logDebug("Taking snapshot...");
16938
+ }
16939
+ const result = await this.executeInjectedScript(this.nextElementId, options);
16940
+ if (typeof result?.nextElementId === "number") {
16941
+ this.nextElementId = result.nextElementId;
16942
+ }
16826
16943
  logDebug(
16827
16944
  `Snapshot executeScript result: hasResult=${!!result}, hasTree=${!!result?.tree}, truncated=${result?.truncated || false}`
16828
16945
  );
@@ -16844,60 +16961,60 @@ ${attemptedPaths.map((p) => ` - ${p}`).join("\n")}`
16844
16961
  logDebug(`Snapshot generation failed: ${errorMsg}`);
16845
16962
  throw new Error(`Failed to generate snapshot: ${errorMsg}`);
16846
16963
  }
16847
- this.resolver.storeUidMappings(result.uidMap);
16848
16964
  const snapshotJson = {
16849
16965
  root: result.tree,
16850
- snapshotId,
16851
16966
  timestamp: Date.now(),
16852
- truncated: result.truncated || false,
16853
- uidMap: result.uidMap
16967
+ truncated: result.truncated || false
16854
16968
  };
16855
16969
  const snapshot = {
16856
16970
  text: formatSnapshotTree(result.tree),
16857
16971
  json: snapshotJson
16858
16972
  };
16859
16973
  logDebug(
16860
- `Snapshot created: ${result.uidMap.length} elements with UIDs${result.truncated ? " (truncated)" : ""}`
16974
+ `Snapshot created: ${result.nodeCount} elements with UIDs${result.truncated ? " (truncated)" : ""}`
16861
16975
  );
16862
16976
  return snapshot;
16863
16977
  }
16864
16978
  /**
16865
- * Resolve UID to CSS selector (with staleness check)
16979
+ * Resolve UID to a CSS selector generated on demand
16866
16980
  */
16867
- resolveUidToSelector(uid) {
16868
- return this.resolver.resolveUidToSelector(uid);
16981
+ async resolveUidToSelector(uid) {
16982
+ return await this.resolver.resolveUidToSelector(uid);
16869
16983
  }
16870
16984
  /**
16871
- * Resolve UID to WebElement (with staleness check and caching)
16985
+ * Resolve UID to the WebElement it was assigned to
16872
16986
  */
16873
16987
  async resolveUidToElement(uid) {
16874
16988
  return await this.resolver.resolveUidToElement(uid);
16875
16989
  }
16876
16990
  /**
16877
- * Clear snapshot (called on navigation)
16991
+ * Clear snapshot UIDs
16878
16992
  */
16879
- clear() {
16880
- this.resolver.clear();
16993
+ async clear() {
16994
+ await this.resolver.clear();
16881
16995
  }
16882
16996
  /**
16883
16997
  * Execute bundled injected snapshot script
16884
16998
  */
16885
- async executeInjectedScript(snapshotId, options) {
16999
+ async executeInjectedScript(nextElementId, options) {
16886
17000
  const scriptSource = this.getInjectedScript();
16887
17001
  const result = await this.driver.executeScript(
16888
17002
  `
16889
17003
  // Only inject the bundle if not already present
16890
17004
  if (typeof window.__createSnapshot === 'undefined') {
16891
17005
  ${scriptSource}
16892
- // Register the createSnapshot function globally
17006
+ // Register the snapshot and UID resolution functions globally
16893
17007
  if (typeof __SnapshotInjected !== 'undefined' && __SnapshotInjected.createSnapshot) {
16894
17008
  window.__createSnapshot = __SnapshotInjected.createSnapshot;
17009
+ window.__resolveUid = __SnapshotInjected.resolveUid;
17010
+ window.__uidToSelector = __SnapshotInjected.uidToSelector;
17011
+ window.__clearUidRegistry = __SnapshotInjected.clearUidRegistry;
16895
17012
  }
16896
17013
  }
16897
17014
  // Call it with options
16898
17015
  return window.__createSnapshot(arguments[0], arguments[1]);
16899
17016
  `,
16900
- snapshotId,
17017
+ nextElementId,
16901
17018
  options || {}
16902
17019
  );
16903
17020
  return result;
@@ -16921,13 +17038,16 @@ var init_firefox = __esm({
16921
17038
  "src/firefox/index.ts"() {
16922
17039
  "use strict";
16923
17040
  init_core3();
17041
+ init_bidi();
16924
17042
  init_logger();
17043
+ init_remote_value();
16925
17044
  init_events();
16926
17045
  init_dom();
16927
17046
  init_pages();
16928
17047
  init_snapshot();
16929
17048
  FirefoxClient = class {
16930
17049
  core;
17050
+ bidi = null;
16931
17051
  consoleEvents = null;
16932
17052
  networkEvents = null;
16933
17053
  debuggingEvents = null;
@@ -16938,33 +17058,52 @@ var init_firefox = __esm({
16938
17058
  constructor(options) {
16939
17059
  this.core = new FirefoxCore(options);
16940
17060
  }
17061
+ getBidi() {
17062
+ if (!this.bidi) {
17063
+ throw new Error("Not connected");
17064
+ }
17065
+ return this.bidi;
17066
+ }
16941
17067
  /**
16942
17068
  * Connect and initialize all modules
16943
17069
  */
16944
17070
  async connect() {
16945
17071
  await this.core.connect();
16946
17072
  const driver = this.core.getDriver();
17073
+ this.bidi = new BiDiFacade(driver);
16947
17074
  this.snapshot = new SnapshotManager(driver);
16948
- const onNavigate = () => {
16949
- if (this.snapshot) {
16950
- this.snapshot.clear();
16951
- }
16952
- };
16953
- const hasBidi = "getBidi" in driver && typeof driver.getBidi === "function";
16954
- if (hasBidi) {
16955
- this.consoleEvents = new ConsoleEvents(driver, {
16956
- onNavigate,
16957
- autoClearOnNavigate: false
16958
- });
16959
- this.networkEvents = new NetworkEvents(driver, {
16960
- onNavigate,
16961
- autoClearOnNavigate: false
16962
- });
16963
- this.debuggingEvents = new DebuggingEvents(
16964
- driver,
16965
- (method, params) => this.core.sendBiDiCommand(method, params)
16966
- );
16967
- 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;
16968
17107
  }
16969
17108
  this.dom = new DomInteractions(
16970
17109
  driver,
@@ -16974,55 +17113,30 @@ var init_firefox = __esm({
16974
17113
  driver,
16975
17114
  () => this.core.getCurrentContextId(),
16976
17115
  (id) => this.core.setCurrentContextId(id),
16977
- (method, params) => this.core.sendBiDiCommand(method, params)
17116
+ (method, params) => this.getBidi().sendCommand(method, params)
16978
17117
  );
16979
- if (this.consoleEvents) {
16980
- try {
16981
- await this.consoleEvents.subscribe(void 0);
16982
- } catch {
16983
- logDebug("Unable to subscribe to console events");
16984
- this.consoleEvents = null;
16985
- }
16986
- }
16987
- if (this.networkEvents) {
16988
- try {
16989
- await this.networkEvents.subscribe(void 0);
16990
- } catch {
16991
- logDebug("Unable to subscribe to network events");
16992
- this.networkEvents = null;
16993
- }
16994
- }
16995
- if (this.debuggingEvents) {
16996
- try {
16997
- await this.debuggingEvents.subscribe();
16998
- } catch {
16999
- logDebug("Unable to subscribe to debugging events");
17000
- this.debuggingEvents = null;
17001
- }
17002
- }
17003
- if (this.downloadEvents) {
17004
- try {
17005
- await this.downloadEvents.subscribe(void 0);
17006
- } catch {
17007
- logDebug("Unable to subscribe to download events");
17008
- this.downloadEvents = null;
17009
- }
17010
- }
17011
17118
  }
17012
17119
  // ============================================================================
17013
17120
  // DOM / Evaluate
17014
17121
  // ============================================================================
17015
- async evaluate(script) {
17016
- if (!this.dom) {
17017
- throw new Error("Not connected");
17018
- }
17019
- return await this.dom.evaluate(script);
17020
- }
17021
- async getContent() {
17022
- if (!this.dom) {
17023
- throw new Error("Not connected");
17122
+ /**
17123
+ * Evaluate a JavaScript expression in the current browsing context over
17124
+ * WebDriver BiDi (script.evaluate), so reads target the BiDi-tracked context
17125
+ * rather than Selenium's classic window handle. Returns the result as a
17126
+ * native value; throws on a script exception.
17127
+ */
17128
+ async evaluate(expression) {
17129
+ const result = await this.getBidi().sendCommand("script.evaluate", {
17130
+ expression,
17131
+ awaitPromise: true,
17132
+ target: { context: this.core.getCurrentContextId() }
17133
+ });
17134
+ if (result.type === "success") {
17135
+ return remoteValueToNative(result.result);
17024
17136
  }
17025
- return await this.dom.getContent();
17137
+ throw new Error(
17138
+ `Script evaluation failed: ${result.exceptionDetails?.text ?? "unknown error"}`
17139
+ );
17026
17140
  }
17027
17141
  async clickBySelector(selector) {
17028
17142
  if (!this.dom) {
@@ -17118,7 +17232,6 @@ var init_firefox = __esm({
17118
17232
  throw new Error("Not connected");
17119
17233
  }
17120
17234
  await this.pages.navigate(url);
17121
- this.clearSnapshot();
17122
17235
  }
17123
17236
  async navigateBack() {
17124
17237
  if (!this.pages) {
@@ -17221,6 +17334,18 @@ var init_firefox = __esm({
17221
17334
  }
17222
17335
  this.networkEvents.clearRequests();
17223
17336
  }
17337
+ /**
17338
+ * Fetch a captured request or response body for a given request id.
17339
+ * Returns a structured result describing the body or why it is unavailable.
17340
+ */
17341
+ async getNetworkRequestBody(requestId, dataType) {
17342
+ if (!this.networkEvents) {
17343
+ throw new Error(
17344
+ "Network events not available (Firefox Remote Agent not running \u2014 start Firefox with --remote-debugging-port to enable BiDi)"
17345
+ );
17346
+ }
17347
+ return this.networkEvents.fetchBody(requestId, dataType);
17348
+ }
17224
17349
  // ============================================================================
17225
17350
  // Downloads
17226
17351
  // ============================================================================
@@ -17246,7 +17371,7 @@ var init_firefox = __esm({
17246
17371
  */
17247
17372
  async setDownloadBehavior(behavior) {
17248
17373
  const downloadBehavior = behavior === "default" ? null : behavior === "allowed" ? { type: "allowed" } : { type: "denied" };
17249
- await this.core.sendBiDiCommand("browser.setDownloadBehavior", { downloadBehavior });
17374
+ await this.getBidi().sendCommand("browser.setDownloadBehavior", { downloadBehavior });
17250
17375
  }
17251
17376
  // ============================================================================
17252
17377
  // Snapshot
@@ -17257,11 +17382,11 @@ var init_firefox = __esm({
17257
17382
  }
17258
17383
  return await this.snapshot.takeSnapshot(options);
17259
17384
  }
17260
- resolveUidToSelector(uid) {
17385
+ async resolveUidToSelector(uid) {
17261
17386
  if (!this.snapshot) {
17262
17387
  throw new Error("Not connected");
17263
17388
  }
17264
- return this.snapshot.resolveUidToSelector(uid);
17389
+ return await this.snapshot.resolveUidToSelector(uid);
17265
17390
  }
17266
17391
  async resolveUidToElement(uid) {
17267
17392
  if (!this.snapshot) {
@@ -17269,11 +17394,11 @@ var init_firefox = __esm({
17269
17394
  }
17270
17395
  return await this.snapshot.resolveUidToElement(uid);
17271
17396
  }
17272
- clearSnapshot() {
17397
+ async clearSnapshot() {
17273
17398
  if (!this.snapshot) {
17274
17399
  throw new Error("Not connected");
17275
17400
  }
17276
- this.snapshot.clear();
17401
+ await this.snapshot.clear();
17277
17402
  }
17278
17403
  // ============================================================================
17279
17404
  // Screenshot
@@ -17298,7 +17423,7 @@ var init_firefox = __esm({
17298
17423
  * @internal
17299
17424
  */
17300
17425
  async sendBiDiCommand(method, params = {}) {
17301
- return await this.core.sendBiDiCommand(method, params);
17426
+ return await this.getBidi().sendCommand(method, params);
17302
17427
  }
17303
17428
  /**
17304
17429
  * Get WebDriver instance (for advanced operations)
@@ -17343,7 +17468,7 @@ var init_firefox = __esm({
17343
17468
  if (!this.debuggingEvents) {
17344
17469
  throw new Error("Debugging events not available");
17345
17470
  }
17346
- const result = await this.core.sendBiDiCommand("moz:debugging.setBreakpoint", {
17471
+ const result = await this.getBidi().sendCommand("moz:debugging.setBreakpoint", {
17347
17472
  location: { url, line }
17348
17473
  });
17349
17474
  const logpointId = result.breakpoint;
@@ -17357,7 +17482,7 @@ var init_firefox = __esm({
17357
17482
  if (!this.debuggingEvents) {
17358
17483
  throw new Error("Debugging events not available");
17359
17484
  }
17360
- await this.core.sendBiDiCommand("moz:debugging.removeBreakpoint", {
17485
+ await this.getBidi().sendCommand("moz:debugging.removeBreakpoint", {
17361
17486
  breakpoint: logpointId
17362
17487
  });
17363
17488
  this.debuggingEvents.removeLogpoint(logpointId);
@@ -17411,11 +17536,56 @@ var init_firefox = __esm({
17411
17536
  }
17412
17537
  });
17413
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
+
17414
17583
  // src/tools/registry.ts
17415
17584
  function buildToolset(options) {
17416
17585
  const { moduleNames, warnings } = selectModules(options);
17417
17586
  const { toolDefinitions, handlers } = collectTools(moduleNames);
17418
- return { moduleNames, warnings, toolDefinitions, handlers };
17587
+ const instructions = buildInstructions(moduleNames, new Set(handlers.keys()));
17588
+ return { moduleNames, warnings, toolDefinitions, handlers, instructions };
17419
17589
  }
17420
17590
  function selectModules(options) {
17421
17591
  const { tools: requested, preset, enableScript, enablePrivilegedContext } = options;
@@ -17503,6 +17673,7 @@ var init_registry = __esm({
17503
17673
  "src/tools/registry.ts"() {
17504
17674
  "use strict";
17505
17675
  init_tools();
17676
+ init_instructions();
17506
17677
  privilegedModuleNames = new Set(MODULES.filter((m) => m.privileged).map((m) => m.name));
17507
17678
  }
17508
17679
  });
@@ -17579,11 +17750,14 @@ async function getFirefox() {
17579
17750
  acceptInsecureCerts: args.acceptInsecureCerts,
17580
17751
  connectExisting: args.connectExisting,
17581
17752
  marionettePort: args.marionettePort,
17753
+ lookupMarionettePort: args.lookupMarionettePort,
17582
17754
  env: envVars,
17583
17755
  logFile: args.outputFile ?? void 0,
17584
17756
  prefs,
17585
17757
  androidDevice: args.androidDevice ?? void 0,
17586
- androidPackage: args.androidPackage ?? void 0
17758
+ androidPackage: args.androidPackage ?? void 0,
17759
+ androidWipeAppData: args.androidWipeAppData,
17760
+ captureNetworkBodies: !args.disableNetworkBodyCollection
17587
17761
  };
17588
17762
  }
17589
17763
  firefox2 = new FirefoxClient(options);
@@ -17620,7 +17794,8 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17620
17794
  moduleNames,
17621
17795
  warnings,
17622
17796
  toolDefinitions: allTools,
17623
- handlers: toolHandlers
17797
+ handlers: toolHandlers,
17798
+ instructions
17624
17799
  } = buildToolset({
17625
17800
  tools: args.tools,
17626
17801
  preset: args.toolPreset,
@@ -17649,9 +17824,9 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17649
17824
  },
17650
17825
  {
17651
17826
  capabilities: {
17652
- resources: {},
17653
17827
  tools: {}
17654
- }
17828
+ },
17829
+ instructions
17655
17830
  }
17656
17831
  );
17657
17832
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -17683,12 +17858,6 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17683
17858
  throw error2;
17684
17859
  }
17685
17860
  });
17686
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
17687
- return { resources: [] };
17688
- });
17689
- server.setRequestHandler(ReadResourceRequestSchema, async () => {
17690
- throw new Error("Resource reading not implemented");
17691
- });
17692
17861
  const transport = new StdioServerTransport();
17693
17862
  await server.connect(transport);
17694
17863
  log("Firefox DevTools MCP server running on stdio");
@@ -17700,48 +17869,123 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17700
17869
  });
17701
17870
  process.exit(0);
17702
17871
  };
17703
- const onSignal = () => void cleanup();
17704
- process.on("SIGTERM", onSignal);
17705
- process.on("SIGINT", onSignal);
17706
- process.stdin.on("end", onSignal);
17707
- process.stdin.on("close", onSignal);
17872
+ const onSignal = () => void cleanup();
17873
+ process.on("SIGTERM", onSignal);
17874
+ process.on("SIGINT", onSignal);
17875
+ process.stdin.on("end", onSignal);
17876
+ process.stdin.on("close", onSignal);
17877
+ }
17878
+ var major, args, firefox2, nextLaunchOptions, pendingWarning;
17879
+ var init_src = __esm({
17880
+ "src/index.ts"() {
17881
+ "use strict";
17882
+ init_server2();
17883
+ init_stdio2();
17884
+ init_types();
17885
+ init_constants();
17886
+ init_logger();
17887
+ init_cli();
17888
+ init_firefox();
17889
+ init_registry();
17890
+ init_response_helpers();
17891
+ [major] = version2.substring(1).split(".").map(Number);
17892
+ if (!major || major < 20) {
17893
+ console.error(`Node ${version2} is not supported. Please use Node.js >=20.`);
17894
+ process.exit(1);
17895
+ }
17896
+ args = {};
17897
+ firefox2 = null;
17898
+ nextLaunchOptions = null;
17899
+ pendingWarning = null;
17900
+ }
17901
+ });
17902
+
17903
+ // src/utils/save-output.ts
17904
+ import { mkdir, rename, stat, unlink, writeFile } from "fs/promises";
17905
+ import { randomBytes } from "crypto";
17906
+ import { homedir as homedir2 } from "os";
17907
+ import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve3, sep } from "path";
17908
+ function homeRoot() {
17909
+ return join3(homedir2(), ".firefox-devtools-mcp");
17910
+ }
17911
+ function generatedName(baseName, extension) {
17912
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
17913
+ return `${baseName}-${timestamp}.${extension}`;
17914
+ }
17915
+ async function isDirectory(path) {
17916
+ try {
17917
+ return (await stat(path)).isDirectory();
17918
+ } catch {
17919
+ return false;
17920
+ }
17921
+ }
17922
+ async function assertAllowedPath(saveTo, resolvedPath) {
17923
+ const { args: args2 } = await Promise.resolve().then(() => (init_src(), src_exports));
17924
+ if (args2?.unrestrictedSavePaths) {
17925
+ return;
17926
+ }
17927
+ const root = isAbsolute(saveTo) ? homeRoot() : process.cwd();
17928
+ if (resolvedPath !== root && !resolvedPath.startsWith(root + sep)) {
17929
+ throw new Error(
17930
+ `saveTo "${saveTo}" resolves outside the allowed location (${resolvedPath}). Relative paths must stay within the current working directory and absolute paths within ${homeRoot()}. Start the server with --unrestricted-save-paths to write to arbitrary locations.`
17931
+ );
17932
+ }
17933
+ }
17934
+ async function saveOutput(content, saveTo, baseName, extension = "json") {
17935
+ let resolvedPath;
17936
+ if (saveTo) {
17937
+ resolvedPath = resolve3(saveTo);
17938
+ if (await isDirectory(resolvedPath)) {
17939
+ resolvedPath = join3(resolvedPath, generatedName(baseName, extension));
17940
+ }
17941
+ await assertAllowedPath(saveTo, resolvedPath);
17942
+ } else {
17943
+ resolvedPath = join3(homeRoot(), "output", generatedName(baseName, extension));
17944
+ }
17945
+ await mkdir(dirname2(resolvedPath), { recursive: true });
17946
+ const tmpPath = `${resolvedPath}.${randomBytes(6).toString("hex")}.tmp`;
17947
+ try {
17948
+ await writeFile(tmpPath, content);
17949
+ await rename(tmpPath, resolvedPath);
17950
+ } catch (error2) {
17951
+ await unlink(tmpPath).catch(() => void 0);
17952
+ throw error2;
17953
+ }
17954
+ return { path: resolvedPath, bytes: Buffer.byteLength(content) };
17955
+ }
17956
+ var init_save_output = __esm({
17957
+ "src/utils/save-output.ts"() {
17958
+ "use strict";
17959
+ }
17960
+ });
17961
+
17962
+ // src/tools/module.ts
17963
+ function defineModule(config2) {
17964
+ return {
17965
+ name: config2.name,
17966
+ description: config2.description,
17967
+ ...config2.privileged ? { privileged: true } : {},
17968
+ tools: config2.tools.map(([definition, handler]) => ({ definition, handler }))
17969
+ };
17708
17970
  }
17709
- var major, args, firefox2, nextLaunchOptions, pendingWarning;
17710
- var init_src = __esm({
17711
- "src/index.ts"() {
17971
+ var init_module = __esm({
17972
+ "src/tools/module.ts"() {
17712
17973
  "use strict";
17713
- init_server2();
17714
- init_stdio2();
17715
- init_types();
17716
- init_constants();
17717
- init_logger();
17718
- init_cli();
17719
- init_firefox();
17720
- init_registry();
17721
- init_response_helpers();
17722
- [major] = version2.substring(1).split(".").map(Number);
17723
- if (!major || major < 20) {
17724
- console.error(`Node ${version2} is not supported. Please use Node.js >=20.`);
17725
- process.exit(1);
17726
- }
17727
- args = {};
17728
- firefox2 = null;
17729
- nextLaunchOptions = null;
17730
- pendingWarning = null;
17731
17974
  }
17732
17975
  });
17733
17976
 
17734
17977
  // src/tools/pages.ts
17735
17978
  function formatPageList(tabs, selectedIdx) {
17736
17979
  if (tabs.length === 0) {
17737
- return "\u{1F4C4} No pages";
17980
+ return "No pages";
17738
17981
  }
17739
- const lines = [`\u{1F4C4} ${tabs.length} pages (selected: ${selectedIdx})`];
17982
+ const lines = [`${tabs.length} pages (selected: ${selectedIdx})`];
17740
17983
  for (const tab of tabs) {
17741
17984
  const idx = tabs.indexOf(tab);
17742
17985
  const marker = idx === selectedIdx ? ">" : " ";
17743
17986
  const title = (tab.title || "Untitled").substring(0, 40);
17744
- lines.push(`${marker}[${idx}] ${title}`);
17987
+ const url = (tab.url || "URL unavailable").substring(0, 200);
17988
+ lines.push(`${marker}[${idx}] ${title} (${url})`);
17745
17989
  }
17746
17990
  return lines.join("\n");
17747
17991
  }
@@ -17848,12 +18092,57 @@ async function handleClosePage(args2) {
17848
18092
  return errorResponse(error2);
17849
18093
  }
17850
18094
  }
17851
- var listPagesTool, newPageTool, navigatePageTool, selectPageTool, closePageTool, module;
18095
+ async function respondWithContent(content, args2, baseName, extension) {
18096
+ const {
18097
+ maxLength = DEFAULT_MAX_CONTENT_CHARS,
18098
+ saveTo,
18099
+ preview
18100
+ } = args2 || {};
18101
+ if (saveTo) {
18102
+ const saved = await saveOutput(
18103
+ content,
18104
+ saveTo === true ? void 0 : saveTo,
18105
+ baseName,
18106
+ extension
18107
+ );
18108
+ let output = `${baseName} saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18109
+ const excerpt = previewExcerpt(content, preview);
18110
+ if (excerpt) {
18111
+ output += "\nPreview:\n" + excerpt;
18112
+ }
18113
+ return successResponse(output);
18114
+ }
18115
+ if (content.length <= maxLength) {
18116
+ return successResponse(content + `
18117
+
18118
+ [full content, ${content.length} chars]`);
18119
+ }
18120
+ const footer = truncationFooter(content.length - maxLength, "chars", [
18121
+ "maxLength to show more",
18122
+ "saveTo to save the full content to a file"
18123
+ ]);
18124
+ return successResponse(content.slice(0, maxLength) + "\n\n" + footer);
18125
+ }
18126
+ async function handleGetPageText(args2) {
18127
+ try {
18128
+ const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18129
+ const firefox3 = await getFirefox2();
18130
+ const text = await firefox3.evaluate(
18131
+ "document.body ? document.body.innerText : document.documentElement.innerText"
18132
+ );
18133
+ return respondWithContent(text ?? "", args2, "page-text", "txt");
18134
+ } catch (error2) {
18135
+ return errorResponse(error2);
18136
+ }
18137
+ }
18138
+ var DEFAULT_MAX_CONTENT_CHARS, listPagesTool, newPageTool, navigatePageTool, selectPageTool, closePageTool, getPageTextTool, module;
17852
18139
  var init_pages2 = __esm({
17853
18140
  "src/tools/pages.ts"() {
17854
18141
  "use strict";
17855
18142
  init_response_helpers();
18143
+ init_save_output();
17856
18144
  init_module();
18145
+ DEFAULT_MAX_CONTENT_CHARS = 2e4;
17857
18146
  listPagesTool = {
17858
18147
  name: "list_pages",
17859
18148
  description: "List open tabs (index, title, URL). Selected tab is marked.",
@@ -17941,6 +18230,30 @@ var init_pages2 = __esm({
17941
18230
  required: ["pageIdx"]
17942
18231
  }
17943
18232
  };
18233
+ getPageTextTool = {
18234
+ name: "get_page_text",
18235
+ description: "Get the visible text of the page (document.body.innerText). Caps at maxLength (default 20000 chars); saveTo saves the full text to a file.",
18236
+ annotations: {
18237
+ readOnlyHint: true
18238
+ },
18239
+ inputSchema: {
18240
+ type: "object",
18241
+ properties: {
18242
+ maxLength: {
18243
+ type: "number",
18244
+ description: "Max characters to return inline (default: 20000). Ignored when saveTo is used."
18245
+ },
18246
+ saveTo: {
18247
+ type: ["boolean", "string"],
18248
+ description: "Save the full untruncated text to a file instead of returning it inline. Pass a file path, an existing directory (generated file inside), or true (generated file under ~/.firefox-devtools-mcp/output/). Relative paths resolve against the current working directory."
18249
+ },
18250
+ preview: {
18251
+ type: "number",
18252
+ description: "Number of characters of the saved text to return inline as a preview when saveTo is used. Omit for no preview."
18253
+ }
18254
+ }
18255
+ }
18256
+ };
17944
18257
  module = defineModule({
17945
18258
  name: "pages",
17946
18259
  description: "Open, navigate, select, and close pages.",
@@ -17949,7 +18262,8 @@ var init_pages2 = __esm({
17949
18262
  [newPageTool, handleNewPage],
17950
18263
  [navigatePageTool, handleNavigatePage],
17951
18264
  [selectPageTool, handleSelectPage],
17952
- [closePageTool, handleClosePage]
18265
+ [closePageTool, handleClosePage],
18266
+ [getPageTextTool, handleGetPageText]
17953
18267
  ]
17954
18268
  });
17955
18269
  }
@@ -17969,65 +18283,6 @@ var init_uid_helpers = __esm({
17969
18283
  }
17970
18284
  });
17971
18285
 
17972
- // src/utils/save-output.ts
17973
- import { mkdir, rename, stat, unlink, writeFile } from "fs/promises";
17974
- import { randomBytes } from "crypto";
17975
- import { homedir as homedir2 } from "os";
17976
- import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve3, sep } from "path";
17977
- function homeRoot() {
17978
- return join3(homedir2(), ".firefox-devtools-mcp");
17979
- }
17980
- function generatedName(baseName, extension) {
17981
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
17982
- return `${baseName}-${timestamp}.${extension}`;
17983
- }
17984
- async function isDirectory(path) {
17985
- try {
17986
- return (await stat(path)).isDirectory();
17987
- } catch {
17988
- return false;
17989
- }
17990
- }
17991
- async function assertAllowedPath(saveTo, resolvedPath) {
17992
- const { args: args2 } = await Promise.resolve().then(() => (init_src(), src_exports));
17993
- if (args2?.unrestrictedSavePaths) {
17994
- return;
17995
- }
17996
- const root = isAbsolute(saveTo) ? homeRoot() : process.cwd();
17997
- if (resolvedPath !== root && !resolvedPath.startsWith(root + sep)) {
17998
- throw new Error(
17999
- `saveTo "${saveTo}" resolves outside the allowed location (${resolvedPath}). Relative paths must stay within the current working directory and absolute paths within ${homeRoot()}. Start the server with --unrestricted-save-paths to write to arbitrary locations.`
18000
- );
18001
- }
18002
- }
18003
- async function saveOutput(content, saveTo, baseName, extension = "json") {
18004
- let resolvedPath;
18005
- if (saveTo) {
18006
- resolvedPath = resolve3(saveTo);
18007
- if (await isDirectory(resolvedPath)) {
18008
- resolvedPath = join3(resolvedPath, generatedName(baseName, extension));
18009
- }
18010
- await assertAllowedPath(saveTo, resolvedPath);
18011
- } else {
18012
- resolvedPath = join3(homeRoot(), "output", generatedName(baseName, extension));
18013
- }
18014
- await mkdir(dirname2(resolvedPath), { recursive: true });
18015
- const tmpPath = `${resolvedPath}.${randomBytes(6).toString("hex")}.tmp`;
18016
- try {
18017
- await writeFile(tmpPath, content);
18018
- await rename(tmpPath, resolvedPath);
18019
- } catch (error2) {
18020
- await unlink(tmpPath).catch(() => void 0);
18021
- throw error2;
18022
- }
18023
- return { path: resolvedPath, bytes: Buffer.byteLength(content) };
18024
- }
18025
- var init_save_output = __esm({
18026
- "src/utils/save-output.ts"() {
18027
- "use strict";
18028
- }
18029
- });
18030
-
18031
18286
  // src/tools/snapshot.ts
18032
18287
  async function handleTakeSnapshot(args2) {
18033
18288
  try {
@@ -18063,6 +18318,9 @@ async function handleTakeSnapshot(args2) {
18063
18318
  if (maxDepth !== void 0) {
18064
18319
  options.maxDepth = maxDepth;
18065
18320
  }
18321
+ if (saveTo) {
18322
+ options.maxAttrLength = Infinity;
18323
+ }
18066
18324
  const formattedText = formatSnapshotTree2(snapshot.json.root, 0, options);
18067
18325
  if (saveTo) {
18068
18326
  const saved = await saveOutput(
@@ -18071,7 +18329,7 @@ async function handleTakeSnapshot(args2) {
18071
18329
  "snapshot",
18072
18330
  "txt"
18073
18331
  );
18074
- let output2 = `Snapshot (id=${snapshot.json.snapshotId}) saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18332
+ let output2 = `Snapshot saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18075
18333
  if (snapshot.json.truncated) {
18076
18334
  output2 += " [DOM truncated]";
18077
18335
  }
@@ -18084,7 +18342,7 @@ async function handleTakeSnapshot(args2) {
18084
18342
  const lines = formattedText.split("\n");
18085
18343
  const truncated = lines.length > maxLines;
18086
18344
  const displayLines = truncated ? lines.slice(0, maxLines) : lines;
18087
- let output = `\u{1F4F8} Snapshot (id=${snapshot.json.snapshotId})`;
18345
+ let output = "Snapshot";
18088
18346
  if (selector) {
18089
18347
  output += ` [selector: ${selector}]`;
18090
18348
  }
@@ -18100,9 +18358,11 @@ async function handleTakeSnapshot(args2) {
18100
18358
  output += "\n\n";
18101
18359
  output += displayLines.join("\n");
18102
18360
  if (truncated) {
18103
- output += `
18104
-
18105
- [+${lines.length - maxLines} lines, use maxLines to see more]`;
18361
+ output += "\n\n" + truncationFooter(lines.length - maxLines, "lines", [
18362
+ "maxLines to show more",
18363
+ "selector to scope",
18364
+ "saveTo to dump the full tree to a file"
18365
+ ]);
18106
18366
  }
18107
18367
  return successResponse(output);
18108
18368
  } catch (error2) {
@@ -18124,7 +18384,7 @@ async function handleResolveUidToSelector(args2) {
18124
18384
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18125
18385
  const firefox3 = await getFirefox2();
18126
18386
  try {
18127
- const selector = firefox3.resolveUidToSelector(uid);
18387
+ const selector = await firefox3.resolveUidToSelector(uid);
18128
18388
  return successResponse(`${uid} \u2192 ${selector}`);
18129
18389
  } catch (error2) {
18130
18390
  throw handleUidError(error2, uid);
@@ -18137,7 +18397,7 @@ async function handleClearSnapshot(_args) {
18137
18397
  try {
18138
18398
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18139
18399
  const firefox3 = await getFirefox2();
18140
- firefox3.clearSnapshot();
18400
+ await firefox3.clearSnapshot();
18141
18401
  return successResponse("\u{1F9F9} Snapshot cleared");
18142
18402
  } catch (error2) {
18143
18403
  return errorResponse(error2);
@@ -18154,7 +18414,7 @@ var init_snapshot2 = __esm({
18154
18414
  DEFAULT_SNAPSHOT_LINES = 100;
18155
18415
  takeSnapshotTool = {
18156
18416
  name: "take_snapshot",
18157
- description: "Capture DOM snapshot with stable UIDs. Retake after navigation.",
18417
+ description: "Capture DOM snapshot with stable UIDs. A UID stays valid across snapshots until its element is removed or the page navigates. Output caps at maxLines (default 100); scope with selector or dump the full tree with saveTo.",
18158
18418
  annotations: {
18159
18419
  readOnlyHint: true
18160
18420
  },
@@ -18198,7 +18458,7 @@ var init_snapshot2 = __esm({
18198
18458
  };
18199
18459
  resolveUidToSelectorTool = {
18200
18460
  name: "resolve_uid_to_selector",
18201
- description: "Resolve UID to CSS selector. Fails if stale.",
18461
+ description: "Resolve UID to CSS selector. Fails if the element is gone.",
18202
18462
  annotations: {
18203
18463
  readOnlyHint: true
18204
18464
  },
@@ -18215,7 +18475,7 @@ var init_snapshot2 = __esm({
18215
18475
  };
18216
18476
  clearSnapshotTool = {
18217
18477
  name: "clear_snapshot",
18218
- description: "Clear snapshot cache. Usually not needed.",
18478
+ description: "Clear snapshot UIDs. Usually not needed.",
18219
18479
  annotations: {
18220
18480
  readOnlyHint: false
18221
18481
  },
@@ -18535,6 +18795,55 @@ var init_input = __esm({
18535
18795
  });
18536
18796
 
18537
18797
  // src/tools/network.ts
18798
+ async function safeFetchBody(firefox3, id, dataType) {
18799
+ if (typeof firefox3.getNetworkRequestBody !== "function") {
18800
+ return { ok: false, reason: "unsupported" };
18801
+ }
18802
+ try {
18803
+ return await firefox3.getNetworkRequestBody(id, dataType);
18804
+ } catch {
18805
+ return { ok: false, reason: "error" };
18806
+ }
18807
+ }
18808
+ function bodyUnavailableMarker(reason) {
18809
+ switch (reason) {
18810
+ case "unsupported":
18811
+ return "<not captured: body collection not supported by this Firefox>";
18812
+ case "not-collected":
18813
+ return "<not captured>";
18814
+ case "evicted":
18815
+ return "<not available: evicted from the capture buffer>";
18816
+ case "aborted":
18817
+ return "<not available: collection aborted>";
18818
+ default:
18819
+ return "<not available>";
18820
+ }
18821
+ }
18822
+ function hasRequestBody(result) {
18823
+ return result.ok || result.reason === "evicted" || result.reason === "aborted";
18824
+ }
18825
+ function renderBodyInline(result) {
18826
+ if (!result.ok) {
18827
+ return { body: bodyUnavailableMarker(result.reason) };
18828
+ }
18829
+ if (result.type === "base64") {
18830
+ const approxKb = (result.value.length * 3 / 4 / 1024).toFixed(1);
18831
+ return {
18832
+ body: `<binary data (~${approxKb}KB); use saveTo to retrieve the full body>`,
18833
+ encoding: "base64"
18834
+ };
18835
+ }
18836
+ return { body: truncateText(result.value, TOKEN_LIMITS.MAX_RESPONSE_CHARS) };
18837
+ }
18838
+ function renderBodyForFile(result) {
18839
+ if (!result.ok) {
18840
+ return { body: null, encoding: null, unavailable: result.reason };
18841
+ }
18842
+ return {
18843
+ body: result.value,
18844
+ encoding: result.type === "base64" ? "base64" : "utf-8"
18845
+ };
18846
+ }
18538
18847
  async function handleListNetworkRequests(args2) {
18539
18848
  try {
18540
18849
  const {
@@ -18643,6 +18952,11 @@ async function handleListNetworkRequests(args2) {
18643
18952
  const effectiveDetail = detail ?? "summary";
18644
18953
  const limitedRequests = requests.slice(0, effectiveLimit);
18645
18954
  const hasMore = requests.length > effectiveLimit;
18955
+ const moreFooter = hasMore ? "\n" + truncationFooter(requests.length - limitedRequests.length, "requests", [
18956
+ "limit to show more",
18957
+ "urlContains/method/status to filter",
18958
+ "saveTo to save all to a file"
18959
+ ]) : "";
18646
18960
  if (format === "json") {
18647
18961
  const responseData = {
18648
18962
  total: requests.length,
@@ -18684,7 +18998,7 @@ async function handleListNetworkRequests(args2) {
18684
18998
  });
18685
18999
  const header = `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18686
19000
  `;
18687
- return successResponse(header + formattedRequests.join("\n"));
19001
+ return successResponse(header + formattedRequests.join("\n") + moreFooter);
18688
19002
  } else if (effectiveDetail === "min") {
18689
19003
  const minData = limitedRequests.map((req) => ({
18690
19004
  id: req.id,
@@ -18698,7 +19012,7 @@ async function handleListNetworkRequests(args2) {
18698
19012
  }));
18699
19013
  return successResponse(
18700
19014
  `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18701
- ` + JSON.stringify(minData, null, 2)
19015
+ ` + JSON.stringify(minData, null, 2) + moreFooter
18702
19016
  );
18703
19017
  } else {
18704
19018
  const fullData = limitedRequests.map((req) => ({
@@ -18715,7 +19029,7 @@ async function handleListNetworkRequests(args2) {
18715
19029
  }));
18716
19030
  return successResponse(
18717
19031
  `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18718
- ` + JSON.stringify(fullData, null, 2)
19032
+ ` + JSON.stringify(fullData, null, 2) + moreFooter
18719
19033
  );
18720
19034
  }
18721
19035
  } catch (error2) {
@@ -18764,8 +19078,29 @@ async function handleGetNetworkRequest(args2) {
18764
19078
  requestHeaders: request.requestHeaders ?? null,
18765
19079
  responseHeaders: request.responseHeaders ?? null
18766
19080
  };
19081
+ const [responseBodyResult, requestBodyResult] = await Promise.all([
19082
+ safeFetchBody(firefox3, request.id, "response"),
19083
+ safeFetchBody(firefox3, request.id, "request")
19084
+ ]);
18767
19085
  if (saveTo) {
18768
- const fileBody = JSON.stringify(fullDetails, null, 2);
19086
+ const responseFile = renderBodyForFile(responseBodyResult);
19087
+ const fileObject = {
19088
+ ...fullDetails,
19089
+ responseBody: responseFile.body,
19090
+ responseBodyEncoding: responseFile.encoding
19091
+ };
19092
+ if (responseFile.unavailable) {
19093
+ fileObject.responseBodyUnavailable = responseFile.unavailable;
19094
+ }
19095
+ if (hasRequestBody(requestBodyResult)) {
19096
+ const requestFile = renderBodyForFile(requestBodyResult);
19097
+ fileObject.requestBody = requestFile.body;
19098
+ fileObject.requestBodyEncoding = requestFile.encoding;
19099
+ if (requestFile.unavailable) {
19100
+ fileObject.requestBodyUnavailable = requestFile.unavailable;
19101
+ }
19102
+ }
19103
+ const fileBody = JSON.stringify(fileObject, null, 2);
18769
19104
  const saved = await saveOutput(
18770
19105
  fileBody,
18771
19106
  saveTo === true ? void 0 : saveTo,
@@ -18778,11 +19113,23 @@ async function handleGetNetworkRequest(args2) {
18778
19113
  }
18779
19114
  return successResponse(output);
18780
19115
  }
19116
+ const responseInline = renderBodyInline(responseBodyResult);
18781
19117
  const details = {
18782
19118
  ...fullDetails,
18783
19119
  requestHeaders: truncateHeaders(request.requestHeaders),
18784
- responseHeaders: truncateHeaders(request.responseHeaders)
19120
+ responseHeaders: truncateHeaders(request.responseHeaders),
19121
+ responseBody: responseInline.body
18785
19122
  };
19123
+ if (responseInline.encoding) {
19124
+ details.responseBodyEncoding = responseInline.encoding;
19125
+ }
19126
+ if (hasRequestBody(requestBodyResult)) {
19127
+ const requestInline = renderBodyInline(requestBodyResult);
19128
+ details.requestBody = requestInline.body;
19129
+ if (requestInline.encoding) {
19130
+ details.requestBodyEncoding = requestInline.encoding;
19131
+ }
19132
+ }
18786
19133
  if (format === "json") {
18787
19134
  return jsonResponse(details);
18788
19135
  }
@@ -18800,7 +19147,7 @@ var init_network2 = __esm({
18800
19147
  init_module();
18801
19148
  listNetworkRequestsTool = {
18802
19149
  name: "list_network_requests",
18803
- description: "List network requests. Returns IDs for get_network_request.",
19150
+ description: "List network requests, returning IDs for get_network_request. Filter by url/method/status; caps at limit (default 50); saveTo saves all matches to a file.",
18804
19151
  annotations: {
18805
19152
  readOnlyHint: true
18806
19153
  },
@@ -18871,7 +19218,7 @@ var init_network2 = __esm({
18871
19218
  };
18872
19219
  getNetworkRequestTool = {
18873
19220
  name: "get_network_request",
18874
- description: "Get request details by ID. URL lookup as fallback.",
19221
+ description: "Get request details by ID, including the response body (and request body when present). Large text bodies are truncated inline; binary bodies are summarized. URL lookup as fallback.",
18875
19222
  annotations: {
18876
19223
  readOnlyHint: true
18877
19224
  },
@@ -18893,7 +19240,7 @@ var init_network2 = __esm({
18893
19240
  },
18894
19241
  saveTo: {
18895
19242
  type: ["boolean", "string"],
18896
- description: "Save the request details with full untruncated headers to a file as JSON instead of returning them inline. Pass a file path, an existing directory (generated file inside), or true (generated file under ~/.firefox-devtools-mcp/output/). Relative paths resolve against the current working directory."
19243
+ description: "Save the request details with full untruncated headers and bodies to a file as JSON instead of returning them inline (binary bodies are stored base64-encoded). Pass a file path, an existing directory (generated file inside), or true (generated file under ~/.firefox-devtools-mcp/output/). Relative paths resolve against the current working directory."
18897
19244
  },
18898
19245
  preview: {
18899
19246
  type: "number",
@@ -19072,8 +19419,11 @@ Total messages: ${totalCount}${filterInfo.length > 0 ? `, Filters: ${filterInfo.
19072
19419
  `;
19073
19420
  }
19074
19421
  if (truncated) {
19075
- output += `
19076
- [+${filteredCount - messages.length} more]`;
19422
+ output += "\n" + truncationFooter(filteredCount - messages.length, "messages", [
19423
+ "limit to show more",
19424
+ "level/textContains/source to filter",
19425
+ "saveTo to save all to a file"
19426
+ ]);
19077
19427
  }
19078
19428
  return successResponse(output);
19079
19429
  } catch (error2) {
@@ -19100,7 +19450,7 @@ var init_console2 = __esm({
19100
19450
  init_module();
19101
19451
  listConsoleMessagesTool = {
19102
19452
  name: "list_console_messages",
19103
- description: "List console messages. Supports filtering by level, time, text, source.",
19453
+ description: "List console messages, filterable by level, time, text, source. Caps at limit (default 50); saveTo saves all matches to a file.",
19104
19454
  annotations: {
19105
19455
  readOnlyHint: true
19106
19456
  },
@@ -19570,7 +19920,7 @@ var init_utilities = __esm({
19570
19920
  });
19571
19921
 
19572
19922
  // src/tools/firefox-management.ts
19573
- import { readFileSync as readFileSync2, existsSync as existsSync3, statSync as statSync2 } from "fs";
19923
+ import { readFileSync as readFileSync3, existsSync as existsSync3, statSync as statSync2 } from "fs";
19574
19924
  async function handleGetFirefoxLogs(input) {
19575
19925
  try {
19576
19926
  const {
@@ -19598,7 +19948,7 @@ async function handleGetFirefoxLogs(input) {
19598
19948
  );
19599
19949
  }
19600
19950
  }
19601
- const content = readFileSync2(logFilePath, "utf-8");
19951
+ const content = readFileSync3(logFilePath, "utf-8");
19602
19952
  let allLines = content.split("\n").filter((line) => line.trim().length > 0);
19603
19953
  if (grep) {
19604
19954
  const grepLower = grep.toLowerCase();
@@ -20488,67 +20838,6 @@ var init_screencast = __esm({
20488
20838
  }
20489
20839
  });
20490
20840
 
20491
- // src/utils/remote-value.ts
20492
- function remoteValueToNative(rv) {
20493
- if (!rv || typeof rv !== "object") {
20494
- return rv;
20495
- }
20496
- const { type, value } = rv;
20497
- switch (type) {
20498
- case "undefined":
20499
- return void 0;
20500
- case "null":
20501
- return null;
20502
- case "string":
20503
- case "boolean":
20504
- return value;
20505
- case "number":
20506
- if (value === "NaN") {
20507
- return "NaN";
20508
- }
20509
- if (value === "Infinity") {
20510
- return "Infinity";
20511
- }
20512
- if (value === "-Infinity") {
20513
- return "-Infinity";
20514
- }
20515
- if (value === "-0") {
20516
- return "-0";
20517
- }
20518
- return value;
20519
- case "bigint":
20520
- return `${value}n`;
20521
- case "array":
20522
- return value.map(remoteValueToNative);
20523
- case "object":
20524
- return Object.fromEntries(
20525
- value.map(([k, v]) => [k, remoteValueToNative(v)])
20526
- );
20527
- case "map":
20528
- return Object.fromEntries(
20529
- value.map(([k, v]) => [
20530
- typeof k === "object" ? JSON.stringify(remoteValueToNative(k)) : String(k),
20531
- remoteValueToNative(v)
20532
- ])
20533
- );
20534
- case "set":
20535
- return value.map(remoteValueToNative);
20536
- case "regexp": {
20537
- const { pattern, flags } = value;
20538
- return `/${pattern}/${flags ?? ""}`;
20539
- }
20540
- case "date":
20541
- return value;
20542
- default:
20543
- return `[${type}]`;
20544
- }
20545
- }
20546
- var init_remote_value = __esm({
20547
- "src/utils/remote-value.ts"() {
20548
- "use strict";
20549
- }
20550
- });
20551
-
20552
20841
  // src/utils/js-validation.ts
20553
20842
  function validateFunction(fnString) {
20554
20843
  if (!fnString || typeof fnString !== "string") {
@@ -20588,6 +20877,7 @@ async function handleEvaluateScript(args2) {
20588
20877
  function: fnString,
20589
20878
  args: fnArgs,
20590
20879
  timeout,
20880
+ sandbox,
20591
20881
  saveTo,
20592
20882
  preview
20593
20883
  } = args2;
@@ -20619,7 +20909,7 @@ Please call take_snapshot to get fresh UIDs and try again.`
20619
20909
  functionDeclaration: fnString,
20620
20910
  awaitPromise: true,
20621
20911
  arguments: resolvedArgs,
20622
- target: { context: firefox3.getCurrentContextId() }
20912
+ target: { context: firefox3.getCurrentContextId(), ...sandbox !== void 0 && { sandbox } }
20623
20913
  });
20624
20914
  const result = await Promise.race([
20625
20915
  new Promise((r) => setTimeout(() => r(TIMEOUT), scriptTimeout)),
@@ -20678,7 +20968,7 @@ var init_script = __esm({
20678
20968
  init_module();
20679
20969
  evaluateScriptTool = {
20680
20970
  name: "evaluate_script",
20681
- description: "Execute JS function in page. Prefer UID tools for interactions.",
20971
+ description: "Run a JS function in the page and return its result. Prefer this for targeted reads (a value, text, computed style, whether an element exists) instead of a full take_snapshot. Use the UID interaction tools for clicking, typing, and filling.",
20682
20972
  annotations: {
20683
20973
  readOnlyHint: false
20684
20974
  },
@@ -20707,6 +20997,10 @@ var init_script = __esm({
20707
20997
  type: "number",
20708
20998
  description: "Timeout in ms (default: 5000)"
20709
20999
  },
21000
+ sandbox: {
21001
+ type: "string",
21002
+ description: "Evaluate in an isolated sandbox realm with this name instead of the page realm. The sandbox shares the page DOM and keeps the native built-ins even where the page overrode them. Page-defined globals and expandos are invisible from the sandbox, and vice-versa. The same name reuses the same sandbox across calls; omit to evaluate in the page realm."
21003
+ },
20710
21004
  saveTo: {
20711
21005
  type: ["boolean", "string"],
20712
21006
  description: "Save the result to a file as JSON instead of returning it inline. Pass a file path, an existing directory (generated file inside), or true (generated file under ~/.firefox-devtools-mcp/output/). Relative paths resolve against the current working directory."
@@ -21182,6 +21476,25 @@ function formatContextList(contexts) {
21182
21476
  }
21183
21477
  return lines.join("\n");
21184
21478
  }
21479
+ async function assertPrivilegedContext(firefox3, contextId) {
21480
+ let result;
21481
+ try {
21482
+ result = await firefox3.sendBiDiCommand("browsingContext.getTree", {
21483
+ "moz:scope": "chrome"
21484
+ });
21485
+ } catch (error2) {
21486
+ if (error2 instanceof Error && error2.message.includes("UnsupportedOperationError")) {
21487
+ throw new Error(SYSTEM_ACCESS_ERROR);
21488
+ }
21489
+ throw error2;
21490
+ }
21491
+ const contexts = result.contexts || [];
21492
+ if (!contexts.some((ctx) => ctx.context === contextId)) {
21493
+ throw new Error(
21494
+ `${contextId} is not a privileged context. Use list_privileged_contexts to see valid ids.`
21495
+ );
21496
+ }
21497
+ }
21185
21498
  async function handleListPrivilegedContexts(_args) {
21186
21499
  try {
21187
21500
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
@@ -21193,11 +21506,7 @@ async function handleListPrivilegedContexts(_args) {
21193
21506
  return successResponse(formatContextList(contexts));
21194
21507
  } catch (error2) {
21195
21508
  if (error2 instanceof Error && error2.message.includes("UnsupportedOperationError")) {
21196
- return errorResponse(
21197
- new Error(
21198
- "Privileged context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox."
21199
- )
21200
- );
21509
+ return errorResponse(new Error(SYSTEM_ACCESS_ERROR));
21201
21510
  }
21202
21511
  return errorResponse(error2);
21203
21512
  }
@@ -21210,6 +21519,7 @@ async function handleSelectPrivilegedContext(args2) {
21210
21519
  }
21211
21520
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
21212
21521
  const firefox3 = await getFirefox2();
21522
+ await assertPrivilegedContext(firefox3, contextId);
21213
21523
  const driver = firefox3.getDriver();
21214
21524
  await driver.switchTo().window(contextId);
21215
21525
  try {
@@ -21233,17 +21543,22 @@ async function handleEvaluatePrivilegedScript(args2) {
21233
21543
  try {
21234
21544
  const {
21235
21545
  function: fnString,
21546
+ context,
21236
21547
  saveTo,
21237
21548
  preview
21238
21549
  } = args2;
21239
21550
  validateFunction(fnString);
21551
+ if (!context || typeof context !== "string") {
21552
+ throw new Error("context parameter is required and must be a string");
21553
+ }
21240
21554
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
21241
21555
  const firefox3 = await getFirefox2();
21556
+ await assertPrivilegedContext(firefox3, context);
21242
21557
  const result = await firefox3.sendBiDiCommand("script.callFunction", {
21243
21558
  functionDeclaration: fnString,
21244
21559
  awaitPromise: true,
21245
21560
  arguments: [],
21246
- target: { context: firefox3.getCurrentContextId() }
21561
+ target: { context }
21247
21562
  });
21248
21563
  if (result.type === EvaluateResultType2.Success) {
21249
21564
  const json = JSON.stringify(remoteValueToNative(result.result), null, 2) ?? "undefined";
@@ -21280,7 +21595,7 @@ async function handleEvaluatePrivilegedScript(args2) {
21280
21595
  return errorResponse(error2);
21281
21596
  }
21282
21597
  }
21283
- var listPrivilegedContextsTool, selectPrivilegedContextTool, evaluatePrivilegedScriptTool, EvaluateResultType2, module16;
21598
+ var listPrivilegedContextsTool, selectPrivilegedContextTool, evaluatePrivilegedScriptTool, SYSTEM_ACCESS_ERROR, EvaluateResultType2, module16;
21284
21599
  var init_privileged_context = __esm({
21285
21600
  "src/tools/privileged-context.ts"() {
21286
21601
  "use strict";
@@ -21320,7 +21635,7 @@ var init_privileged_context = __esm({
21320
21635
  };
21321
21636
  evaluatePrivilegedScriptTool = {
21322
21637
  name: "evaluate_privileged_script",
21323
- description: "Execute JS function in the current privileged context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Use select_privileged_context first to target a chrome context.",
21638
+ description: "Execute JS function in a privileged (chrome) browsing context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Get context ids from list_privileged_contexts.",
21324
21639
  annotations: {
21325
21640
  readOnlyHint: false
21326
21641
  },
@@ -21331,6 +21646,10 @@ var init_privileged_context = __esm({
21331
21646
  type: "string",
21332
21647
  description: 'JS function string, e.g. () => Services.prefs.getBoolPref("foo")'
21333
21648
  },
21649
+ context: {
21650
+ type: "string",
21651
+ description: "Privileged browsing context ID from list_privileged_contexts"
21652
+ },
21334
21653
  saveTo: {
21335
21654
  type: ["boolean", "string"],
21336
21655
  description: "Save the result to a file as JSON instead of returning it inline. Pass a file path, an existing directory (generated file inside), or true (generated file under ~/.firefox-devtools-mcp/output/). Relative paths resolve against the current working directory."
@@ -21340,9 +21659,10 @@ var init_privileged_context = __esm({
21340
21659
  description: "Number of characters of the saved result to return inline as a preview when saveTo is used. Omit for no preview."
21341
21660
  }
21342
21661
  },
21343
- required: ["function"]
21662
+ required: ["function", "context"]
21344
21663
  }
21345
21664
  };
21665
+ SYSTEM_ACCESS_ERROR = "Privileged context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.";
21346
21666
  EvaluateResultType2 = {
21347
21667
  Exception: "exception",
21348
21668
  Success: "success"
@@ -21547,6 +21867,12 @@ var init_cli = __esm({
21547
21867
  description: "Marionette port to connect to when using --connect-existing (default: 2828)",
21548
21868
  default: Number(process.env.MARIONETTE_PORT ?? "2828")
21549
21869
  },
21870
+ lookupMarionettePort: {
21871
+ type: "boolean",
21872
+ hidden: true,
21873
+ description: "Lookup the port of a Marionette instance started by Firefox's AI assistant companion, instead of using --marionette-port. Only applies when --connect-existing is set.",
21874
+ default: (process.env.LOOKUP_MARIONETTE_PORT ?? "false") === "true"
21875
+ },
21550
21876
  env: {
21551
21877
  type: "array",
21552
21878
  description: "Environment variables for Firefox in KEY=VALUE format. Can be specified multiple times. Example: --env MOZ_LOG=HTMLMediaElement:4"
@@ -21570,6 +21896,11 @@ var init_cli = __esm({
21570
21896
  description: "Android app package name (default: org.mozilla.firefox). Use org.mozilla.fenix for Nightly.",
21571
21897
  default: process.env.ANDROID_PACKAGE ?? "org.mozilla.firefox"
21572
21898
  },
21899
+ androidWipeAppData: {
21900
+ type: "boolean",
21901
+ description: "Confirm that connecting to Firefox for Android wipes all data of the target app (tabs, history, bookmarks, passwords, settings). Required with --android-device.",
21902
+ default: (process.env.ANDROID_WIPE_APP_DATA ?? "false") === "true"
21903
+ },
21573
21904
  logFile: {
21574
21905
  type: "string",
21575
21906
  description: "Path to a file where MCP server logs will be written. Set DEBUG=* to also enable verbose debug logs."
@@ -21604,6 +21935,12 @@ var init_cli = __esm({
21604
21935
  type: "boolean",
21605
21936
  description: "Allow tools that save files (e.g. screenshots, snapshots, network/console output) to write to arbitrary locations. By default, relative save paths resolve against the current working directory and absolute save paths are restricted to ~/.firefox-devtools-mcp; this flag lifts both restrictions. Use with caution.",
21606
21937
  default: (process.env.UNRESTRICTED_SAVE_PATHS ?? "false") === "true"
21938
+ },
21939
+ disableNetworkBodyCollection: {
21940
+ type: "boolean",
21941
+ hidden: true,
21942
+ description: "Disable capturing network request/response bodies via BiDi data collectors. get_network_request will still return metadata and headers but no bodies. Reduces browser memory and stream-cloning overhead.",
21943
+ default: (process.env.DISABLE_NETWORK_BODY_COLLECTION ?? "false") === "true"
21607
21944
  }
21608
21945
  };
21609
21946
  }