Skip to main content
Glama
README.md
# firefox-jev-mcp

An MCP server that lets Claude drive Firefox, with a fast decision model for clicks: Claude plans, [Jev](https://docs.typesafe.ai) (TypeSafe) picks the element to act on, and Claude only takes over when Jev hesitates.

<picture>
  <source media="(prefers-color-scheme: dark)" srcset=".github/assets/race-dark.svg">
  <img src=".github/assets/race-light.svg" width="100%" alt="Wikipedia race: firefox-jev-mcp finishes in 14.7 s, firefox-devtools-mcp in 23.4 s">
</picture>

## Why

With a classic browser MCP server, the LLM rereads the page and decides on every click: one model turn per action, so a few seconds each time. Most clicks are simple decisions ("which link leads to Paris?"). firefox-jev-mcp hands them to Jev, a model that generates no text and returns one probability per page element, in about a second. Claude keeps what it does best: understanding the request, planning, and settling ambiguous cases.

## Results

Same task, same prompt, same model (Claude Opus 5.5 through `claude -p`), two races run in parallel: start from the Wikipedia article "Titanic" and reach "Eiffel Tower" by clicking links only (no search, no typing, no typed URL, no going back).

| Wikipedia race | firefox-jev-mcp | Mozilla firefox-devtools-mcp |
|---|--:|--:|
| Total time | **14.7 s** | 23.4 s |
| of which Claude thinking | **7.9 s** | 19.1 s |
| Claude turns | **4** | 12 |
| Claude tokens | **20.8k** | 88.5k |
| Cost per race, Claude and Jev included | **€0.033** | €0.050 |

Over 4 races, firefox-jev-mcp finished first every time: 17.2, 19.2, 22.7 and 14.7 s, against 23.5, 20.2, 27.8 and 23.4 s (the first ones used earlier versions of the server). At those rates, 1,000 races cost €32.74 against €49.60.

Basic actions, measured without an LLM on 4 real pages (median of 4 runs after a warm-up run):

<picture>
  <source media="(prefers-color-scheme: dark)" srcset=".github/assets/click-latency-dark.svg">
  <img src=".github/assets/click-latency-light.svg" width="100%" alt="Time per click: 181 ms for firefox-jev-mcp, the fastest of the 5 MCP servers tested">
</picture>

| Basic action | firefox-jev-mcp | Mozilla firefox-devtools-mcp | Google chrome-devtools-mcp | Playwright MCP | Playwright MCP |
|---|--:|--:|--:|--:|--:|
| Browser | Firefox | Firefox | Chrome | Chrome | Firefox |
| Click, until the next page has loaded | **181 ms** | 290 ms [1] | 326 ms | 958 ms | 1,257 ms |
| Navigate to a URL | 186 ms | **129 ms** [2] | 280 ms | 248 ms | 388 ms |
| Snapshot of a Wikipedia article, in tokens [3] | 3.7k | 1.1k [4] | 215k | 209k | 209k |

1. Median over 3 pages: its snapshot does not show the Hacker News navigation links.
2. Returns as soon as the HTML is parsed (DOMContentLoaded), before loading finishes.
3. Estimate: characters divided by 4.
4. Truncated to 100 lines by default.

Prices used: public API prices (Claude Opus 5.5, Jev 1.13 at $0.042 per million input tokens, output free), ECB rate of 2026-09-23. Method, raw data and scripts to reproduce everything are in [`bench/`](bench/README.md) (documented in French).

## How it works

```
Claude Code
   |  MCP tools (stdio)
   v
Local MCP server (server/, TypeScript)
   |-- Jev API: one Choice over the page elements + one Noul "goal reached?",
   |            then verification Nouls when the Choice hesitates
   |
   |  WebSocket ws://127.0.0.1:8765 (moz-extension:// origin only)
   v
Firefox extension (extension/)
   |-- content.js: extracts interactive elements, clicks, types, waits for rendering to finish
   '-- background.js: WebSocket bridge, tabs, navigation, iframes, network tracking, copies of received PDFs
```

Before each read (`browser_snapshot`, `browser_read`, steps of `browse_goal`), the extension waits for the page to finish rendering: the tab's data requests are done, the structure has been stable for 250 ms, and no loading indicator is visible ("Loading...", `aria-busy`, a progress bar without a value). It waits 4 s at most and reports an indicator that is still visible. A page that has already rendered is read without delay.

The `browse_goal` tool runs on its own: page snapshot, Jev's decision, action, and so on until the goal. It stops when the goal is reached, when Jev hesitates, or when the action is sensitive (paying, deleting, sending), and then gives Claude the best candidates, ready to use. Claude remains the orchestrator: the extension never contacts Claude, which works with a Claude subscription, without an Anthropic API key.

### How Jev decides, step by step

1. **Duplicates are merged.** Links to the same URL (menu, card, footer) become a single option. The probabilities of a Choice always add up to 1: without merging, Jev splits its probability across equivalent links, and its confidence drops even though it has no doubt about the destination.
2. **Used elements are removed.** An element already clicked or filled on the current page is no longer offered, which avoids loops.
3. **Text to type is known to Jev.** With `typeText`, Jev knows a search is possible. Without `typeText`, search fields are not offered to it.
4. **Choice + Noul "goal reached?"** in a single request.
5. **Verification when the Choice hesitates.** Below `minConfidence` (0.6), a second call asks one Noul per candidate among the top 3: "does this action move towards the goal?". A Choice is relative and shares its probability across several valid paths. A Noul is absolute. The loop acts if the chosen candidate reaches `verifyThreshold` (0.8), otherwise it hands back to Claude.
6. **Arrival check.** After each action, a Noul sees the previous page, the action taken and the current page, for goals defined by the starting page ("the 3rd story", "the cheapest book").
7. **Dropdowns.** A Choice over the options picks the value to select. A list drawn by the page (ARIA combobox: Angular Material, React Select...) often only has its options once open: the loop clicks it first, then `select` clicks the option and closes the list.
8. **Typing.** `typeText` is typed only once, and Enter is pressed only in a search field.

Above 250 options (the API limits a Choice to 255), the decision takes two passes: one Choice per slice of 200, in parallel requests, then a final Choice over the top 5 of each slice.

## Getting started

Requirements: Node.js 20 or later, Firefox 142 or later, a TypeSafe API key ([console.typesafe.ai](https://console.typesafe.ai)), and `pdftotext` to read PDFs (package `poppler-utils`).

```bash
npm install
cp .env.example .env   # then set TYPESAFE_API_KEY
```

1. Connect the server to Claude Code for all projects:

   ```bash
   claude mcp add firefox-jev --scope user -- "$(pwd)/node_modules/.bin/tsx" "$(pwd)/server/src/index.ts"
   ```

   Or run `claude` from this folder: the `.mcp.json` file is detected. The server only opens port 8765 on the first tool call, so sessions that do not use Firefox do not block it.

2. Install the `/firefox` command, available in all projects:

   ```bash
   ln -s "$(pwd)/skills/firefox" ~/.claude/skills/firefox
   ```

3. Ask Claude, for example: `/firefox open the API documentation on example.com`. The command starts Firefox if needed (`browser_launch` tool), then chains the tools.

To start Firefox by hand, with the extension, in the dedicated profile (`./firefox-profile`, created on first launch): `npm run firefox`. The extension badge shows `ON` when a Claude Code session is connected.

### Driving your everyday Firefox

The extension can also run in your everyday Firefox profile, to act on pages you already have open (resuming a form from an email, for example). It is **off by default** there: one click on its button gives control to Claude (badge `ON`), a second click takes it back. While it is on, it takes priority over the dedicated profile, which reconnects as soon as it is turned off.

Claude works in its own tab, in the background: the tab you are looking at stays displayed (a stream keeps playing), and tools without a `tabId` target the tab Claude is working in. On the first call, Claude only takes over the active tab if it is empty, otherwise it opens its own. To see its page, ask it to display it (`activate`).

Firefox only installs extensions signed by Mozilla permanently. "Unlisted" signing is free and does not publish the extension:

1. Create keys on [addons.mozilla.org/developers/addon/api/key](https://addons.mozilla.org/developers/addon/api/key/) and set them in `.env` (`WEB_EXT_API_KEY`, `WEB_EXT_API_SECRET`).
2. Run `npm run sign`: the signed `.xpi` file lands in `web-ext-artifacts/`. Each new signature requires raising the version in `extension/manifest.json`. Since 0.3.0, the extension also asks for access to network requests (`webRequest`), to follow page loading and keep a copy of PDFs: Firefox asks you to accept it on update.
3. Open it in Firefox (drag the file into a window, or `about:addons` then "Install Add-on From File").

To try it without signing: go to `about:debugging`, "This Firefox", "Load Temporary Add-on" and choose `extension/manifest.json`. Loaded this way, the extension behaves as in the dedicated profile: it connects by itself, and disappears when Firefox restarts.

## MCP tools

| Tool | Role |
|---|---|
| `browser_launch` | Starts Firefox with the extension if none is connected |
| `browse_goal` | Autonomous navigation towards a goal: snapshot, Jev's decision, action, in a loop |
| `jev_rank` | Ranks the page elements with Jev for a goal, without acting |
| `browser_snapshot` | Visible interactive elements, each with a ref (`e1`, `e2`...) |
| `browser_act` | `click`, `type` (with `submit` for Enter) or `select` on a ref |
| `browser_upload` | Drops local files (resume, letter...) into a file field or a drag-and-drop zone |
| `browser_save` | Saves to disk the PDF shown in the tab, a link's target or a URL, without overwriting anything |
| `browser_navigate` | Opens a URL or goes back, in the background; `activate` shows the tab |
| `browser_read` | Visible page text, iframes included, or the text of the PDF shown in the tab |
| `browser_scroll` | Scrolls by one screen |
| `browser_status` | Connection to the extension, presence of the Jev key, tabs (displayed, Claude's), recent PDFs |

`browse_goal` returns one of these statuses:

- `done`: Jev estimates that the page meets the goal (`goalThreshold`, 0.85 by default).
- `need_decision`: Claude has to decide. `reason` explains why and `candidates` gives the top 5 refs.
- `max_steps`: the number of steps was reached (`maxSteps`, 6 by default).
- `error`: a technical incident. The steps already done are kept.

## Limitations

- **Synthetic clicks**: Firefox offers extensions no equivalent of Chrome's `debugger` API. Events have `isTrusted = false`, which some sites (anti-bot, payment, OAuth) ignore.
- **Iframes** are explored 3 levels deep, even across origins, and the content of a modal window comes first. An iframe link that targets the whole page (`target="_top"`) is opened by the tab, because Firefox blocks that navigation without a real click. Open and closed Shadow DOMs are supported.
- **PDFs**: Firefox's viewer is closed to extensions, so the extension keeps a copy of the last 10 PDFs received (30 MB each at most, in memory, forgotten when it is turned off). `browser_read` extracts their text and `browser_save` saves them, even when a site serves a document only once or another PDF has replaced it in the tab.
- **Background tabs**: Firefox slows pages down there. The extension counts its waits with its background script, which is not slowed down, but a few sites only render their content once visible (load on scroll, render on next frame). In that case show the tab with `activate`.
- **At most 1,000 elements** per snapshot: on a very long article, the last links are not offered.
- **One session at a time** drives Firefox: the last one to call a tool takes control, and a request in progress in the other session fails.
- **Planning**: Jev excels when the target link is on the page, less so when choosing an intermediate page. It then hands back to Claude.
- **Language**: Jev is mostly trained in English. Goals phrased in English give better results.
- **Privacy**: the URL, title, element labels and the beginning of the text of pages visited by `browse_goal` or `jev_rank` are sent to the TypeSafe API. The `/firefox` command goes through Jev for all clicks, including in your everyday profile, on pages where you are logged in.

## License

MIT, see [LICENSE](LICENSE).