marionette-mcp
# firefox-mcp-marionette
Zero-dependency [Model Context Protocol](https://modelcontextprotocol.io) 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](https://github.com/bornmw/firefox-mcp-marionette/archive/refs/heads/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`):
```jsonc
{
"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", "firefox-mcp-marionette@0.8.0"]`; 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:
```bash
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):**
```bash
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 browser** — `fx_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 instead** — `fx_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 instance** — `fx_launch` (as above).
2. **Connect to an already-running instance** — `fx_connect {host, port}` with details you provide.
3. **Something else** — your direction (launch it yourself, reconfigure the endpoint, …).
* **Something answers but drops the handshake** → `busy-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:
```bash
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`):
```bash
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`).
TDQS
Scored across 18 tools
Each tool targets a distinct browser automation concern: navigation, page state, element actions, windows, alerts, cookies, and evaluation. There is minimal overlap, and similar-sounding tools like fx_window vs fx_windows are clearly differentiated by singular/plural usage and descriptions.
All tools share the fx_ prefix and use lowercase names without mixed case or underscores, which is a predictable pattern. There is a slight mix of verb-style names like fx_navigate and fx_click with noun-style names like fx_page and fx_cookies, but the overall convention remains coherent.
Eighteen tools is slightly above the typical 3-15 range, but each tool covers a distinct and necessary browser automation action. The count feels justified for the scope, though a few could be consolidated without much loss.
The set covers core browser automation workflows: navigation, interaction, snapshots, waiting, screenshots, window management, alerts, cookies, and JS evaluation. Minor gaps exist such as no dedicated back/forward/refresh or cookie mutation, but fx_eval can compensate for many advanced needs.