@mozilla/firefox-devtools-mcp 0.9.15 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0" : "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);
@@ -15397,6 +15479,67 @@ var init_core3 = __esm({
15397
15479
  }
15398
15480
  });
15399
15481
 
15482
+ // src/utils/remote-value.ts
15483
+ function remoteValueToNative(rv) {
15484
+ if (!rv || typeof rv !== "object") {
15485
+ return rv;
15486
+ }
15487
+ const { type, value } = rv;
15488
+ switch (type) {
15489
+ case "undefined":
15490
+ return void 0;
15491
+ case "null":
15492
+ return null;
15493
+ case "string":
15494
+ case "boolean":
15495
+ return value;
15496
+ case "number":
15497
+ if (value === "NaN") {
15498
+ return "NaN";
15499
+ }
15500
+ if (value === "Infinity") {
15501
+ return "Infinity";
15502
+ }
15503
+ if (value === "-Infinity") {
15504
+ return "-Infinity";
15505
+ }
15506
+ if (value === "-0") {
15507
+ return "-0";
15508
+ }
15509
+ return value;
15510
+ case "bigint":
15511
+ return `${value}n`;
15512
+ case "array":
15513
+ return value.map(remoteValueToNative);
15514
+ case "object":
15515
+ return Object.fromEntries(
15516
+ value.map(([k, v]) => [k, remoteValueToNative(v)])
15517
+ );
15518
+ case "map":
15519
+ return Object.fromEntries(
15520
+ value.map(([k, v]) => [
15521
+ typeof k === "object" ? JSON.stringify(remoteValueToNative(k)) : String(k),
15522
+ remoteValueToNative(v)
15523
+ ])
15524
+ );
15525
+ case "set":
15526
+ return value.map(remoteValueToNative);
15527
+ case "regexp": {
15528
+ const { pattern, flags } = value;
15529
+ return `/${pattern}/${flags ?? ""}`;
15530
+ }
15531
+ case "date":
15532
+ return value;
15533
+ default:
15534
+ return `[${type}]`;
15535
+ }
15536
+ }
15537
+ var init_remote_value = __esm({
15538
+ "src/utils/remote-value.ts"() {
15539
+ "use strict";
15540
+ }
15541
+ });
15542
+
15400
15543
  // src/firefox/events/console.ts
15401
15544
  var MAX_CONSOLE_MESSAGES, CONSOLE_TTL_MS, ConsoleEvents;
