npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

firefox-mcp-marionette

v0.9.0

Published

Zero-dependency Model Context Protocol server that drives your own Firefox (user-launched, --marionette) — or boots a dedicated loopback-only instance for the session on request — via its native wire protocol: snapshot, click, type, upload, screenshot, JS

Downloads

477

Readme

firefox-mcp-marionette

Zero-dependency Model Context Protocol server that drives a Firefox with Marionette enabled — the instance you launched with --marionette (the MCP asks before attaching to a running one), or a dedicated instance the MCP starts for you on request — via its native wire protocol.

AI agents get a precise DOM actuator: snapshot the interactive elements of a page, then click, type, select, toggle checkboxes, upload files, wait for conditions, run JS, and screenshot — all against the browser session you control (your profile, your logins, your kill switch).

Why not Playwright/CDP?

Playwright's browser-automation channels (CDP, extension mode) target Chromium, or force the library to launch and own a pinned browser build. Marionette is different:

  • You own the browser. The server connects over loopback TCP (default port 2828) to the Firefox you launched, in whatever profile you chose — but never silently: on the first browser call of a session it probes the endpoint, and if a running Firefox answers, the tool asks you whether to attach to it, launch a new dedicated instance, or do something else (nothing attaches on its own). If nothing is reachable, it asks instead of failing — and if you choose it, fx_launch starts a fresh, dedicated, loopback-only instance with a clean profile. Nothing in this repo downloads or upgrades a browser.
  • Zero dependencies. No npm install of runtime deps, no browser downloads, no CDP shim. One Node runtime (>= 20) plus your existing Firefox.
  • Native protocol. Frames are length-prefixed JSON over TCP — the same protocol Selenium's Firefox driver speaks. No protocol translation, no version drift.

Install

Prerequisites: Node.js >= 20 and Firefox — that's all; there are no runtime dependencies and nothing ever downloads or upgrades a browser. And the mental model: the server runs as the child process of your MCP client over stdio — the client spawns it per session, so there is no daemon to install, start, or stop yourself.

Node MCPs are usually distributed through the npm registry, run via npx from the client config (the same pattern as @playwright/mcp and the official @modelcontextprotocol/server-* packages). This server is published that way: firefox-mcp-marionette — the whole install is one command, no git needed (npx ships with Node). Three ways to get the code on your machine:

  • npm (recommended). npx -y firefox-mcp-marionette — one-shot run straight from the registry (that is all step 1 of the Quick start is); or npm i -g firefox-mcp-marionette to keep it installed.
  • Source zip — no git, no npm. Download the archive in a browser: main.zip, unzip it, and point your client config at the extracted src/server.mjs instead.
  • git clone (development/contributing). git clone https://github.com/bornmw/firefox-mcp-marionette; npm link optionally puts the bin on PATH.

Copying the repo around? The minimal footprint is the whole src/ directory (4 files: server.mjs, marionette.mjs, protocol.mjs, evalwrap.mjs) — they import each other by relative path, so a single server.mjs alone will not work. scripts/ (smoke tests) and test/ are optional; nothing in src/ imports them.

Quick start

The whole setup is: register the server in your MCP client → have a Firefox to attach to (or let the MCP start one) → use the fx_* tools.

1. Register the server with your MCP client (it spawns the server process for you, per session):

opencode (opencode.json):

{
  "mcp": {
    "marionette": {
      "type": "local",
      "command": ["npx", "-y", "firefox-mcp-marionette"],
      "environment": {
        "FX_MARIONETTE_PORT": "2828",
        "FX_MCP_FILE_ROOTS": "/tmp,/your/projects"
      },
      "enabled": true
    }
  }
}

Pin a version with "command": ["npx", "-y", "[email protected]"]; developing from a local checkout instead? Use "command": ["node", "/absolute/path/to/firefox-mcp-marionette/src/server.mjs"] — the same environment block works for both.

2. Have a Firefox listening on port 2828 — or don't. Either launch one yourself:

firefox --marionette        # dedicated profile recommended; default port 2828 matches

or start your agent session — on the first browser call the MCP probes the endpoint non-invasively (no session is opened by the probe): a reachable Firefox → you're asked whether to attach to it, launch a new dedicated instance instead, or do something else; nothing reachable → the same kind of question (a new instance via fx_launch, connect to one you provide, or your own direction). FX_MCP_AUTO_LAUNCH=1 skips the question and auto-launches when nothing is listening.

3. Smoke test (optional):

npm run e2e:live

