@mozilla/firefox-devtools-mcp 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -11
  2. package/dist/index.js +259 -100
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Firefox DevTools MCP
2
2
 
3
- [![npm version](https://badge.fury.io/js/firefox-devtools-mcp.svg)](https://www.npmjs.com/package/firefox-devtools-mcp)
3
+ [![npm version](https://badge.fury.io/js/@mozilla%2Ffirefox-devtools-mcp.svg)](https://www.npmjs.com/package/mozilla/firefox-devtools-mcp)
4
4
  [![CI](https://github.com/mozilla/firefox-devtools-mcp/workflows/CI/badge.svg)](https://github.com/mozilla/firefox-devtools-mcp/actions/workflows/ci.yml)
5
5
  [![codecov](https://codecov.io/gh/mozilla/firefox-devtools-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/mozilla/firefox-devtools-mcp)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE-MIT) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE-APACHE)
@@ -11,7 +11,7 @@ Model Context Protocol server for automating Firefox via WebDriver BiDi (through
11
11
 
12
12
  Repository: https://github.com/mozilla/firefox-devtools-mcp
13
13
 
14
- > **Note**: This MCP server requires a local Firefox browser installation and cannot run on cloud hosting services like glama.ai. Use `npx firefox-devtools-mcp@latest` to run locally, or use Docker with the provided Dockerfile.
14
+ > **Note**: This MCP server requires a local Firefox browser installation and cannot run on cloud hosting services like glama.ai. Use `npx @mozilla/firefox-devtools-mcp@latest` to run locally, or use Docker with the provided Dockerfile.
15
15
 
16
16
  ## Security
17
17
 
@@ -35,17 +35,17 @@ Recommended: use npx so you always run the latest published version from npm.
35
35
  Option A — Claude Code CLI
36
36
 
37
37
  ```bash
38
- claude mcp add firefox-devtools npx firefox-devtools-mcp@latest
38
+ claude mcp add firefox-devtools npx @mozilla/firefox-devtools-mcp@latest
39
39
  ```
40
40
 
41
41
  Pass options either as args or env vars. Examples:
42
42
 
43
43
  ```bash
44
44
  # Headless + viewport via args
45
- claude mcp add firefox-devtools npx firefox-devtools-mcp@latest -- --headless --viewport 1280x720
45
+ claude mcp add firefox-devtools npx @mozilla/firefox-devtools-mcp@latest -- --headless --viewport 1280x720
46
46
 
47
47
  # Or via environment variables
48
- claude mcp add firefox-devtools npx firefox-devtools-mcp@latest \
48
+ claude mcp add firefox-devtools npx @mozilla/firefox-devtools-mcp@latest \
49
49
  --env START_URL=https://example.com \
50
50
  --env FIREFOX_HEADLESS=true
51
51
  ```
@@ -63,9 +63,9 @@ Add to your Claude Code config file:
63
63
  "mcpServers": {
64
64
  "firefox-devtools": {
65
65
  "command": "npx",
66
- "args": ["-y", "firefox-devtools-mcp@latest", "--headless", "--viewport", "1280x720"],
66
+ "args": ["-y", "@mozilla/firefox-devtools-mcp@latest", "--headless", "--viewport", "1280x720"],
67
67
  "env": {
68
- "START_URL": "about:home"
68
+ "START_URL": "about:blank"
69
69
  }
70
70
  }
71
71
  }
@@ -82,7 +82,7 @@ npm run setup
82
82
  ## Try it with MCP Inspector
83
83
 
84
84
  ```bash
85
- npx @modelcontextprotocol/inspector npx firefox-devtools-mcp@latest --start-url https://example.com --headless
85
+ npx @modelcontextprotocol/inspector npx @mozilla/firefox-devtools-mcp@latest --start-url https://example.com --headless
86
86
  ```
87
87
 
88
88
  Then call tools like:
@@ -108,9 +108,31 @@ You can pass flags or environment variables (names on the right):
108
108
  - `--pref name=value` — set Firefox preference at startup via `moz:firefoxOptions` (repeatable)
109
109
  - `--enable-script` — enable the `evaluate_script` tool, which executes arbitrary JavaScript in the page context (`ENABLE_SCRIPT=true`)
110
110
  - `--enable-privileged-context` — enable privileged context tools: list/select privileged contexts, evaluate privileged scripts, get/set Firefox prefs, and list extensions. Requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` (`ENABLE_PRIVILEGED_CONTEXT=true`)
111
+ - `--android-device` — enable Firefox for Android mode; value is the ADB device serial (e.g. `emulator-5554`). Run `adb devices` to list connected devices. Omit the value or use `auto` to select the single connected device automatically.
112
+ - `--android-package` — Android app package name, default `org.mozilla.firefox`. Other packages: `org.mozilla.firefox_beta` for Firefox Beta, `org.mozilla.fenix` for Firefox Nightly, `org.mozilla.fenix.debug` for Firefox Nightly Debug, `org.mozilla.geckoview_example` for geckoview (`ANDROID_PACKAGE`)
111
113
 
112
114
  > **Note on `--pref`:** When Firefox runs in automation, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed.
113
115
 
116
+ ### Firefox for Android
117
+
118
+ Use `--android-device` to automate Firefox running on an Android device. Requires `adb` on your PATH and geckodriver, which is managed automatically.
119
+
120
+ ```bash
121
+ # List connected devices
122
+ adb devices
123
+
124
+ # Launch Firefox for Android on the single connected device
125
+ npx @mozilla/firefox-devtools-mcp --android-device auto
126
+
127
+ # Target a specific device
128
+ npx @mozilla/firefox-devtools-mcp --android-device <serial>
129
+
130
+ # Use Firefox Nightly instead
131
+ npx @mozilla/firefox-devtools-mcp --android-device <serial> --android-package org.mozilla.fenix
132
+ ```
133
+
134
+ Port forwarding between the host and device is handled automatically by geckodriver.
135
+
114
136
  ### Connect to existing Firefox
115
137
 
116
138
  Use `--connect-existing` to automate your real browsing session — with cookies, logins, and open tabs intact:
@@ -120,7 +142,7 @@ Use `--connect-existing` to automate your real browsing session — with cookies
120
142
  firefox --marionette
121
143
 
122
144
  # Run the MCP server
123
- npx firefox-devtools-mcp --connect-existing --marionette-port 2828
145
+ npx @mozilla/firefox-devtools-mcp --connect-existing --marionette-port 2828
124
146
  ```
125
147
 
126
148
  Or set `marionette.enabled` to `true` in `about:config` (or `user.js`) to enable Marionette on every launch.
@@ -186,7 +208,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for more details on local development, te
186
208
  "mcpServers": {
187
209
  "firefox-devtools": {
188
210
  "command": "cmd",
189
- "args": ["/c", "npx", "-y", "firefox-devtools-mcp@latest"]
211
+ "args": ["/c", "npx", "-y", "@mozilla/firefox-devtools-mcp@latest"]
190
212
  }
191
213
  }
192
214
  ```
@@ -197,7 +219,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for more details on local development, te
197
219
  "mcpServers": {
198
220
  "firefox-devtools": {
199
221
  "command": "C:\\nvm4w\\nodejs\\npx.ps1",
200
- "args": ["-y", "firefox-devtools-mcp@latest"]
222
+ "args": ["-y", "@mozilla/firefox-devtools-mcp@latest"]
201
223
  }
202
224
  }
203
225
  ```
package/dist/index.js CHANGED
@@ -128,8 +128,8 @@ var init_cli = __esm({
128
128
  },
129
129
  startUrl: {
130
130
  type: "string",
131
- description: "URL to open when Firefox starts (default: about:home)",
132
- default: process.env.START_URL ?? "about:home"
131
+ description: "URL to open when Firefox starts (default: about:blank)",
132
+ default: process.env.START_URL ?? "about:blank"
133
133
  },
134
134
  connectExisting: {
135
135
  type: "boolean",
@@ -155,6 +155,15 @@ var init_cli = __esm({
155
155
  description: "Set Firefox preference at startup via moz:firefoxOptions (format: name=value). Can be specified multiple times.",
156
156
  alias: "p"
157
157
  },
158
+ androidDevice: {
159
+ type: "string",
160
+ description: "Android device serial for launching Firefox for Android via ADB. Omit to auto-select the single connected device. Requires adb on PATH."
161
+ },
162
+ androidPackage: {
163
+ type: "string",
164
+ description: "Android app package name (default: org.mozilla.firefox). Use org.mozilla.fenix for Nightly.",
165
+ default: process.env.ANDROID_PACKAGE ?? "org.mozilla.firefox"
166
+ },
158
167
  enableScript: {
159
168
  type: "boolean",
160
169
  description: "Enable the evaluate_script tool, which allows executing arbitrary JavaScript in the page context.",
@@ -5172,8 +5181,8 @@ var init_regexes = __esm({
5172
5181
  _emoji = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;
5173
5182
  ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
5174
5183
  ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
5175
- mac = (delimiter) => {
5176
- const escapedDelim = escapeRegex(delimiter ?? ":");
5184
+ mac = (delimiter2) => {
5185
+ const escapedDelim = escapeRegex(delimiter2 ?? ":");
5177
5186
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
5178
5187
  };
5179
5188
  cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
@@ -17753,6 +17762,9 @@ var require_utils = __commonJS({
17753
17762
  "use strict";
17754
17763
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
17755
17764
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
17765
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
17766
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
17767
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
17756
17768
  function stringArrayToHexStripped(input) {
17757
17769
  let acc = "";
17758
17770
  let code = 0;
@@ -17945,27 +17957,77 @@ var require_utils = __commonJS({
17945
17957
  }
17946
17958
  return output.join("");
17947
17959
  }
17948
- function normalizeComponentEncoding(component, esc2) {
17949
- const func = esc2 !== true ? escape : unescape;
17950
- if (component.scheme !== void 0) {
17951
- component.scheme = func(component.scheme);
17952
- }
17953
- if (component.userinfo !== void 0) {
17954
- component.userinfo = func(component.userinfo);
17955
- }
17956
- if (component.host !== void 0) {
17957
- component.host = func(component.host);
17960
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
17961
+ var HOST_DELIM_RE = /[@/?#:]/g;
17962
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
17963
+ function reescapeHostDelimiters(host, isIP) {
17964
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
17965
+ re.lastIndex = 0;
17966
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
17967
+ }
17968
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
17969
+ if (input.indexOf("%") === -1) {
17970
+ return input;
17958
17971
  }
17959
- if (component.path !== void 0) {
17960
- component.path = func(component.path);
17972
+ let output = "";
17973
+ for (let i = 0; i < input.length; i++) {
17974
+ if (input[i] === "%" && i + 2 < input.length) {
17975
+ const hex3 = input.slice(i + 1, i + 3);
17976
+ if (isHexPair(hex3)) {
17977
+ const normalizedHex = hex3.toUpperCase();
17978
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
17979
+ if (decodeUnreserved && isUnreserved(decoded)) {
17980
+ output += decoded;
17981
+ } else {
17982
+ output += "%" + normalizedHex;
17983
+ }
17984
+ i += 2;
17985
+ continue;
17986
+ }
17987
+ }
17988
+ output += input[i];
17961
17989
  }
17962
- if (component.query !== void 0) {
17963
- component.query = func(component.query);
17990
+ return output;
17991
+ }
17992
+ function normalizePathEncoding(input) {
17993
+ let output = "";
17994
+ for (let i = 0; i < input.length; i++) {
17995
+ if (input[i] === "%" && i + 2 < input.length) {
17996
+ const hex3 = input.slice(i + 1, i + 3);
17997
+ if (isHexPair(hex3)) {
17998
+ const normalizedHex = hex3.toUpperCase();
17999
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
18000
+ if (decoded !== "." && isUnreserved(decoded)) {
18001
+ output += decoded;
18002
+ } else {
18003
+ output += "%" + normalizedHex;
18004
+ }
18005
+ i += 2;
18006
+ continue;
18007
+ }
18008
+ }
18009
+ if (isPathCharacter(input[i])) {
18010
+ output += input[i];
18011
+ } else {
18012
+ output += escape(input[i]);
18013
+ }
17964
18014
  }
17965
- if (component.fragment !== void 0) {
17966
- component.fragment = func(component.fragment);
18015
+ return output;
18016
+ }
18017
+ function escapePreservingEscapes(input) {
18018
+ let output = "";
18019
+ for (let i = 0; i < input.length; i++) {
18020
+ if (input[i] === "%" && i + 2 < input.length) {
18021
+ const hex3 = input.slice(i + 1, i + 3);
18022
+ if (isHexPair(hex3)) {
18023
+ output += "%" + hex3.toUpperCase();
18024
+ i += 2;
18025
+ continue;
18026
+ }
18027
+ }
18028
+ output += escape(input[i]);
17967
18029
  }
17968
- return component;
18030
+ return output;
17969
18031
  }
17970
18032
  function recomposeAuthority(component) {
17971
18033
  const uriTokens = [];
@@ -17980,7 +18042,7 @@ var require_utils = __commonJS({
17980
18042
  if (ipV6res.isIPV6 === true) {
17981
18043
  host = `[${ipV6res.escapedHost}]`;
17982
18044
  } else {
17983
- host = component.host;
18045
+ host = reescapeHostDelimiters(host, false);
17984
18046
  }
17985
18047
  }
17986
18048
  uriTokens.push(host);
@@ -17994,7 +18056,10 @@ var require_utils = __commonJS({
17994
18056
  module.exports = {
17995
18057
  nonSimpleDomain,
17996
18058
  recomposeAuthority,
17997
- normalizeComponentEncoding,
18059
+ reescapeHostDelimiters,
18060
+ normalizePercentEncoding,
18061
+ normalizePathEncoding,
18062
+ escapePreservingEscapes,
17998
18063
  removeDotSegments,
17999
18064
  isIPv4,
18000
18065
  isUUID,
@@ -18218,12 +18283,12 @@ var require_schemes = __commonJS({
18218
18283
  var require_fast_uri = __commonJS({
18219
18284
  "node_modules/fast-uri/index.js"(exports, module) {
18220
18285
  "use strict";
18221
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
18286
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
18222
18287
  var { SCHEMES, getSchemeHandler } = require_schemes();
18223
18288
  function normalize(uri, options) {
18224
18289
  if (typeof uri === "string") {
18225
18290
  uri = /** @type {T} */
18226
- serialize(parse3(uri, options), options);
18291
+ normalizeString(uri, options);
18227
18292
  } else if (typeof uri === "object") {
18228
18293
  uri = /** @type {T} */
18229
18294
  parse3(serialize(uri, options), options);
@@ -18290,19 +18355,9 @@ var require_fast_uri = __commonJS({
18290
18355
  return target;
18291
18356
  }
18292
18357
  function equal(uriA, uriB, options) {
18293
- if (typeof uriA === "string") {
18294
- uriA = unescape(uriA);
18295
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
18296
- } else if (typeof uriA === "object") {
18297
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
18298
- }
18299
- if (typeof uriB === "string") {
18300
- uriB = unescape(uriB);
18301
- uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
18302
- } else if (typeof uriB === "object") {
18303
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
18304
- }
18305
- return uriA.toLowerCase() === uriB.toLowerCase();
18358
+ const normalizedA = normalizeComparableURI(uriA, options);
18359
+ const normalizedB = normalizeComparableURI(uriB, options);
18360
+ return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
18306
18361
  }
18307
18362
  function serialize(cmpts, opts) {
18308
18363
  const component = {
@@ -18327,12 +18382,12 @@ var require_fast_uri = __commonJS({
18327
18382
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
18328
18383
  if (component.path !== void 0) {
18329
18384
  if (!options.skipEscape) {
18330
- component.path = escape(component.path);
18385
+ component.path = escapePreservingEscapes(component.path);
18331
18386
  if (component.scheme !== void 0) {
18332
18387
  component.path = component.path.split("%3A").join(":");
18333
18388
  }
18334
18389
  } else {
18335
- component.path = unescape(component.path);
18390
+ component.path = normalizePercentEncoding(component.path);
18336
18391
  }
18337
18392
  }
18338
18393
  if (options.reference !== "suffix" && component.scheme) {
@@ -18367,7 +18422,16 @@ var require_fast_uri = __commonJS({
18367
18422
  return uriTokens.join("");
18368
18423
  }
18369
18424
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
18370
- function parse3(uri, opts) {
18425
+ function getParseError(parsed, matches) {
18426
+ if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
18427
+ return 'URI path must start with "/" when authority is present.';
18428
+ }
18429
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
18430
+ return "URI port is malformed.";
18431
+ }
18432
+ return void 0;
18433
+ }
18434
+ function parseWithStatus(uri, opts) {
18371
18435
  const options = Object.assign({}, opts);
18372
18436
  const parsed = {
18373
18437
  scheme: void 0,
@@ -18378,6 +18442,7 @@ var require_fast_uri = __commonJS({
18378
18442
  query: void 0,
18379
18443
  fragment: void 0
18380
18444
  };
18445
+ let malformedAuthorityOrPort = false;
18381
18446
  let isIP = false;
18382
18447
  if (options.reference === "suffix") {
18383
18448
  if (options.scheme) {
@@ -18398,6 +18463,11 @@ var require_fast_uri = __commonJS({
18398
18463
  if (isNaN(parsed.port)) {
18399
18464
  parsed.port = matches[5];
18400
18465
  }
18466
+ const parseError = getParseError(parsed, matches);
18467
+ if (parseError !== void 0) {
18468
+ parsed.error = parsed.error || parseError;
18469
+ malformedAuthorityOrPort = true;
18470
+ }
18401
18471
  if (parsed.host) {
18402
18472
  const ipv4result = isIPv4(parsed.host);
18403
18473
  if (ipv4result === false) {
@@ -18436,14 +18506,18 @@ var require_fast_uri = __commonJS({
18436
18506
  parsed.scheme = unescape(parsed.scheme);
18437
18507
  }
18438
18508
  if (parsed.host !== void 0) {
18439
- parsed.host = unescape(parsed.host);
18509
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
18440
18510
  }
18441
18511
  }
18442
18512
  if (parsed.path) {
18443
- parsed.path = escape(unescape(parsed.path));
18513
+ parsed.path = normalizePathEncoding(parsed.path);
18444
18514
  }
18445
18515
  if (parsed.fragment) {
18446
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
18516
+ try {
18517
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
18518
+ } catch {
18519
+ parsed.error = parsed.error || "URI malformed";
18520
+ }
18447
18521
  }
18448
18522
  }
18449
18523
  if (schemeHandler && schemeHandler.parse) {
@@ -18452,7 +18526,29 @@ var require_fast_uri = __commonJS({
18452
18526
  } else {
18453
18527
  parsed.error = parsed.error || "URI can not be parsed.";
18454
18528
  }
18455
- return parsed;
18529
+ return { parsed, malformedAuthorityOrPort };
18530
+ }
18531
+ function parse3(uri, opts) {
18532
+ return parseWithStatus(uri, opts).parsed;
18533
+ }
18534
+ function normalizeString(uri, opts) {
18535
+ return normalizeStringWithStatus(uri, opts).normalized;
18536
+ }
18537
+ function normalizeStringWithStatus(uri, opts) {
18538
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
18539
+ return {
18540
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
18541
+ malformedAuthorityOrPort
18542
+ };
18543
+ }
18544
+ function normalizeComparableURI(uri, opts) {
18545
+ if (typeof uri === "string") {
18546
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
18547
+ return malformedAuthorityOrPort ? void 0 : normalized;
18548
+ }
18549
+ if (typeof uri === "object") {
18550
+ return serialize(uri, opts);
18551
+ }
18456
18552
  }
18457
18553
  var fastUri = {
18458
18554
  SCHEMES,
@@ -28049,49 +28145,60 @@ var init_profile = __esm({
28049
28145
  // src/firefox/core.ts
28050
28146
  import { Builder, Browser, Capabilities } from "selenium-webdriver";
28051
28147
  import firefox from "selenium-webdriver/firefox.js";
28052
- import { mkdirSync as mkdirSync2, openSync, closeSync } from "fs";
28148
+ import { mkdirSync as mkdirSync2, openSync, closeSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
28053
28149
  import { homedir } from "os";
28054
- import { join as join2 } from "path";
28055
- async function findGeckodriver() {
28056
- const path = await import("path");
28057
- const { execFileSync } = await import("child_process");
28150
+ import { join as join2, delimiter } from "path";
28151
+ function findGeckodriverInPath(binaryName) {
28152
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
28153
+ if (!dir) {
28154
+ continue;
28155
+ }
28156
+ const candidate = join2(dir, binaryName);
28157
+ if (existsSync2(candidate)) {
28158
+ return candidate;
28159
+ }
28160
+ }
28161
+ return null;
28162
+ }
28163
+ function findGeckodriverInSeleniumCache(binaryName) {
28164
+ const cacheBase = join2(homedir(), ".cache/selenium/geckodriver");
28058
28165
  try {
28059
- const { createRequire } = await import("module");
28060
- const require2 = createRequire(import.meta.url);
28061
- const swPkg = require2.resolve("selenium-webdriver/package.json");
28062
- const swDir = path.dirname(swPkg);
28063
- const platform = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
28064
- const ext = process.platform === "win32" ? ".exe" : "";
28065
- const smBin = path.join(swDir, "bin", platform, `selenium-manager${ext}`);
28066
- const result = JSON.parse(
28067
- execFileSync(smBin, ["--driver", "geckodriver", "--output", "json"], { encoding: "utf-8" })
28068
- );
28069
- return result.result.driver_path;
28070
- } catch {
28071
- const os = await import("os");
28072
- const fs = await import("fs");
28073
- const cacheBase = path.join(os.homedir(), ".cache/selenium/geckodriver");
28074
- const ext = process.platform === "win32" ? ".exe" : "";
28075
- const binaryName = `geckodriver${ext}`;
28076
- try {
28077
- if (fs.existsSync(cacheBase)) {
28078
- for (const platformDir of fs.readdirSync(cacheBase)) {
28079
- const platformPath = path.join(cacheBase, platformDir);
28080
- if (!fs.statSync(platformPath).isDirectory()) {
28081
- continue;
28082
- }
28083
- for (const versionDir of fs.readdirSync(platformPath).sort().reverse()) {
28084
- const candidate = path.join(platformPath, versionDir, binaryName);
28085
- if (fs.existsSync(candidate)) {
28086
- return candidate;
28087
- }
28088
- }
28166
+ if (!existsSync2(cacheBase)) {
28167
+ return null;
28168
+ }
28169
+ for (const platformDir of readdirSync(cacheBase)) {
28170
+ const platformPath = join2(cacheBase, platformDir);
28171
+ if (!statSync(platformPath).isDirectory()) {
28172
+ continue;
28173
+ }
28174
+ for (const versionDir of readdirSync(platformPath).sort().reverse()) {
28175
+ const candidate = join2(platformPath, versionDir, binaryName);
28176
+ if (existsSync2(candidate)) {
28177
+ return candidate;
28089
28178
  }
28090
28179
  }
28091
- } catch {
28092
28180
  }
28093
- throw new Error("Cannot find geckodriver binary. Ensure selenium-webdriver is installed.");
28181
+ } catch {
28094
28182
  }
28183
+ return null;
28184
+ }
28185
+ async function findGeckodriverInNpmPackage() {
28186
+ try {
28187
+ const { download } = await import("geckodriver");
28188
+ log("geckodriver not found in PATH or selenium cache, downloading via npm package...");
28189
+ return await download();
28190
+ } catch {
28191
+ return null;
28192
+ }
28193
+ }
28194
+ async function findGeckodriver() {
28195
+ const ext = process.platform === "win32" ? ".exe" : "";
28196
+ const binaryName = `geckodriver${ext}`;
28197
+ const found = findGeckodriverInPath(binaryName) ?? findGeckodriverInSeleniumCache(binaryName) ?? await findGeckodriverInNpmPackage();
28198
+ if (!found) {
28199
+ throw new Error("Cannot find geckodriver binary. Ensure geckodriver is in PATH.");
28200
+ }
28201
+ return found;
28095
28202
  }
28096
28203
  var FirefoxCore;
28097
28204
  var init_core3 = __esm({
@@ -28113,12 +28220,35 @@ var init_core3 = __esm({
28113
28220
  * Launch Firefox (or connect to an existing instance) and establish BiDi connection
28114
28221
  */
28115
28222
  async connect() {
28116
- if (this.options.connectExisting) {
28223
+ const isAndroid = this.options.androidDevice !== void 0;
28224
+ if (isAndroid) {
28225
+ log("Launching Firefox for Android via ADB...");
28226
+ } else if (this.options.connectExisting) {
28117
28227
  log("Connecting to existing Firefox via Marionette...");
28118
28228
  } else {
28119
28229
  log("Launching Firefox via Selenium WebDriver BiDi...");
28120
28230
  }
28121
- if (this.options.connectExisting) {
28231
+ if (isAndroid) {
28232
+ const geckodriverPath = await findGeckodriver();
28233
+ logDebug(`Using geckodriver: ${geckodriverPath}`);
28234
+ const pkg = this.options.androidPackage ?? "org.mozilla.firefox";
28235
+ const mozOptions = { androidPackage: pkg };
28236
+ const deviceSerial = this.options.androidDevice;
28237
+ if (deviceSerial && deviceSerial !== "auto") {
28238
+ mozOptions.androidDeviceSerial = deviceSerial;
28239
+ }
28240
+ if (this.options.prefs) {
28241
+ mozOptions.prefs = this.options.prefs;
28242
+ }
28243
+ const caps = new Capabilities();
28244
+ caps.set("webSocketUrl", true);
28245
+ caps.set("moz:firefoxOptions", mozOptions);
28246
+ if (this.options.acceptInsecureCerts) {
28247
+ caps.set("acceptInsecureCerts", true);
28248
+ }
28249
+ const serviceBuilder = new firefox.ServiceBuilder(geckodriverPath);
28250
+ this.driver = firefox.Driver.createSession(caps, serviceBuilder.build());
28251
+ } else if (this.options.connectExisting) {
28122
28252
  const port = this.options.marionettePort ?? 2828;
28123
28253
  const geckodriverPath = await findGeckodriver();
28124
28254
  logDebug(`Using geckodriver: ${geckodriverPath}`);
@@ -28177,7 +28307,14 @@ var init_core3 = __esm({
28177
28307
  firefoxOptions.setPreference(name, value);
28178
28308
  }
28179
28309
  }
28180
- const serviceBuilder = new firefox.ServiceBuilder();
28310
+ let serviceBuilder;
28311
+ if (process.platform === "win32") {
28312
+ const geckodriverPath = await findGeckodriver();
28313
+ logDebug(`Using geckodriver: ${geckodriverPath}`);
28314
+ serviceBuilder = new firefox.ServiceBuilder(geckodriverPath);
28315
+ } else {
28316
+ serviceBuilder = new firefox.ServiceBuilder();
28317
+ }
28181
28318
  if (this.logFilePath) {
28182
28319
  this.logFileFd = openSync(this.logFilePath, "a");
28183
28320
  serviceBuilder.setStdio(["ignore", this.logFileFd, this.logFileFd]);
@@ -29051,22 +29188,41 @@ var init_dom = __esm({
29051
29188
  });
29052
29189
 
29053
29190
  // src/firefox/pages.ts
29054
- var PageManagement;
29191
+ function isCommonScheme(url2) {
29192
+ try {
29193
+ return COMMON_URL_SCHEMES.includes(new URL(url2).protocol);
29194
+ } catch {
29195
+ return false;
29196
+ }
29197
+ }
29198
+ var COMMON_URL_SCHEMES, PageManagement;
29055
29199
  var init_pages = __esm({
29056
29200
  "src/firefox/pages.ts"() {
29057
29201
  "use strict";
29058
29202
  init_logger();
29203
+ COMMON_URL_SCHEMES = ["http:", "https:", "data:", "blob:", "file:"];
29059
29204
  PageManagement = class {
29060
- constructor(driver, getCurrentContextId, setCurrentContextId) {
29205
+ constructor(driver, getCurrentContextId, setCurrentContextId, sendBiDiCommand) {
29061
29206
  this.driver = driver;
29062
29207
  this.getCurrentContextId = getCurrentContextId;
29063
29208
  this.setCurrentContextId = setCurrentContextId;
29209
+ this.sendBiDiCommand = sendBiDiCommand;
29064
29210
  }
29065
29211
  /**
29066
- * Navigate to URL
29212
+ * Navigate to URL using BiDi
29067
29213
  */
29068
29214
  async navigate(url2) {
29069
- await this.driver.get(url2);
29215
+ const contextId = this.getCurrentContextId();
29216
+ if (!contextId) {
29217
+ throw new Error(`Cannot navigate: no browsing context ID`);
29218
+ }
29219
+ const wait = isCommonScheme(url2) ? "interactive" : "none";
29220
+ await this.sendBiDiCommand("browsingContext.navigate", {
29221
+ context: contextId,
29222
+ url: url2,
29223
+ wait
29224
+ });
29225
+ logDebug(`BiDi navigate (wait:${wait}) to: ${url2}`);
29070
29226
  log(`Navigated to: ${url2}`);
29071
29227
  }
29072
29228
  /**
@@ -29188,7 +29344,7 @@ var init_pages = __esm({
29188
29344
  const newIdx = handles.length - 1;
29189
29345
  this.setCurrentContextId(handles[newIdx]);
29190
29346
  this.cachedSelectedIdx = newIdx;
29191
- await this.driver.get(url2);
29347
+ await this.navigate(url2);
29192
29348
  return newIdx;
29193
29349
  }
29194
29350
  /**
@@ -29673,7 +29829,8 @@ var init_firefox = __esm({
29673
29829
  this.pages = new PageManagement(
29674
29830
  driver,
29675
29831
  () => this.core.getCurrentContextId(),
29676
- (id) => this.core.setCurrentContextId(id)
29832
+ (id) => this.core.setCurrentContextId(id),
29833
+ (method, params) => this.core.sendBiDiCommand(method, params)
29677
29834
  );
29678
29835
  if (this.consoleEvents) {
29679
29836
  try {
@@ -31586,7 +31743,7 @@ var init_utilities = __esm({
31586
31743
  });
31587
31744
 
31588
31745
  // src/tools/firefox-management.ts
31589
- import { readFileSync as readFileSync2, existsSync as existsSync2, statSync } from "fs";
31746
+ import { readFileSync as readFileSync2, existsSync as existsSync3, statSync as statSync2 } from "fs";
31590
31747
  async function handleGetFirefoxLogs(input) {
31591
31748
  try {
31592
31749
  const {
@@ -31601,11 +31758,11 @@ async function handleGetFirefoxLogs(input) {
31601
31758
  "No output capture configured. Use --env to set environment variables or --output-file to enable output capture."
31602
31759
  );
31603
31760
  }
31604
- if (!existsSync2(logFilePath)) {
31761
+ if (!existsSync3(logFilePath)) {
31605
31762
  return successResponse(`Output file not found: ${logFilePath}`);
31606
31763
  }
31607
31764
  if (since !== void 0) {
31608
- const stats = statSync(logFilePath);
31765
+ const stats = statSync2(logFilePath);
31609
31766
  const ageSeconds = (Date.now() - stats.mtimeMs) / 1e3;
31610
31767
  if (ageSeconds > since) {
31611
31768
  return successResponse(
@@ -31674,8 +31831,8 @@ async function handleGetFirefoxInfo(_input) {
31674
31831
  if (logFilePath) {
31675
31832
  info.push("");
31676
31833
  info.push(`Output File: ${logFilePath}`);
31677
- if (existsSync2(logFilePath)) {
31678
- const stats = statSync(logFilePath);
31834
+ if (existsSync3(logFilePath)) {
31835
+ const stats = statSync2(logFilePath);
31679
31836
  const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
31680
31837
  info.push(` Size: ${sizeMB} MB`);
31681
31838
  info.push(` Last Modified: ${stats.mtime.toISOString()}`);
@@ -31712,7 +31869,7 @@ async function handleRestartFirefox(input) {
31712
31869
  profilePath: profilePath ?? currentOptions.profilePath,
31713
31870
  env: newEnv !== void 0 ? newEnv : currentOptions.env,
31714
31871
  headless: headless !== void 0 ? headless : currentOptions.headless,
31715
- startUrl: startUrl ?? currentOptions.startUrl ?? "about:home",
31872
+ startUrl: startUrl ?? currentOptions.startUrl ?? "about:blank",
31716
31873
  prefs: mergedPrefs
31717
31874
  };
31718
31875
  setNextLaunchOptions(newOptions);
@@ -31766,7 +31923,7 @@ ${changes.join("\n")}`
31766
31923
  profilePath: profilePath ?? args.profilePath ?? void 0,
31767
31924
  env: newEnv,
31768
31925
  headless: headless ?? false,
31769
- startUrl: startUrl ?? "about:home"
31926
+ startUrl: startUrl ?? "about:blank"
31770
31927
  };
31771
31928
  setNextLaunchOptions(newOptions);
31772
31929
  const config2 = [`Binary: ${resolvedFirefoxPath}`];
@@ -31857,7 +32014,7 @@ var init_firefox_management = __esm({
31857
32014
  },
31858
32015
  startUrl: {
31859
32016
  type: "string",
31860
- description: "URL to navigate to after restart (optional, uses about:home if not specified)"
32017
+ description: "URL to navigate to after restart (optional, uses about:blank if not specified)"
31861
32018
  },
31862
32019
  prefs: {
31863
32020
  type: "object",
@@ -32577,7 +32734,9 @@ async function getFirefox() {
32577
32734
  marionettePort: args.marionettePort,
32578
32735
  env: envVars,
32579
32736
  logFile: args.outputFile ?? void 0,
32580
- prefs
32737
+ prefs,
32738
+ androidDevice: args.androidDevice ?? void 0,
32739
+ androidPackage: args.androidPackage ?? void 0
32581
32740
  };
32582
32741
  }
32583
32742
  firefox2 = new FirefoxClient(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mozilla/firefox-devtools-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Model Context Protocol (MCP) server for Firefox DevTools automation",
5
5
  "author": "Mozilla",
6
6
  "license": "MIT OR Apache-2.0",
@@ -15,6 +15,7 @@
15
15
  "build": "tsup",
16
16
  "build:moz": "node scripts/generate-moz-package.mjs && tsup --config tsup.config.moz.ts",
17
17
  "build:all": "npm run build && npm run build:moz",
18
+ "publish:moz": "node scripts/publish-moz-package.mjs",
18
19
  "start": "node dist/index.js",
19
20
  "setup": "node scripts/setup-mcp-config.js",
20
21
  "clean": "rm -rf dist",