15402
15545
  var init_console = __esm({
@@ -15454,9 +15597,6 @@ var init_console = __esm({
15454
15597
  if (this.options.autoClearOnNavigate) {
15455
15598
  this.clearMessages();
15456
15599
  }
15457
- if (this.options.onNavigate) {
15458
- this.options.onNavigate();
15459
- }
15460
15600
  }
15461
15601
  } catch {
15462
15602
  }
@@ -15499,16 +15639,18 @@ var init_console = __esm({
15499
15639
  });
15500
15640
 
15501
15641
  // src/firefox/events/network.ts
15502
- var MAX_NETWORK_REQUESTS, NETWORK_TTL_MS, NetworkEvents;
15642
+ var MAX_NETWORK_REQUESTS, NETWORK_TTL_MS, MAX_ENCODED_DATA_SIZE, NetworkEvents;
15503
15643
  var init_network = __esm({
15504
15644
  "src/firefox/events/network.ts"() {
15505
15645
  "use strict";
15506
15646
  init_logger();
15507
15647
  MAX_NETWORK_REQUESTS = 1e3;
15508
15648
  NETWORK_TTL_MS = 5 * 60 * 1e3;
15649
+ MAX_ENCODED_DATA_SIZE = 10 * 1e3 * 1e3;
15509
15650
  NetworkEvents = class {
15510
- constructor(driver, options = {}) {
15651
+ constructor(driver, options = {}, sendCommand) {
15511
15652
  this.driver = driver;
15653
+ this.sendCommand = sendCommand;
15512
15654
  this.options = {
15513
15655
  autoClearOnNavigate: true,
15514
15656
  ...options
@@ -15519,6 +15661,7 @@ var init_network = __esm({
15519
15661
  enabled = false;
15520
15662
  requestStartTimes = /* @__PURE__ */ new Map();
15521
15663
  options;
15664
+ collectorId = null;
15522
15665
  /**
15523
15666
  * Subscribe to BiDi network events and navigation lifecycle
15524
15667
  * Enables monitoring by default (always-on capture)
@@ -15547,9 +15690,6 @@ var init_network = __esm({
15547
15690
  if (this.enabled && this.options.autoClearOnNavigate) {
15548
15691
  this.clearRequests();
15549
15692
  }
15550
- if (this.options.onNavigate) {
15551
- this.options.onNavigate();
15552
- }
15553
15693
  return;
15554
15694
  }
15555
15695
  if (!this.enabled) {
@@ -15611,10 +15751,77 @@ var init_network = __esm({
15611
15751
  } catch {
15612
15752
  }
15613
15753
  });
15754
+ await this.registerDataCollector();
15614
15755
  this.subscribed = true;
15615
15756
  this.enabled = true;
15616
15757
  logDebug("Network listener ready with lifecycle hooks (monitoring enabled by default)");
15617
15758
  }
15759
+ /**
15760
+ * Register a BiDi network data collector for request and response bodies.
15761
+ * Failures are non-fatal and simply leave body capture disabled.
15762
+ */
15763
+ async registerDataCollector() {
15764
+ if (this.options.captureBodies === false) {
15765
+ logDebug("Network body capture disabled, skipping data collector registration");
15766
+ return;
15767
+ }
15768
+ if (!this.sendCommand) {
15769
+ return;
15770
+ }
15771
+ try {
15772
+ const result = await this.sendCommand("network.addDataCollector", {
15773
+ dataTypes: ["request", "response"],
15774
+ maxEncodedDataSize: MAX_ENCODED_DATA_SIZE
15775
+ });
15776
+ this.collectorId = result?.collector ?? null;
15777
+ if (this.collectorId) {
15778
+ logDebug(`Network data collector registered (${this.collectorId})`);
15779
+ }
15780
+ } catch (error2) {
15781
+ logDebug(
15782
+ `Network data collector unavailable, response bodies will not be captured: ${error2 instanceof Error ? error2.message : String(error2)}`
15783
+ );
15784
+ }
15785
+ }
15786
+ /**
15787
+ * Fetch a captured request or response body via network.getData.
15788
+ * Returns a structured result so callers can render an appropriate marker
15789
+ * when the body was never collected, evicted, or the browser lacks support.
15790
+ */
15791
+ async fetchBody(requestId, dataType) {
15792
+ if (!this.sendCommand || !this.collectorId) {
15793
+ return { ok: false, reason: "unsupported" };
15794
+ }
15795
+ try {
15796
+ const result = await this.sendCommand("network.getData", {
15797
+ request: requestId,
15798
+ dataType
15799
+ });
15800
+ const bytes = result?.bytes;
15801
+ if (!bytes || typeof bytes.value !== "string") {
15802
+ return { ok: false, reason: "not-collected" };
15803
+ }
15804
+ return {
15805
+ ok: true,
15806
+ type: bytes.type === "base64" ? "base64" : "string",
15807
+ value: bytes.value
15808
+ };
15809
+ } catch (error2) {
15810
+ const message = (error2 instanceof Error ? error2.message : String(error2)).toLowerCase();
15811
+ if (message.includes("no such network data")) {
15812
+ return { ok: false, reason: "not-collected" };
15813
+ }
15814
+ if (message.includes("unavailable network data")) {
15815
+ if (message.includes("evicted")) {
15816
+ return { ok: false, reason: "evicted" };
15817
+ }
15818
+ if (message.includes("aborted")) {
15819
+ return { ok: false, reason: "aborted" };
15820
+ }
15821
+ }
15822
+ return { ok: false, reason: "error" };
15823
+ }
15824
+ }
15618
15825
  /**
15619
15826
  * Start collecting network requests
15620
15827
  */
@@ -16033,19 +16240,6 @@ var init_dom = __esm({
16033
16240
  this.driver = driver;
16034
16241
  this.resolveUid = resolveUid;
16035
16242
  }
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
16243
  // ============================================================================
16050
16244
  // Element polling helpers
16051
16245
  // ============================================================================
@@ -16509,7 +16703,12 @@ __export(formatter_exports, {
16509
16703
  formatSnapshotTree: () => formatSnapshotTree
16510
16704
  });
16511
16705
  function formatSnapshotTree(node, depth = 0, options = {}) {
16512
- const { includeAttributes = true, includeText = true, maxDepth } = options;
16706
+ const {
16707
+ includeAttributes = true,
16708
+ includeText = true,
16709
+ maxDepth,
16710
+ maxAttrLength = MAX_ATTR_LENGTH
16711
+ } = options;
16513
16712
  if (maxDepth !== void 0 && depth >= maxDepth) {
16514
16713
  return "";
16515
16714
  }
@@ -16519,22 +16718,22 @@ function formatSnapshotTree(node, depth = 0, options = {}) {
16519
16718
  const role = node.role || node.tag;
16520
16719
  attrs.push(role);
16521
16720
  if (node.name) {
16522
- attrs.push(`"${truncate(node.name, MAX_ATTR_LENGTH)}"`);
16721
+ attrs.push(`"${truncate(node.name, maxAttrLength)}"`);
16523
16722
  }
16524
16723
  if (node.role && node.role !== node.tag) {
16525
16724
  attrs.push(`tag=${node.tag}`);
16526
16725
  }
16527
16726
  if (node.value) {
16528
- attrs.push(`value="${truncate(node.value, MAX_ATTR_LENGTH)}"`);
16727
+ attrs.push(`value="${truncate(node.value, maxAttrLength)}"`);
16529
16728
  }
16530
16729
  if (node.href) {
16531
- attrs.push(`href="${truncate(node.href, MAX_ATTR_LENGTH)}"`);
16730
+ attrs.push(`href="${truncate(node.href, maxAttrLength)}"`);
16532
16731
  }
16533
16732
  if (node.src) {
16534
- attrs.push(`src="${truncate(node.src, MAX_ATTR_LENGTH)}"`);
16733
+ attrs.push(`src="${truncate(node.src, maxAttrLength)}"`);
16535
16734
  }
16536
16735
  if (includeText && node.text) {
16537
- attrs.push(`text="${truncate(node.text, MAX_ATTR_LENGTH)}"`);
16736
+ attrs.push(`text="${truncate(node.text, maxAttrLength)}"`);
16538
16737
  }
16539
16738
  if (includeAttributes && node.aria) {
16540
16739
  if (node.aria.disabled) {
@@ -16593,7 +16792,7 @@ function formatSnapshotTree(node, depth = 0, options = {}) {
16593
16792
  if (node.isIframe) {
16594
16793
  attrs.push("[iframe");
16595
16794
  if (node.frameSrc) {
16596
- attrs.push(`src="${truncate(node.frameSrc, MAX_ATTR_LENGTH)}"`);
16795
+ attrs.push(`src="${truncate(node.frameSrc, maxAttrLength)}"`);
16597
16796
  }
16598
16797
  if (node.crossOrigin) {
16599
16798
  attrs.push("cross-origin");
@@ -16621,140 +16820,60 @@ var init_formatter = __esm({
16621
16820
  });
16622
16821
 
16623
16822
  // src/firefox/snapshot/resolver.ts
16624
- import { By as By2 } from "selenium-webdriver";
16625
- var UidResolver;
16823
+ function notFoundMessage(uid) {
16824
+ return `UID not found: ${uid}. The element is gone from the page, or the page was reloaded. Take a fresh snapshot first.`;
16825
+ }
16826
+ var RESOLVE_SCRIPT, SELECTOR_SCRIPT, CLEAR_SCRIPT, UidResolver;
16626
16827
  var init_resolver = __esm({
16627
16828
  "src/firefox/snapshot/resolver.ts"() {
16628
16829
  "use strict";
16629
16830
  init_logger();
16831
+ RESOLVE_SCRIPT = "return window.__resolveUid ? window.__resolveUid(arguments[0]) : null;";
16832
+ SELECTOR_SCRIPT = "return window.__uidToSelector ? window.__uidToSelector(arguments[0]) : null;";
16833
+ CLEAR_SCRIPT = "if (window.__clearUidRegistry) { window.__clearUidRegistry(); }";
16630
16834
  UidResolver = class {
16631
16835
  constructor(driver) {
16632
16836
  this.driver = driver;
16633
16837
  }
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
- /**
16644
- * Get current snapshot ID
16645
- */
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
16838
  /**
16667
- * Validate UID (staleness check)
16839
+ * Forget all UID associations in the page, making existing UIDs unresolvable.
16840
+ * Best effort: the registry dies with the page anyway.
16668
16841
  */
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
- );
16842
+ async clear() {
16843
+ try {
16844
+ await this.driver.executeScript(CLEAR_SCRIPT);
16845
+ logDebug("Snapshot UIDs cleared");
16846
+ } catch {
16847
+ logDebug("Unable to clear snapshot UIDs (page may be navigating)");
16682
16848
  }
16683
16849
  }
16684
16850
  /**
16685
- * Resolve UID to CSS selector (with staleness check)
16851
+ * Resolve UID to a CSS selector, generated on demand from the element it points at
16686
16852
  */
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.`);
16853
+ async resolveUidToSelector(uid) {
16854
+ const selector = await this.driver.executeScript(SELECTOR_SCRIPT, uid);
16855
+ if (!selector) {
16856
+ throw new Error(notFoundMessage(uid));
16692
16857
  }
16693
- return entry.css;
16858
+ return selector;
16694
16859
  }
16695
16860
  /**
16696
- * Resolve UID to element (with staleness check and caching)
16697
- * Tries CSS first, falls back to XPath
16861
+ * Resolve UID to the element it was assigned to during the snapshot
16698
16862
  */
16699
16863
  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
- );
16864
+ const element = await this.driver.executeScript(RESOLVE_SCRIPT, uid);
16865
+ if (!element) {
16866
+ throw new Error(notFoundMessage(uid));
16750
16867
  }
16868
+ logDebug(`Resolved element for UID: ${uid}`);
16869
+ return element;
16751
16870
  }
16752
16871
  };
16753
16872
  }
16754
16873
  });
16755
16874
 
16756
16875
  // src/firefox/snapshot/manager.ts
16757
- import { readFileSync } from "fs";
16876
+ import { readFileSync as readFileSync2 } from "fs";
16758
16877
  import { dirname, resolve } from "path";
16759
16878
  import { fileURLToPath } from "url";
16760
16879
  var SnapshotManager;
@@ -16768,7 +16887,8 @@ var init_manager = __esm({
16768
16887
  driver;
16769
16888
  resolver;
16770
16889
  injectedScript = null;
16771
- currentSnapshotId = 0;
16890
+ /** Counter handed to the injected script so UIDs stay unique across snapshots */
16891
+ nextElementId = 0;
16772
16892
  constructor(driver) {
16773
16893
  this.driver = driver;
16774
16894
  this.resolver = new UidResolver(driver);
@@ -16796,7 +16916,7 @@ var init_manager = __esm({
16796
16916
  for (const path of possiblePaths) {
16797
16917
  attemptedPaths.push(path);
16798
16918
  try {
16799
- this.injectedScript = readFileSync(path, "utf-8");
16919
+ this.injectedScript = readFileSync2(path, "utf-8");
16800
16920
  const sizeKB = (this.injectedScript.length / 1024).toFixed(1);
16801
16921
  logDebug(`\u2713 Loaded snapshot bundle: ${path.split("/").pop()} (${sizeKB} KB)`);
16802
16922
  return this.injectedScript;
@@ -16815,14 +16935,25 @@ ${attemptedPaths.map((p) => ` - ${p}`).join("\n")}`
16815
16935
  }
16816
16936
  /**
16817
16937
  * Take a snapshot of the current page
16818
- * Returns text and JSON with snapshotId, no DOM mutations
16938
+ * Returns text and JSON, no DOM mutations
16819
16939
  */
16820
16940
  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);
16941
+ if (options?.selector || options?.includeAll) {
16942
+ const optionsOutput = [];
16943
+ if (options.selector) {
16944
+ optionsOutput.push(`selector: ${options.selector}`);
16945
+ }
16946
+ if (options.includeAll) {
16947
+ optionsOutput.push("include all");
16948
+ }
16949
+ logDebug(`Taking snapshot (${optionsOutput.join(", ")})...`);
16950
+ } else {
16951
+ logDebug("Taking snapshot...");
16952
+ }
16953
+ const result = await this.executeInjectedScript(this.nextElementId, options);
16954
+ if (typeof result?.nextElementId === "number") {
16955
+ this.nextElementId = result.nextElementId;
16956
+ }
16826
16957
  logDebug(
16827
16958
  `Snapshot executeScript result: hasResult=${!!result}, hasTree=${!!result?.tree}, truncated=${result?.truncated || false}`
16828
16959
  );
@@ -16844,60 +16975,60 @@ ${attemptedPaths.map((p) => ` - ${p}`).join("\n")}`
16844
16975
  logDebug(`Snapshot generation failed: ${errorMsg}`);
16845
16976
  throw new Error(`Failed to generate snapshot: ${errorMsg}`);
16846
16977
  }
16847
- this.resolver.storeUidMappings(result.uidMap);
16848
16978
  const snapshotJson = {
16849
16979
  root: result.tree,
16850
- snapshotId,
16851
16980
  timestamp: Date.now(),
16852
- truncated: result.truncated || false,
16853
- uidMap: result.uidMap
16981
+ truncated: result.truncated || false
16854
16982
  };
16855
16983
  const snapshot = {
16856
16984
  text: formatSnapshotTree(result.tree),
16857
16985
  json: snapshotJson
16858
16986
  };
16859
16987
  logDebug(
16860
- `Snapshot created: ${result.uidMap.length} elements with UIDs${result.truncated ? " (truncated)" : ""}`
16988
+ `Snapshot created: ${result.nodeCount} elements with UIDs${result.truncated ? " (truncated)" : ""}`
16861
16989
  );
16862
16990
  return snapshot;
16863
16991
  }
16864
16992
  /**
16865
- * Resolve UID to CSS selector (with staleness check)
16993
+ * Resolve UID to a CSS selector generated on demand
16866
16994
  */
16867
- resolveUidToSelector(uid) {
16868
- return this.resolver.resolveUidToSelector(uid);
16995
+ async resolveUidToSelector(uid) {
16996
+ return await this.resolver.resolveUidToSelector(uid);
16869
16997
  }
16870
16998
  /**
16871
- * Resolve UID to WebElement (with staleness check and caching)
16999
+ * Resolve UID to the WebElement it was assigned to
16872
17000
  */
16873
17001
  async resolveUidToElement(uid) {
16874
17002
  return await this.resolver.resolveUidToElement(uid);
16875
17003
  }
16876
17004
  /**
16877
- * Clear snapshot (called on navigation)
17005
+ * Clear snapshot UIDs
16878
17006
  */
16879
- clear() {
16880
- this.resolver.clear();
17007
+ async clear() {
17008
+ await this.resolver.clear();
16881
17009
  }
16882
17010
  /**
16883
17011
  * Execute bundled injected snapshot script
16884
17012
  */
16885
- async executeInjectedScript(snapshotId, options) {
17013
+ async executeInjectedScript(nextElementId, options) {
16886
17014
  const scriptSource = this.getInjectedScript();
16887
17015
  const result = await this.driver.executeScript(
16888
17016
  `
16889
17017
  // Only inject the bundle if not already present
16890
17018
  if (typeof window.__createSnapshot === 'undefined') {
16891
17019
  ${scriptSource}
16892
- // Register the createSnapshot function globally
17020
+ // Register the snapshot and UID resolution functions globally
16893
17021
  if (typeof __SnapshotInjected !== 'undefined' && __SnapshotInjected.createSnapshot) {
16894
17022
  window.__createSnapshot = __SnapshotInjected.createSnapshot;
17023
+ window.__resolveUid = __SnapshotInjected.resolveUid;
17024
+ window.__uidToSelector = __SnapshotInjected.uidToSelector;
17025
+ window.__clearUidRegistry = __SnapshotInjected.clearUidRegistry;
16895
17026
  }
16896
17027
  }
16897
17028
  // Call it with options
16898
17029
  return window.__createSnapshot(arguments[0], arguments[1]);
16899
17030
  `,
16900
- snapshotId,
17031
+ nextElementId,
16901
17032
  options || {}
16902
17033
  );
16903
17034
  return result;
@@ -16922,6 +17053,7 @@ var init_firefox = __esm({
16922
17053
  "use strict";
16923
17054
  init_core3();
16924
17055
  init_logger();
17056
+ init_remote_value();
16925
17057
  init_events();
16926
17058
  init_dom();
16927
17059
  init_pages();
@@ -16945,21 +17077,19 @@ var init_firefox = __esm({
16945
17077
  await this.core.connect();
16946
17078
  const driver = this.core.getDriver();
16947
17079
  this.snapshot = new SnapshotManager(driver);
16948
- const onNavigate = () => {
16949
- if (this.snapshot) {
16950
- this.snapshot.clear();
16951
- }
16952
- };
16953
17080
  const hasBidi = "getBidi" in driver && typeof driver.getBidi === "function";
16954
17081
  if (hasBidi) {
16955
17082
  this.consoleEvents = new ConsoleEvents(driver, {
16956
- onNavigate,
16957
- autoClearOnNavigate: false
16958
- });
16959
- this.networkEvents = new NetworkEvents(driver, {
16960
- onNavigate,
16961
17083
  autoClearOnNavigate: false
16962
17084
  });
17085
+ this.networkEvents = new NetworkEvents(
17086
+ driver,
17087
+ {
17088
+ autoClearOnNavigate: false,
17089
+ captureBodies: this.core.getOptions().captureNetworkBodies !== false
17090
+ },
17091
+ (method, params) => this.core.sendBiDiCommand(method, params ?? {})
17092
+ );
16963
17093
  this.debuggingEvents = new DebuggingEvents(
16964
17094
  driver,
16965
17095
  (method, params) => this.core.sendBiDiCommand(method, params)
@@ -17012,17 +17142,24 @@ var init_firefox = __esm({
17012
17142
  // ============================================================================
17013
17143
  // DOM / Evaluate
17014
17144
  // ============================================================================
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");
17145
+ /**
17146
+ * Evaluate a JavaScript expression in the current browsing context over
17147
+ * WebDriver BiDi (script.evaluate), so reads target the BiDi-tracked context
17148
+ * rather than Selenium's classic window handle. Returns the result as a
17149
+ * native value; throws on a script exception.
17150
+ */
17151
+ async evaluate(expression) {
17152
+ const result = await this.core.sendBiDiCommand("script.evaluate", {
17153
+ expression,
17154
+ awaitPromise: true,
17155
+ target: { context: this.core.getCurrentContextId() }
17156
+ });
17157
+ if (result.type === "success") {
17158
+ return remoteValueToNative(result.result);
17024
17159
  }
17025
- return await this.dom.getContent();
17160
+ throw new Error(
17161
+ `Script evaluation failed: ${result.exceptionDetails?.text ?? "unknown error"}`
17162
+ );
17026
17163
  }
17027
17164
  async clickBySelector(selector) {
17028
17165
  if (!this.dom) {
@@ -17118,7 +17255,6 @@ var init_firefox = __esm({
17118
17255
  throw new Error("Not connected");
17119
17256
  }
17120
17257
  await this.pages.navigate(url);
17121
- this.clearSnapshot();
17122
17258
  }
17123
17259
  async navigateBack() {
17124
17260
  if (!this.pages) {
@@ -17221,6 +17357,18 @@ var init_firefox = __esm({
17221
17357
  }
17222
17358
  this.networkEvents.clearRequests();
17223
17359
  }
17360
+ /**
17361
+ * Fetch a captured request or response body for a given request id.
17362
+ * Returns a structured result describing the body or why it is unavailable.
17363
+ */
17364
+ async getNetworkRequestBody(requestId, dataType) {
17365
+ if (!this.networkEvents) {
17366
+ throw new Error(
17367
+ "Network events not available (Firefox Remote Agent not running \u2014 start Firefox with --remote-debugging-port to enable BiDi)"
17368
+ );
17369
+ }
17370
+ return this.networkEvents.fetchBody(requestId, dataType);
17371
+ }
17224
17372
  // ============================================================================
17225
17373
  // Downloads
17226
17374
  // ============================================================================
@@ -17257,11 +17405,11 @@ var init_firefox = __esm({
17257
17405
  }
17258
17406
  return await this.snapshot.takeSnapshot(options);
17259
17407
  }
17260
- resolveUidToSelector(uid) {
17408
+ async resolveUidToSelector(uid) {
17261
17409
  if (!this.snapshot) {
17262
17410
  throw new Error("Not connected");
17263
17411
  }
17264
- return this.snapshot.resolveUidToSelector(uid);
17412
+ return await this.snapshot.resolveUidToSelector(uid);
17265
17413
  }
17266
17414
  async resolveUidToElement(uid) {
17267
17415
  if (!this.snapshot) {
@@ -17269,11 +17417,11 @@ var init_firefox = __esm({
17269
17417
  }
17270
17418
  return await this.snapshot.resolveUidToElement(uid);
17271
17419
  }
17272
- clearSnapshot() {
17420
+ async clearSnapshot() {
17273
17421
  if (!this.snapshot) {
17274
17422
  throw new Error("Not connected");
17275
17423
  }
17276
- this.snapshot.clear();
17424
+ await this.snapshot.clear();
17277
17425
  }
17278
17426
  // ============================================================================
17279
17427
  // Screenshot
@@ -17579,11 +17727,14 @@ async function getFirefox() {
17579
17727
  acceptInsecureCerts: args.acceptInsecureCerts,
17580
17728
  connectExisting: args.connectExisting,
17581
17729
  marionettePort: args.marionettePort,
17730
+ lookupMarionettePort: args.lookupMarionettePort,
17582
17731
  env: envVars,
17583
17732
  logFile: args.outputFile ?? void 0,
17584
17733
  prefs,
17585
17734
  androidDevice: args.androidDevice ?? void 0,
17586
- androidPackage: args.androidPackage ?? void 0
17735
+ androidPackage: args.androidPackage ?? void 0,
17736
+ androidWipeAppData: args.androidWipeAppData,
17737
+ captureNetworkBodies: !args.disableNetworkBodyCollection
17587
17738
  };
17588
17739
  }
17589
17740
  firefox2 = new FirefoxClient(options);
@@ -17649,7 +17800,6 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17649
17800
  },
17650
17801
  {
17651
17802
  capabilities: {
17652
- resources: {},
17653
17803
  tools: {}
17654
17804
  }
17655
17805
  }
@@ -17683,12 +17833,6 @@ async function run(parseArgsFn, importMetaUrl, allowPrivileged = false) {
17683
17833
  throw error2;
17684
17834
  }
17685
17835
  });
17686
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
17687
- return { resources: [] };
17688
- });
17689
- server.setRequestHandler(ReadResourceRequestSchema, async () => {
17690
- throw new Error("Resource reading not implemented");
17691
- });
17692
17836
  const transport = new StdioServerTransport();
17693
17837
  await server.connect(transport);
17694
17838
  log("Firefox DevTools MCP server running on stdio");
@@ -17731,26 +17875,101 @@ var init_src = __esm({
17731
17875
  }
17732
17876
  });
17733
17877
 
17734
- // src/tools/pages.ts
17735
- function formatPageList(tabs, selectedIdx) {
17736
- if (tabs.length === 0) {
17737
- return "\u{1F4C4} No pages";
17738
- }
17739
- const lines = [`\u{1F4C4} ${tabs.length} pages (selected: ${selectedIdx})`];
17740
- for (const tab of tabs) {
17741
- const idx = tabs.indexOf(tab);
17742
- const marker = idx === selectedIdx ? ">" : " ";
17743
- const title = (tab.title || "Untitled").substring(0, 40);
17744
- lines.push(`${marker}[${idx}] ${title}`);
17745
- }
17746
- return lines.join("\n");
17878
+ // src/utils/save-output.ts
17879
+ import { mkdir, rename, stat, unlink, writeFile } from "fs/promises";
17880
+ import { randomBytes } from "crypto";
17881
+ import { homedir as homedir2 } from "os";
17882
+ import { dirname as dirname2, isAbsolute, join as join3, resolve as resolve3, sep } from "path";
17883
+ function homeRoot() {
17884
+ return join3(homedir2(), ".firefox-devtools-mcp");
17747
17885
  }
17748
- async function handleListPages(_args) {
17886
+ function generatedName(baseName, extension) {
17887
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
17888
+ return `${baseName}-${timestamp}.${extension}`;
17889
+ }
17890
+ async function isDirectory(path) {
17749
17891
  try {
17750
- const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
17751
- const firefox3 = await getFirefox2();
17752
- await firefox3.refreshTabs();
17753
- const tabs = firefox3.getTabs();
17892
+ return (await stat(path)).isDirectory();
17893
+ } catch {
17894
+ return false;
17895
+ }
17896
+ }
17897
+ async function assertAllowedPath(saveTo, resolvedPath) {
17898
+ const { args: args2 } = await Promise.resolve().then(() => (init_src(), src_exports));
17899
+ if (args2?.unrestrictedSavePaths) {
17900
+ return;
17901
+ }
17902
+ const root = isAbsolute(saveTo) ? homeRoot() : process.cwd();
17903
+ if (resolvedPath !== root && !resolvedPath.startsWith(root + sep)) {
17904
+ throw new Error(
17905
+ `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.`
17906
+ );
17907
+ }
17908
+ }
17909
+ async function saveOutput(content, saveTo, baseName, extension = "json") {
17910
+ let resolvedPath;
17911
+ if (saveTo) {
17912
+ resolvedPath = resolve3(saveTo);
17913
+ if (await isDirectory(resolvedPath)) {
17914
+ resolvedPath = join3(resolvedPath, generatedName(baseName, extension));
17915
+ }
17916
+ await assertAllowedPath(saveTo, resolvedPath);
17917
+ } else {
17918
+ resolvedPath = join3(homeRoot(), "output", generatedName(baseName, extension));
17919
+ }
17920
+ await mkdir(dirname2(resolvedPath), { recursive: true });
17921
+ const tmpPath = `${resolvedPath}.${randomBytes(6).toString("hex")}.tmp`;
17922
+ try {
17923
+ await writeFile(tmpPath, content);
17924
+ await rename(tmpPath, resolvedPath);
17925
+ } catch (error2) {
17926
+ await unlink(tmpPath).catch(() => void 0);
17927
+ throw error2;
17928
+ }
17929
+ return { path: resolvedPath, bytes: Buffer.byteLength(content) };
17930
+ }
17931
+ var init_save_output = __esm({
17932
+ "src/utils/save-output.ts"() {
17933
+ "use strict";
17934
+ }
17935
+ });
17936
+
17937
+ // src/tools/module.ts
17938
+ function defineModule(config2) {
17939
+ return {
17940
+ name: config2.name,
17941
+ description: config2.description,
17942
+ ...config2.privileged ? { privileged: true } : {},
17943
+ tools: config2.tools.map(([definition, handler]) => ({ definition, handler }))
17944
+ };
17945
+ }
17946
+ var init_module = __esm({
17947
+ "src/tools/module.ts"() {
17948
+ "use strict";
17949
+ }
17950
+ });
17951
+
17952
+ // src/tools/pages.ts
17953
+ function formatPageList(tabs, selectedIdx) {
17954
+ if (tabs.length === 0) {
17955
+ return "No pages";
17956
+ }
17957
+ const lines = [`${tabs.length} pages (selected: ${selectedIdx})`];
17958
+ for (const tab of tabs) {
17959
+ const idx = tabs.indexOf(tab);
17960
+ const marker = idx === selectedIdx ? ">" : " ";
17961
+ const title = (tab.title || "Untitled").substring(0, 40);
17962
+ const url = (tab.url || "URL unavailable").substring(0, 200);
17963
+ lines.push(`${marker}[${idx}] ${title} (${url})`);
17964
+ }
17965
+ return lines.join("\n");
17966
+ }
17967
+ async function handleListPages(_args) {
17968
+ try {
17969
+ const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
17970
+ const firefox3 = await getFirefox2();
17971
+ await firefox3.refreshTabs();
17972
+ const tabs = firefox3.getTabs();
17754
17973
  const selectedIdx = firefox3.getSelectedTabIdx();
17755
17974
  return successResponse(formatPageList(tabs, selectedIdx));
17756
17975
  } catch (error2) {
@@ -17848,12 +18067,57 @@ async function handleClosePage(args2) {
17848
18067
  return errorResponse(error2);
17849
18068
  }
17850
18069
  }
17851
- var listPagesTool, newPageTool, navigatePageTool, selectPageTool, closePageTool, module;
18070
+ async function respondWithContent(content, args2, baseName, extension) {
18071
+ const {
18072
+ maxLength = DEFAULT_MAX_CONTENT_CHARS,
18073
+ saveTo,
18074
+ preview
18075
+ } = args2 || {};
18076
+ if (saveTo) {
18077
+ const saved = await saveOutput(
18078
+ content,
18079
+ saveTo === true ? void 0 : saveTo,
18080
+ baseName,
18081
+ extension
18082
+ );
18083
+ let output = `${baseName} saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18084
+ const excerpt = previewExcerpt(content, preview);
18085
+ if (excerpt) {
18086
+ output += "\nPreview:\n" + excerpt;
18087
+ }
18088
+ return successResponse(output);
18089
+ }
18090
+ if (content.length <= maxLength) {
18091
+ return successResponse(content + `
18092
+
18093
+ [full content, ${content.length} chars]`);
18094
+ }
18095
+ const footer = truncationFooter(content.length - maxLength, "chars", [
18096
+ "maxLength to show more",
18097
+ "saveTo to save the full content to a file"
18098
+ ]);
18099
+ return successResponse(content.slice(0, maxLength) + "\n\n" + footer);
18100
+ }
18101
+ async function handleGetPageText(args2) {
18102
+ try {
18103
+ const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18104
+ const firefox3 = await getFirefox2();
18105
+ const text = await firefox3.evaluate(
18106
+ "document.body ? document.body.innerText : document.documentElement.innerText"
18107
+ );
18108
+ return respondWithContent(text ?? "", args2, "page-text", "txt");
18109
+ } catch (error2) {
18110
+ return errorResponse(error2);
18111
+ }
18112
+ }
18113
+ var DEFAULT_MAX_CONTENT_CHARS, listPagesTool, newPageTool, navigatePageTool, selectPageTool, closePageTool, getPageTextTool, module;
17852
18114
  var init_pages2 = __esm({
17853
18115
  "src/tools/pages.ts"() {
17854
18116
  "use strict";
17855
18117
  init_response_helpers();
18118
+ init_save_output();
17856
18119
  init_module();
18120
+ DEFAULT_MAX_CONTENT_CHARS = 2e4;
17857
18121
  listPagesTool = {
17858
18122
  name: "list_pages",
17859
18123
  description: "List open tabs (index, title, URL). Selected tab is marked.",
@@ -17941,6 +18205,30 @@ var init_pages2 = __esm({
17941
18205
  required: ["pageIdx"]
17942
18206
  }
17943
18207
  };
18208
+ getPageTextTool = {
18209
+ name: "get_page_text",
18210
+ 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.",
18211
+ annotations: {
18212
+ readOnlyHint: true
18213
+ },
18214
+ inputSchema: {
18215
+ type: "object",
18216
+ properties: {
18217
+ maxLength: {
18218
+ type: "number",
18219
+ description: "Max characters to return inline (default: 20000). Ignored when saveTo is used."
18220
+ },
18221
+ saveTo: {
18222
+ type: ["boolean", "string"],
18223
+ 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."
18224
+ },
18225
+ preview: {
18226
+ type: "number",
18227
+ description: "Number of characters of the saved text to return inline as a preview when saveTo is used. Omit for no preview."
18228
+ }
18229
+ }
18230
+ }
18231
+ };
17944
18232
  module = defineModule({
17945
18233
  name: "pages",
17946
18234
  description: "Open, navigate, select, and close pages.",
@@ -17949,7 +18237,8 @@ var init_pages2 = __esm({
17949
18237
  [newPageTool, handleNewPage],
17950
18238
  [navigatePageTool, handleNavigatePage],
17951
18239
  [selectPageTool, handleSelectPage],
17952
- [closePageTool, handleClosePage]
18240
+ [closePageTool, handleClosePage],
18241
+ [getPageTextTool, handleGetPageText]
17953
18242
  ]
17954
18243
  });
17955
18244
  }
@@ -17969,65 +18258,6 @@ var init_uid_helpers = __esm({
17969
18258
  }
17970
18259
  });
17971
18260
 
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
18261
  // src/tools/snapshot.ts
18032
18262
  async function handleTakeSnapshot(args2) {
18033
18263
  try {
@@ -18063,6 +18293,9 @@ async function handleTakeSnapshot(args2) {
18063
18293
  if (maxDepth !== void 0) {
18064
18294
  options.maxDepth = maxDepth;
18065
18295
  }
18296
+ if (saveTo) {
18297
+ options.maxAttrLength = Infinity;
18298
+ }
18066
18299
  const formattedText = formatSnapshotTree2(snapshot.json.root, 0, options);
18067
18300
  if (saveTo) {
18068
18301
  const saved = await saveOutput(
@@ -18071,7 +18304,7 @@ async function handleTakeSnapshot(args2) {
18071
18304
  "snapshot",
18072
18305
  "txt"
18073
18306
  );
18074
- let output2 = `Snapshot (id=${snapshot.json.snapshotId}) saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18307
+ let output2 = `Snapshot saved to: ${saved.path} (${(saved.bytes / 1024).toFixed(1)}KB)`;
18075
18308
  if (snapshot.json.truncated) {
18076
18309
  output2 += " [DOM truncated]";
18077
18310
  }
@@ -18084,7 +18317,7 @@ async function handleTakeSnapshot(args2) {
18084
18317
  const lines = formattedText.split("\n");
18085
18318
  const truncated = lines.length > maxLines;
18086
18319
  const displayLines = truncated ? lines.slice(0, maxLines) : lines;
18087
- let output = `\u{1F4F8} Snapshot (id=${snapshot.json.snapshotId})`;
18320
+ let output = "Snapshot";
18088
18321
  if (selector) {
18089
18322
  output += ` [selector: ${selector}]`;
18090
18323
  }
@@ -18100,9 +18333,11 @@ async function handleTakeSnapshot(args2) {
18100
18333
  output += "\n\n";
18101
18334
  output += displayLines.join("\n");
18102
18335
  if (truncated) {
18103
- output += `
18104
-
18105
- [+${lines.length - maxLines} lines, use maxLines to see more]`;
18336
+ output += "\n\n" + truncationFooter(lines.length - maxLines, "lines", [
18337
+ "maxLines to show more",
18338
+ "selector to scope",
18339
+ "saveTo to dump the full tree to a file"
18340
+ ]);
18106
18341
  }
18107
18342
  return successResponse(output);
18108
18343
  } catch (error2) {
@@ -18124,7 +18359,7 @@ async function handleResolveUidToSelector(args2) {
18124
18359
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18125
18360
  const firefox3 = await getFirefox2();
18126
18361
  try {
18127
- const selector = firefox3.resolveUidToSelector(uid);
18362
+ const selector = await firefox3.resolveUidToSelector(uid);
18128
18363
  return successResponse(`${uid} \u2192 ${selector}`);
18129
18364
  } catch (error2) {
18130
18365
  throw handleUidError(error2, uid);
@@ -18137,7 +18372,7 @@ async function handleClearSnapshot(_args) {
18137
18372
  try {
18138
18373
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
18139
18374
  const firefox3 = await getFirefox2();
18140
- firefox3.clearSnapshot();
18375
+ await firefox3.clearSnapshot();
18141
18376
  return successResponse("\u{1F9F9} Snapshot cleared");
18142
18377
  } catch (error2) {
18143
18378
  return errorResponse(error2);
@@ -18154,7 +18389,7 @@ var init_snapshot2 = __esm({
18154
18389
  DEFAULT_SNAPSHOT_LINES = 100;
18155
18390
  takeSnapshotTool = {
18156
18391
  name: "take_snapshot",
18157
- description: "Capture DOM snapshot with stable UIDs. Retake after navigation.",
18392
+ 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
18393
  annotations: {
18159
18394
  readOnlyHint: true
18160
18395
  },
@@ -18198,7 +18433,7 @@ var init_snapshot2 = __esm({
18198
18433
  };
18199
18434
  resolveUidToSelectorTool = {
18200
18435
  name: "resolve_uid_to_selector",
18201
- description: "Resolve UID to CSS selector. Fails if stale.",
18436
+ description: "Resolve UID to CSS selector. Fails if the element is gone.",
18202
18437
  annotations: {
18203
18438
  readOnlyHint: true
18204
18439
  },
@@ -18215,7 +18450,7 @@ var init_snapshot2 = __esm({
18215
18450
  };
18216
18451
  clearSnapshotTool = {
18217
18452
  name: "clear_snapshot",
18218
- description: "Clear snapshot cache. Usually not needed.",
18453
+ description: "Clear snapshot UIDs. Usually not needed.",
18219
18454
  annotations: {
18220
18455
  readOnlyHint: false
18221
18456
  },
@@ -18535,6 +18770,55 @@ var init_input = __esm({
18535
18770
  });
18536
18771
 
18537
18772
  // src/tools/network.ts
18773
+ async function safeFetchBody(firefox3, id, dataType) {
18774
+ if (typeof firefox3.getNetworkRequestBody !== "function") {
18775
+ return { ok: false, reason: "unsupported" };
18776
+ }
18777
+ try {
18778
+ return await firefox3.getNetworkRequestBody(id, dataType);
18779
+ } catch {
18780
+ return { ok: false, reason: "error" };
18781
+ }
18782
+ }
18783
+ function bodyUnavailableMarker(reason) {
18784
+ switch (reason) {
18785
+ case "unsupported":
18786
+ return "<not captured: body collection not supported by this Firefox>";
18787
+ case "not-collected":
18788
+ return "<not captured>";
18789
+ case "evicted":
18790
+ return "<not available: evicted from the capture buffer>";
18791
+ case "aborted":
18792
+ return "<not available: collection aborted>";
18793
+ default:
18794
+ return "<not available>";
18795
+ }
18796
+ }
18797
+ function hasRequestBody(result) {
18798
+ return result.ok || result.reason === "evicted" || result.reason === "aborted";
18799
+ }
18800
+ function renderBodyInline(result) {
18801
+ if (!result.ok) {
18802
+ return { body: bodyUnavailableMarker(result.reason) };
18803
+ }
18804
+ if (result.type === "base64") {
18805
+ const approxKb = (result.value.length * 3 / 4 / 1024).toFixed(1);
18806
+ return {
18807
+ body: `<binary data (~${approxKb}KB); use saveTo to retrieve the full body>`,
18808
+ encoding: "base64"
18809
+ };
18810
+ }
18811
+ return { body: truncateText(result.value, TOKEN_LIMITS.MAX_RESPONSE_CHARS) };
18812
+ }
18813
+ function renderBodyForFile(result) {
18814
+ if (!result.ok) {
18815
+ return { body: null, encoding: null, unavailable: result.reason };
18816
+ }
18817
+ return {
18818
+ body: result.value,
18819
+ encoding: result.type === "base64" ? "base64" : "utf-8"
18820
+ };
18821
+ }
18538
18822
  async function handleListNetworkRequests(args2) {
18539
18823
  try {
18540
18824
  const {
@@ -18643,6 +18927,11 @@ async function handleListNetworkRequests(args2) {
18643
18927
  const effectiveDetail = detail ?? "summary";
18644
18928
  const limitedRequests = requests.slice(0, effectiveLimit);
18645
18929
  const hasMore = requests.length > effectiveLimit;
18930
+ const moreFooter = hasMore ? "\n" + truncationFooter(requests.length - limitedRequests.length, "requests", [
18931
+ "limit to show more",
18932
+ "urlContains/method/status to filter",
18933
+ "saveTo to save all to a file"
18934
+ ]) : "";
18646
18935
  if (format === "json") {
18647
18936
  const responseData = {
18648
18937
  total: requests.length,
@@ -18684,7 +18973,7 @@ async function handleListNetworkRequests(args2) {
18684
18973
  });
18685
18974
  const header = `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18686
18975
  `;
18687
- return successResponse(header + formattedRequests.join("\n"));
18976
+ return successResponse(header + formattedRequests.join("\n") + moreFooter);
18688
18977
  } else if (effectiveDetail === "min") {
18689
18978
  const minData = limitedRequests.map((req) => ({
18690
18979
  id: req.id,
@@ -18698,7 +18987,7 @@ async function handleListNetworkRequests(args2) {
18698
18987
  }));
18699
18988
  return successResponse(
18700
18989
  `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18701
- ` + JSON.stringify(minData, null, 2)
18990
+ ` + JSON.stringify(minData, null, 2) + moreFooter
18702
18991
  );
18703
18992
  } else {
18704
18993
  const fullData = limitedRequests.map((req) => ({
@@ -18715,7 +19004,7 @@ async function handleListNetworkRequests(args2) {
18715
19004
  }));
18716
19005
  return successResponse(
18717
19006
  `[network] ${requests.length} requests${hasMore ? ` (limit ${effectiveLimit})` : ""}
18718
- ` + JSON.stringify(fullData, null, 2)
19007
+ ` + JSON.stringify(fullData, null, 2) + moreFooter
18719
19008
  );
18720
19009
  }
18721
19010
  } catch (error2) {
@@ -18764,8 +19053,29 @@ async function handleGetNetworkRequest(args2) {
18764
19053
  requestHeaders: request.requestHeaders ?? null,
18765
19054
  responseHeaders: request.responseHeaders ?? null
18766
19055
  };
19056
+ const [responseBodyResult, requestBodyResult] = await Promise.all([
19057
+ safeFetchBody(firefox3, request.id, "response"),
19058
+ safeFetchBody(firefox3, request.id, "request")
19059
+ ]);
18767
19060
  if (saveTo) {
18768
- const fileBody = JSON.stringify(fullDetails, null, 2);
19061
+ const responseFile = renderBodyForFile(responseBodyResult);
19062
+ const fileObject = {
19063
+ ...fullDetails,
19064
+ responseBody: responseFile.body,
19065
+ responseBodyEncoding: responseFile.encoding
19066
+ };
19067
+ if (responseFile.unavailable) {
19068
+ fileObject.responseBodyUnavailable = responseFile.unavailable;
19069
+ }
19070
+ if (hasRequestBody(requestBodyResult)) {
19071
+ const requestFile = renderBodyForFile(requestBodyResult);
19072
+ fileObject.requestBody = requestFile.body;
19073
+ fileObject.requestBodyEncoding = requestFile.encoding;
19074
+ if (requestFile.unavailable) {
19075
+ fileObject.requestBodyUnavailable = requestFile.unavailable;
19076
+ }
19077
+ }
19078
+ const fileBody = JSON.stringify(fileObject, null, 2);
18769
19079
  const saved = await saveOutput(
18770
19080
  fileBody,
18771
19081
  saveTo === true ? void 0 : saveTo,
@@ -18778,11 +19088,23 @@ async function handleGetNetworkRequest(args2) {
18778
19088
  }
18779
19089
  return successResponse(output);
18780
19090
  }
19091
+ const responseInline = renderBodyInline(responseBodyResult);
18781
19092
  const details = {
18782
19093
  ...fullDetails,
18783
19094
  requestHeaders: truncateHeaders(request.requestHeaders),
18784
- responseHeaders: truncateHeaders(request.responseHeaders)
19095
+ responseHeaders: truncateHeaders(request.responseHeaders),
19096
+ responseBody: responseInline.body
18785
19097
  };
19098
+ if (responseInline.encoding) {
19099
+ details.responseBodyEncoding = responseInline.encoding;
19100
+ }
19101
+ if (hasRequestBody(requestBodyResult)) {
19102
+ const requestInline = renderBodyInline(requestBodyResult);
19103
+ details.requestBody = requestInline.body;
19104
+ if (requestInline.encoding) {
19105
+ details.requestBodyEncoding = requestInline.encoding;
19106
+ }
19107
+ }
18786
19108
  if (format === "json") {
18787
19109
  return jsonResponse(details);
18788
19110
  }
@@ -18800,7 +19122,7 @@ var init_network2 = __esm({
18800
19122
  init_module();
18801
19123
  listNetworkRequestsTool = {
18802
19124
  name: "list_network_requests",
18803
- description: "List network requests. Returns IDs for get_network_request.",
19125
+ 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
19126
  annotations: {
18805
19127
  readOnlyHint: true
18806
19128
  },
@@ -18871,7 +19193,7 @@ var init_network2 = __esm({
18871
19193
  };
18872
19194
  getNetworkRequestTool = {
18873
19195
  name: "get_network_request",
18874
- description: "Get request details by ID. URL lookup as fallback.",
19196
+ 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
19197
  annotations: {
18876
19198
  readOnlyHint: true
18877
19199
  },
@@ -18893,7 +19215,7 @@ var init_network2 = __esm({
18893
19215
  },
18894
19216
  saveTo: {
18895
19217
  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."
19218
+ 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
19219
  },
18898
19220
  preview: {
18899
19221
  type: "number",
@@ -19072,8 +19394,11 @@ Total messages: ${totalCount}${filterInfo.length > 0 ? `, Filters: ${filterInfo.
19072
19394
  `;
19073
19395
  }
19074
19396
  if (truncated) {
19075
- output += `
19076
- [+${filteredCount - messages.length} more]`;
19397
+ output += "\n" + truncationFooter(filteredCount - messages.length, "messages", [
19398
+ "limit to show more",
19399
+ "level/textContains/source to filter",
19400
+ "saveTo to save all to a file"
19401
+ ]);
19077
19402
  }
19078
19403
  return successResponse(output);
19079
19404
  } catch (error2) {
@@ -19100,7 +19425,7 @@ var init_console2 = __esm({
19100
19425
  init_module();
19101
19426
  listConsoleMessagesTool = {
19102
19427
  name: "list_console_messages",
19103
- description: "List console messages. Supports filtering by level, time, text, source.",
19428
+ description: "List console messages, filterable by level, time, text, source. Caps at limit (default 50); saveTo saves all matches to a file.",
19104
19429
  annotations: {
19105
19430
  readOnlyHint: true
19106
19431
  },
@@ -19570,7 +19895,7 @@ var init_utilities = __esm({
19570
19895
  });
19571
19896
 
19572
19897
  // src/tools/firefox-management.ts
19573
- import { readFileSync as readFileSync2, existsSync as existsSync3, statSync as statSync2 } from "fs";
19898
+ import { readFileSync as readFileSync3, existsSync as existsSync3, statSync as statSync2 } from "fs";
19574
19899
  async function handleGetFirefoxLogs(input) {
19575
19900
  try {
19576
19901
  const {
@@ -19598,7 +19923,7 @@ async function handleGetFirefoxLogs(input) {
19598
19923
  );
19599
19924
  }
19600
19925
  }
19601
- const content = readFileSync2(logFilePath, "utf-8");
19926
+ const content = readFileSync3(logFilePath, "utf-8");
19602
19927
  let allLines = content.split("\n").filter((line) => line.trim().length > 0);
19603
19928
  if (grep) {
19604
19929
  const grepLower = grep.toLowerCase();
@@ -20488,67 +20813,6 @@ var init_screencast = __esm({
20488
20813
  }
20489
20814
  });
20490
20815
 
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
20816
  // src/utils/js-validation.ts
20553
20817
  function validateFunction(fnString) {
20554
20818
  if (!fnString || typeof fnString !== "string") {
@@ -20588,6 +20852,7 @@ async function handleEvaluateScript(args2) {
20588
20852
  function: fnString,
20589
20853
  args: fnArgs,
20590
20854
  timeout,
20855
+ sandbox,
20591
20856
  saveTo,
20592
20857
  preview
20593
20858
  } = args2;
@@ -20619,7 +20884,7 @@ Please call take_snapshot to get fresh UIDs and try again.`
20619
20884
  functionDeclaration: fnString,
20620
20885
  awaitPromise: true,
20621
20886
  arguments: resolvedArgs,
20622
- target: { context: firefox3.getCurrentContextId() }
20887
+ target: { context: firefox3.getCurrentContextId(), ...sandbox !== void 0 && { sandbox } }
20623
20888
  });
20624
20889
  const result = await Promise.race([
20625
20890
  new Promise((r) => setTimeout(() => r(TIMEOUT), scriptTimeout)),
@@ -20678,7 +20943,7 @@ var init_script = __esm({
20678
20943
  init_module();
20679
20944
  evaluateScriptTool = {
20680
20945
  name: "evaluate_script",
20681
- description: "Execute JS function in page. Prefer UID tools for interactions.",
20946
+ 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
20947
  annotations: {
20683
20948
  readOnlyHint: false
20684
20949
  },
@@ -20707,6 +20972,10 @@ var init_script = __esm({
20707
20972
  type: "number",
20708
20973
  description: "Timeout in ms (default: 5000)"
20709
20974
  },
20975
+ sandbox: {
20976
+ type: "string",
20977
+ 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."
20978
+ },
20710
20979
  saveTo: {
20711
20980
  type: ["boolean", "string"],
20712
20981
  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 +21451,25 @@ function formatContextList(contexts) {
21182
21451
  }
21183
21452
  return lines.join("\n");
21184
21453
  }
21454
+ async function assertPrivilegedContext(firefox3, contextId) {
21455
+ let result;
21456
+ try {
21457
+ result = await firefox3.sendBiDiCommand("browsingContext.getTree", {
21458
+ "moz:scope": "chrome"
21459
+ });
21460
+ } catch (error2) {
21461
+ if (error2 instanceof Error && error2.message.includes("UnsupportedOperationError")) {
21462
+ throw new Error(SYSTEM_ACCESS_ERROR);
21463
+ }
21464
+ throw error2;
21465
+ }
21466
+ const contexts = result.contexts || [];
21467
+ if (!contexts.some((ctx) => ctx.context === contextId)) {
21468
+ throw new Error(
21469
+ `${contextId} is not a privileged context. Use list_privileged_contexts to see valid ids.`
21470
+ );
21471
+ }
21472
+ }
21185
21473
  async function handleListPrivilegedContexts(_args) {
21186
21474
  try {
21187
21475
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
@@ -21193,11 +21481,7 @@ async function handleListPrivilegedContexts(_args) {
21193
21481
  return successResponse(formatContextList(contexts));
21194
21482
  } catch (error2) {
21195
21483
  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
- );
21484
+ return errorResponse(new Error(SYSTEM_ACCESS_ERROR));
21201
21485
  }
21202
21486
  return errorResponse(error2);
21203
21487
  }
@@ -21210,6 +21494,7 @@ async function handleSelectPrivilegedContext(args2) {
21210
21494
  }
21211
21495
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
21212
21496
  const firefox3 = await getFirefox2();
21497
+ await assertPrivilegedContext(firefox3, contextId);
21213
21498
  const driver = firefox3.getDriver();
21214
21499
  await driver.switchTo().window(contextId);
21215
21500
  try {
@@ -21233,17 +21518,22 @@ async function handleEvaluatePrivilegedScript(args2) {
21233
21518
  try {
21234
21519
  const {
21235
21520
  function: fnString,
21521
+ context,
21236
21522
  saveTo,
21237
21523
  preview
21238
21524
  } = args2;
21239
21525
  validateFunction(fnString);
21526
+ if (!context || typeof context !== "string") {
21527
+ throw new Error("context parameter is required and must be a string");
21528
+ }
21240
21529
  const { getFirefox: getFirefox2 } = await Promise.resolve().then(() => (init_src(), src_exports));
21241
21530
  const firefox3 = await getFirefox2();
21531
+ await assertPrivilegedContext(firefox3, context);
21242
21532
  const result = await firefox3.sendBiDiCommand("script.callFunction", {
21243
21533
  functionDeclaration: fnString,
21244
21534
  awaitPromise: true,
21245
21535
  arguments: [],
21246
- target: { context: firefox3.getCurrentContextId() }
21536
+ target: { context }
21247
21537
  });
21248
21538
  if (result.type === EvaluateResultType2.Success) {
21249
21539
  const json = JSON.stringify(remoteValueToNative(result.result), null, 2) ?? "undefined";
@@ -21280,7 +21570,7 @@ async function handleEvaluatePrivilegedScript(args2) {
21280
21570
  return errorResponse(error2);
21281
21571
  }
21282
21572
  }
21283
- var listPrivilegedContextsTool, selectPrivilegedContextTool, evaluatePrivilegedScriptTool, EvaluateResultType2, module16;
21573
+ var listPrivilegedContextsTool, selectPrivilegedContextTool, evaluatePrivilegedScriptTool, SYSTEM_ACCESS_ERROR, EvaluateResultType2, module16;
21284
21574
  var init_privileged_context = __esm({
21285
21575
  "src/tools/privileged-context.ts"() {
21286
21576
  "use strict";
@@ -21320,7 +21610,7 @@ var init_privileged_context = __esm({
21320
21610
  };
21321
21611
  evaluatePrivilegedScriptTool = {
21322
21612
  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.",
21613
+ 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
21614
  annotations: {
21325
21615
  readOnlyHint: false
21326
21616
  },
@@ -21331,6 +21621,10 @@ var init_privileged_context = __esm({
21331
21621
  type: "string",
21332
21622
  description: 'JS function string, e.g. () => Services.prefs.getBoolPref("foo")'
21333
21623
  },
21624
+ context: {
21625
+ type: "string",
21626
+ description: "Privileged browsing context ID from list_privileged_contexts"
21627
+ },
21334
21628
  saveTo: {
21335
21629
  type: ["boolean", "string"],
21336
21630
  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 +21634,10 @@ var init_privileged_context = __esm({
21340
21634
  description: "Number of characters of the saved result to return inline as a preview when saveTo is used. Omit for no preview."
21341
21635
  }
21342
21636
  },
21343
- required: ["function"]
21637
+ required: ["function", "context"]
21344
21638
  }
21345
21639
  };
21640
+ SYSTEM_ACCESS_ERROR = "Privileged context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.";
21346
21641
  EvaluateResultType2 = {
21347
21642
  Exception: "exception",
21348
21643
  Success: "success"
@@ -21547,6 +21842,12 @@ var init_cli = __esm({
21547
21842
  description: "Marionette port to connect to when using --connect-existing (default: 2828)",
21548
21843
  default: Number(process.env.MARIONETTE_PORT ?? "2828")
21549
21844
  },
21845
+ lookupMarionettePort: {
21846
+ type: "boolean",
21847
+ hidden: true,
21848
+ 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.",
21849
+ default: (process.env.LOOKUP_MARIONETTE_PORT ?? "false") === "true"
21850
+ },
21550
21851
  env: {
21551
21852
  type: "array",
21552
21853
  description: "Environment variables for Firefox in KEY=VALUE format. Can be specified multiple times. Example: --env MOZ_LOG=HTMLMediaElement:4"
@@ -21570,6 +21871,11 @@ var init_cli = __esm({
21570
21871
  description: "Android app package name (default: org.mozilla.firefox). Use org.mozilla.fenix for Nightly.",
21571
21872
  default: process.env.ANDROID_PACKAGE ?? "org.mozilla.firefox"
21572
21873
  },
21874
+ androidWipeAppData: {
21875
+ type: "boolean",
21876
+ description: "Confirm that connecting to Firefox for Android wipes all data of the target app (tabs, history, bookmarks, passwords, settings). Required with --android-device.",
21877
+ default: (process.env.ANDROID_WIPE_APP_DATA ?? "false") === "true"
21878
+ },
21573
21879
  logFile: {
21574
21880
  type: "string",
21575
21881
  description: "Path to a file where MCP server logs will be written. Set DEBUG=* to also enable verbose debug logs."
@@ -21604,6 +21910,12 @@ var init_cli = __esm({
21604
21910
  type: "boolean",
21605
21911
  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
21912
  default: (process.env.UNRESTRICTED_SAVE_PATHS ?? "false") === "true"
21913
+ },
21914
+ disableNetworkBodyCollection: {
21915
+ type: "boolean",
21916
+ hidden: true,
21917
+ 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.",
21918
+ default: (process.env.DISABLE_NETWORK_BODY_COLLECTION ?? "false") === "true"
21607
21919
  }
21608
21920
  };
21609
21921
  }