The fx_* tools are now available in-session.

First trigger in a session: the MCP probes, then asks

The stdio server lives with one automation session (one opencode session). On the first browser call it probes the configured endpoint (FX_MARIONETTE_HOST:PORT) with a short, non-invasive connection: open TCP, expect the Marionette hello frame, close the socket — the probe never opens a session, so it cannot count as the browser's active client and cannot displace another client. Then:

  • A Firefox answers → nothing attaches. Every browser tool returns a structured browser-detected decision, and the agent asks you:
    1. Attach to the detected browserfx_connect {host, port}. This commits that endpoint for the session; later reconnects (e.g. after a browser restart) are silent.
    2. Start a new dedicated instance insteadfx_launch: the server picks a free port, writes that port into a fresh profile's user.js prefs (user_pref("marionette.port", N) + marionette.enabled — there is no --marionette-port CLI flag), runs firefox --marionette --no-remote -profile <dir>, and attaches to it. The detected instance keeps running, untouched. The new instance starts empty (no cookies/logins). Stop it later with fx_shutdown.
    3. Something else — your direction (point at a different endpoint, reconfigure, stop that browser first, …).
  • Nothing reachable → instead of a raw ECONNREFUSED, every browser tool returns a structured need_bootstrap decision with three options, and the agent asks you:
    1. Start a new dedicated instancefx_launch (as above).
    2. Connect to an already-running instancefx_connect {host, port} with details you provide.
    3. Something else — your direction (launch it yourself, reconfigure the endpoint, …).
  • Something answers but drops the handshakebusy-other-client: a Marionette browser there likely already holds another active client (Marionette serves ONE client per browser). The agent asks how to proceed — free the other client, point elsewhere, or launch new.

Every decision payload carries an explicit instruction field: the browser choice belongs to you, not the agent — the agent must present the question and options to you and wait for your explicit choice before calling any listed tool (never launch/attach on its own judgment).

Once an endpoint is committed (you chose fx_connect for a detected browser, fx_launch started an instance, or FX_MCP_AUTO_LAUNCH=1 booted one when nothing was listening), later reconnects within the session are silent — the question is asked at most once per endpoint per session. fx_status never opens a session itself: with no committed endpoint it only probes and reports (connected, endpoint vs configured, the probe result, and the matching options — never an error); it attaches and reports live state only for committed endpoints. It also reports launched/launchedCurrent when this server started the browser. FX_MCP_AUTO_LAUNCH=1 auto-boots option 1 transparently in the nothing-reachable case (the tool runs in the same call, result tagged auto_started) and never auto-attaches to a detected browser.

Matching the port (attach only when you choose it; launch on request)

The server attaches over loopback TCP to a firefox --marionette instance on FX_MARIONETTE_HOST:PORT (you launched it, or fx_launch started it for this session). So the browser's Marionette port must equal the endpoint of the MCP:

  • Default port 2828 (both sides) → just firefox --marionette. No extra config needed.

  • Custom port → there is no --marionette-port CLI flag. The port is always set through the profile's user.js prefs. Let fx_launch {port: 2829} do the whole procedure (fresh profile dir + prefs + launch + attach), or do it by hand:

    PROFILE=~/.mozilla/firefox/mcp-2829   # any dir
    mkdir -p "$PROFILE"
    printf 'user_pref("marionette.enabled", true);\nuser_pref("marionette.port", 2829);\n' > "$PROFILE/user.js"
    firefox --marionette --no-remote -profile "$PROFILE"

    Then point the MCP at it via FX_MARIONETTE_PORT (opencode config) — or, if the MCP is already running, re-point it at runtime with fx_connect {host, port} (no restart needed).

  • Verify / diagnose: fx_status reports both the active endpoint and the configured endpoint. If it can't connect, the error names the port it tried and how to launch Firefox there (e.g. ECONNREFUSED 127.0.0.1:2829 → nothing listening; launch as above). Remember Marionette serves one active client per browser — don't leave another firefox-mcp-marionette (or another automation) attached to the same instance.

Typical flow:

  1. fx_navigate to the page
  2. fx_snapshot → numbered map of interactive elements (refs)
  3. fx_click / fx_type / fx_select / fx_toggle / fx_upload by ref (or CSS selector)
  4. Forms: fx_form → field map (index/label/context/value), then fx_field (set by index/id/label) and fx_answer (Yes/No or radio/checkbox questions by question text + option label); fx_scroll before clicking elements obscured by fixed headers
  5. fx_wait for the next state; fx_screenshot + your own vision pass to verify what the DOM can't

Form-tool gotchas (from live ATS/portal forms): re-renders can silently drop checked boxes — re-verify all fields after any state change; a free-text location field is often separate from a city checkbox group; required radio groups are sometimes not wrapped in labeled field containers — audit fx_form.groups (and a final screenshot) instead of assuming the labeled fields are the whole form; DOM checked ≠ the framework's form state — trust the tools' confirmed/verified output (a stale pre-selected option is the classic failure: fx_answer handles it via the toggle cycle). Long application forms (e.g. Google) hide mandatory consent/attestation checkboxes ("…hereby certify that…", "I understand that the information I submit…") that gate the whole submit/apply: the button is left hard-disabled or the click silently no-ops until the box is ticked — that is client-side enablement, not bot protection; fx_gates surfaces these boxes (plus the disabled button and any alert banner) so you can find and check the actual gate. Material-style rows put the real <input> visually hidden under its own li/button chrome, so a direct input click can be reported "not clickable … obscured" — fx_click/fx_field/fx_answer recover by clicking the obscuring same-widget topmost and report it via overlay-top:….

Architecture

 AI agent (e.g. opencode)
        │  stdio · newline-delimited JSON-RPC 2.0
        ▼
  firefox-mcp-marionette (src/server.mjs)          ── tools: fx_* (29)
        │  loopback TCP · <byteLen>:<json> frames
        ▼
 your Firefox (firefox --marionette)      ── your profile, your cookies
  • src/protocol.mjs — pure wire codec (frame encode/parse, element-ref unwrap). No I/O, fully unit-tested.
  • src/marionette.mjs — async Marionette client (one socket, one session, pending-command map).
  • src/server.mjs — MCP stdio server + tool implementations.

Design notes (bugs that cost real debugging time)

  • Frames are pure ASCII. JSON.stringify does not escape U+E000/U+E001 (W3C file markers) or any char ≥ 0x7F; in UTF-8 those are multi-byte, while the length prefix is computed from string length. That desynchronizes the stream for the rest of the connection. Every frame is \uXXXX-escaped so declared length always equals actual bytes (regression-tested).
  • Element refs are unwrapped. FindElement replies wrap the uuid ({ "element-…": "uuid" }); subsequent commands (ElementClick, ElementSendKeys, …) take the bare uuid.
  • File uploads use the raw absolute path in ElementSendKeys — this protocol generation has no W3C base64 file encoding (those codepoints are the legacy Selenium NULL/CANCEL keys there).
  • Script bodies must return. W3C ExecuteScript bodies are function bodies: a bare expression statement evaluates and is discarded.
  • Marionette never awaits returned Promises. A return (async () => { … })() body would serialize to null immediately, so fx_eval runs the body through a synchronous wrapper and polls window until the Promise settles (two-phase protocol; wait_ms bounds it, default 30 s).
  • #id CSS selectors with digit-leading ids are invalid (e.g. Ashby's UUID ids #56d78818-…). fx_click/fx_type auto-rewrite them to [id="…"] and report the rewrite (used); unsupported CSS (e.g. :has()) is caught in-page before the driver call with an actionable error.
  • DOM checked ≠ framework form state. Frameworks (notably Ashby) register a choice only on a real change. fx_answer therefore detects a stale pre-selected option (or an ineffective click) and runs a toggle cycle — click another option, then the target — on exclusive (radio/button) groups, re-verifying afterwards; fx_form aggregates radio/checkbox inputs into choice groups (question context + per-option state) so required groups can be audited in one call.
  • Marionette keeps a persistent session across reconnects; a crashed automation client can leave stale session state — relaunch the browser if commands queue forever.
  • A single command must always settle. Commands are serialized and the browser's main thread can stall (modal dialog, hung navigation), so send() bounds every command via FX_MCP_CMD_TIMEOUT_MS (default 120 s). On expiry the connection is poisoned (socket destroyed, session cleared) and the next command reconnects fresh — without that, one unanswered command wedges the entire server forever.
  • Socket events are per-socket. The error/close handlers only act when this.sock === s. A superseded socket (dropped during a command-timeout poison) can emit late events after we've reconnected; reacting to them would destroy the fresh, healthy socket.
  • The first trigger is a decision, not an error, and not a silent attach. The server process == one automation session. Its first browser call runs a non-invasive probe (open the endpoint, expect the Marionette hello, close — the probe never opens a session, so it cannot steal the active-client slot): a live browser → a browser-detected decision (attach via fx_connect / launch new via fx_launch / user-directed); nothing listening → the need_bootstrap decision (launch new / connect / user-directed; FX_MCP_AUTO_LAUNCH=1 collapses it to auto-launch); a dropped handshake → busy-other-client (Marionette serves one client per browser — a held instance drops a second client's socket cleanly, which is a distinct probe outcome, not a classified-away error). Committing an endpoint (user-chosen fx_connect, fx_launch, or auto-launch) makes later in-session reconnects silent. fx_status never opens a session — it only probes and reports.
  • The launch port lives in prefs, never on the command line. Firefox has no --marionette-port flag, so fx_launch creates a fresh profile and writes user_pref("marionette.port", N) + user_pref("marionette.enabled", true) to its user.js before firefox --marionette --no-remote -profile <dir>. The started pid is recorded (memory + <profile>/.firefox-mcp-marionette-launched.json) so fx_shutdown kills exactly that instance — a user-launched browser is never touched.

Tools

| Tool | Purpose | |---|---| | fx_status | Connection, active endpoint vs configured, session, current page, navigator.webdriver. Never opens a session on its own: with no committed endpoint it probes non-invasively (connect → expect hello → close) and returns connected:false + probe result + options; with a committed endpoint it attaches/re-attaches and reports live state. Reports launched/launchedCurrent when this server started the instance | | fx_launch | Bootstrap option 1: start a NEW dedicated Firefox — fresh profile (<root>/firefox-mcp-marionette-<port>), port written into user.js prefs (marionette.port; no CLI flag exists), firefox --marionette --no-remote -profile <dir>, wait for the listener, attach. Optional {port} (default: first free above the configured one) and {profile} dir. Reuse: re-calling with the same live port re-attaches, no second process | | fx_shutdown | Stop an instance this server started via fx_launch (killed by the recorded pid; a user-launched browser is never touched). Defaults to the current endpoint | | fx_connect | Bootstrap option 2 / detected-browser approval: (re-)point the MCP at an already-running loopback endpoint {host, port} and attach (env-configured default when omitted). This commits the endpoint for the session — later reconnects are silent. Returns the active endpoint, the configured one, and the session; a failed attach returns a structured decision payload (probe result + options) instead of a raw error | | fx_navigate | Go to a URL | | fx_page | Current URL + title | | fx_snapshot | Interactive-element map with refs (incl. visible label text when present) | | fx_click | Click (ref or selector; digit-leading #id auto-rewritten to [id="…"], unsupported CSS caught in-page). If the element is not clickable because another element obscures it and the obscuring element belongs to the same widget (Material button chrome, an li/label over a hidden input), the obscuring topmost is clicked instead and reported as via: "overlay-top:…"; a foreign blocker is reported with its identity | | fx_type | Type text (clears first unless keep: true; same selector hardening) | | fx_select | Set <select> by option value or label | | fx_toggle | Set checkbox/radio state | | fx_upload | Set file input (raw path, must be under FX_MCP_FILE_ROOTS) | | fx_form | Dump visible form fields: index, type, label, name, context, value, options, files + aggregated choice groups (question context, per-option state); scopes to a CSS root | | fx_field | Set a field by index (from fx_form), id, or label substring: real keystrokes for text, verified real click (with fallbacks) for checkbox/radio, option match for select | | fx_answer | Answer a grouped choice question (Yes/No buttons, radio/checkbox options) by question text + option label; re-reads and reports the selection state; runs a toggle cycle on exclusive groups when a stale pre-selection (or ineffective click) is detected; self-heals to clicking the visible text-matching wrapper when option labels are unreadable (no-option, e.g. label-less li rows) | | fx_scroll | Scroll an element into view (e.g. under a fixed header), wait, return its top coordinate | | fx_gates | Consent/attestation gate audit (read-only): visible checkboxes with nearby text — flagging certify/understand/agree/consent/attest/terms/privacy wording — plus disabled buttons (a dead Submit/Apply) and visible alert banners. Run it whenever a submit click does nothing or a submit button stays disabled; the fix is usually an unchecked consent checkbox, not bot protection | | fx_links | All hyperlinks of the current page: text + absolute href (optional selector filter; reads open shadow roots). Generic read — no JS needed | | fx_extract | Structured page read ("scrape" without JS): one row per container selector; per-row fields {name: css\|"text"} | | fx_search | Search without JS: navigate to engine results (google/bing/duckduckgo presets; overridable container/title/snippet), return {title, link, snippet} rows; resolve:true follows each link in the browser and reports the real final URL/title (needed for redirect-wrapped hrefs, e.g. Google /goto) | | fx_eval | Run JS in the page (function body; return your value — a returned Promise is awaited, default 30 s via wait_ms) | | fx_wait | Wait for visible text or CSS selector (≤ 30 s) | | fx_screenshot | Full-page PNG (not just the viewport) to a file under an allowed root | | fx_windows / fx_window | List / switch windows | | fx_alert_state / fx_alert_accept / fx_alert_dismiss | Native dialogs | | fx_cookies | Current-origin cookies (names/domains only) |

Environment

| Variable | Default | Meaning | |---|---|---| | FX_MARIONETTE_HOST | 127.0.0.1 | Marionette endpoint (loopback only, by design) | | FX_MARIONETTE_PORT | 2828 | Firefox's --marionette port (must match the browser you launch; fx_status shows the active endpoint). Override at runtime with fx_connect {port} | | FX_MCP_FILE_ROOTS | /tmp | Comma-separated roots that fx_upload/fx_screenshot may touch | | FX_MCP_CMD_TIMEOUT_MS | 120000 | Per-command bound. A command that never settles (modal dialog, hung page) poisons the connection and auto-reconnects on the next command, so one stuck page can't wedge the whole server | | FX_MCP_AUTO_LAUNCH | off | When the first-call probe finds no reachable Firefox, fx_launch runs automatically instead of returning the bootstrap question. It never auto-attaches to a browser it detects — that always asks | | FX_MCP_FIREFOX_BIN | auto-detect | Firefox binary for fx_launch / auto-launch: a path or "cmd args" string (e.g. node /path/standin.mjs); default looks up firefox/firefox-esr on PATH + common system paths | | FX_MCP_PROFILE_DIR | ~/.mozilla/firefox | Base directory for the per-port profiles fx_launch creates (firefox-mcp-marionette-<port>/) |

Security

  • Loopback only. The client connects to 127.0.0.1 — there is deliberately no network path.
  • File access is rooted. Uploads and screenshots reject paths outside FX_MCP_FILE_ROOTS.
  • Use a dedicated profile for automation, and keep the browser visible: a human-in-the-loop is the expected model, not headless stealth. Native OS dialogs (e.g. the file picker) and CAPTCHAs are not automatable by design — stop and let the human handle them.
  • Launch is opt-in and self-contained. fx_launch/auto-launch only create a NEW profile under FX_MCP_PROFILE_DIR (never touching your daily profile), bind to 127.0.0.1 only, and record the started pid so fx_shutdown can stop exactly that instance — it never kills a browser the user launched.
  • Attach is confirmed, not automatic. A running Marionette Firefox found on the first call of a session is never attached to without your say-so: the tool returns a browser-detected decision (attach / launch new / other) instead of opening a session.

Testing

Zero-dependency test suite (built-in node:test):

npm test            # or: node --test test/*.test.mjs
  • test/protocol.test.mjs — frame codec, parser resilience, element-ref unwrapping (pure unit tests).
  • test/marionette.test.mjs — the real client against an in-process fake Marionette server that verifies every frame's byte integrity (non-ASCII payloads included).
  • test/server.test.mjs — spawns the real MCP server and drives it end-to-end (JSON-RPC plumbing, all tool paths, framing-safety under Unicode input, stdin-EOF shutdown); includes the fresh-session gate: a running browser is probed, tools ask before attaching, and fx_connect commits the endpoint.
  • test/bootstrap.test.mjs — first-trigger behavior against a stand-in "firefox" binary (test/helpers/fake_firefox.mjs): the no-browser decision payload, the running-browser decision (non-invasive probe → connect/launch question → fx_connect commits, then calls run), the busy-handshake classification (dropped connection → busy-other-client, not a raw error), the prefs-based launch (port flows only through user.js), attach/reuse, fx_shutdown pid lifecycle, and FX_MCP_AUTO_LAUNCH inline bootstrap.

Live-browser tests (start your own firefox --marionette first; note Marionette serves one active client at a time — no other firefox-mcp-marionette client may be attached):

  • npm run e2e:live — connection, navigation, screenshot through the real wire protocol.
  • npm run e2e:forms — the form primitives against a self-generated test page (labels, option/state round-trips, click fallbacks, negative cases).

CI: .github/workflows/ci.yml — syntax check + full test suite across Node 20/22/24.

License

GPL-2.0 (see LICENSE).