Skip to main content
Glama
README.md
<img src="extension/icons/icon.svg" width="96" height="96" alt="WebMCP Controller logo" align="right">

# WebMCP Controller — full-control Firefox MCP

Live Firefox (your **current profile, all tabs**) driven by any MCP harness
(Claude, opencode, Cursor, anything speaking Streamable HTTP). There is no
server to start: Firefox itself launches a small helper app (a native
messaging host) when the add-on loads, and the helper serves MCP.

While the model works, a glowing **AI cursor** glides to whatever it is
clicking or typing into, with a small bubble saying what it is doing
(toggle it in the popup).

**Install:** add the add-on, paste one command (below) to install the helper,
click **Copy MCP config** in the add-on popup, paste that into your harness.

## Architecture

```text
 Firefox                                   helper (native messaging host)        harness
+------------------------+  stdin/stdout  +-------------------------------+     +-----------------+
| WebExtension           | <------------> | webmcp-host              |     | Claude / opencode|
| background.js          |  (Firefox      |  started & stopped by Firefox |     | / any MCP client |
|  connectNative(...)    |   launches it) |  127.0.0.1:8901/mcp  <---------------- POST /mcp       |
+------------------------+                +-------------------------------+     +-----------------+
```

- The add-on owns the settings: it generates the **token** on first run and
  pushes `{token, port, bind}` to the helper. Defaults: port **8901**, bind
  **127.0.0.1**.
- Harness → helper: MCP **Streamable HTTP** `POST /mcp`, authenticated with
  `Authorization: Bearer <token>`. Unauthenticated: `GET /health`, `GET /`.
- The helper lives exactly as long as the add-on is running — close Firefox
  and the MCP endpoint goes away.

Why an extension at all: Firefox's CDP/remote-debugging surface is incomplete,
while a privileged WebExtension gets every tab, window, bookmark, history entry
and cookie of the profile you actually use. Why a native host: extensions
cannot listen on ports, and native messaging is the one sanctioned way for an
add-on to talk to a local program — no manual server, no extra login.

## Quickstart

### 1. Install the helper (once per computer)

The add-on's popup shows this for your system with a **Copy command** button.

**Windows** — open PowerShell or Command Prompt and paste:

```bat
powershell -ExecutionPolicy Bypass -c "irm https://github.com/hamb1y/webmcp-controller/releases/latest/download/install.ps1 | iex"
```

**macOS / Linux / WSL** — open Terminal and paste (inside WSL it installs the Windows helper for you):

```sh
curl -fsSL https://github.com/hamb1y/webmcp-controller/releases/latest/download/install.sh | sh
```

The script picks the right binary (x64/arm64), checks its SHA-256 and runs
`install`. Prefer clicking? Grab `webmcp-host-<os>-<arch>` from the
[releases page](https://github.com/hamb1y/webmcp-controller/releases/latest) — on
Windows double-click the `.exe`; elsewhere `chmod +x` it and run it with `install`.

Already installed? Run the same command again to **update**: Firefox switches
to the new helper by itself within a few seconds, no restart needed.

`install` copies the binary to a per-user folder and registers it with Firefox
(no admin rights needed). The downloaded file can be deleted afterwards.

| OS | Binary goes to | Registered via |
|---|---|---|
| Windows | `%LOCALAPPDATA%\webmcp-controller\` | `HKCU\Software\Mozilla\NativeMessagingHosts\webmcp_controller` |
| macOS | `~/Library/Application Support/webmcp-controller/` | `~/Library/Application Support/Mozilla/NativeMessagingHosts/` |
| Linux | `~/.local/share/webmcp-controller/` | `~/.mozilla/native-messaging-hosts/` (+ snap path) |

Other commands: `status`, `uninstall`, `version`, `help`.
From a source checkout: `npm install && npm run install-host` (uses Node instead
of the bundled binary).

### 2. Load the add-on

Development: `about:debugging#/runtime/this-firefox` → **Load Temporary
Add-on…** → pick `extension/manifest.json`. Permanent: see *Publishing* below.

### 3. Connect your harness

Toolbar icon → **Copy MCP config** (green dot = ready). The settings page
(toolbar icon → **Settings**) also has a Claude Code command and an opencode
snippet. The copied config looks like:

```json
{
  "mcpServers": {
    "firefox": {
      "type": "http",
      "url": "http://127.0.0.1:8901/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}
```

Claude Code: `claude mcp add --transport http firefox http://127.0.0.1:8901/mcp --header "Authorization: Bearer <token>"`.
Codex (`~/.codex/config.toml`): `[mcp_servers.firefox]` with `url = "http://127.0.0.1:8901/mcp"` and `http_headers = { Authorization = "Bearer <token>" }`.
Gemini CLI: `gemini mcp add --scope user --transport http firefox http://127.0.0.1:8901/mcp --header "Authorization: Bearer <token>"`.
opencode: `{"mcp":{"firefox":{"type":"remote","url":"…/mcp","headers":{"Authorization":"Bearer <token>"}}}}`.
Clients that only speak stdio: `npx -y mcp-remote http://127.0.0.1:8901/mcp --header "Authorization: Bearer <token>"`.

Check from a shell:

```bash
curl http://127.0.0.1:8901/health
# {"ok":true,"extensionConnected":true,"version":"0.3.0"}
```

### Harness inside WSL, Firefox on Windows

WSL2 (default NAT networking) can't reach Windows' `127.0.0.1`. Easiest:

1. Add-on toolbar icon → **Settings** → **Connect your AI** → **Your AI**: pick
   yours (Claude Code, Codex, Gemini CLI, opencode, or Other) → **Runs in**: **WSL**.
   That turns on **Let AIs in WSL connect to this Firefox** for you.
2. Windows Firewall asks about `webmcp-host` → tick **Private networks**
   → **Allow access**.
3. **Copy**, then paste it inside WSL. The Claude Code and Gemini commands look up
   the Windows address with `$(ip route show default | awk '{print $3}')`, so they
   survive reboots; config files (Codex, opencode, JSON) hold the current address,
   so copy them again if the connection stops after a Windows restart.

The helper then also listens on the `vEthernet (WSL)` adapter only, not your
LAN. Alternative: WSL mirrored networking (`networkingMode=mirrored` under
`[wsl2]` in `%UserProfile%\.wslconfig`, then `wsl --shutdown`) makes
`127.0.0.1:8901` work from WSL as-is.

## AI cursor

Every tool accepts an optional `thought` argument (≤300 chars, e.g.
`"Opening the pricing page to compare plans"`). The add-on shows it in a
bubble beside an animated cursor on the page being driven:

- `act_*` tools: the cursor glides to the target element first, then clicks
  with a ripple. Without a `thought` it shows a default label
  ("Clicking “Sign in”", "Typing a password", …). Typed passwords are never
  echoed.
- Tab/navigation tools: the thought appears on the tab they affect.
- `cursor_note {note}`: just say something, no action.
- It is drawn in a closed shadow root with `pointer-events: none`, so it never
  blocks real input or leaks into `page_html` / `snapshot_ax`, and it is
  hidden while `screenshot` captures.
- It fades after 8s idle. Toggle: popup → **Show AI cursor**, or Settings →
  **AI cursor**. On by default.

The server's MCP `instructions` tell models to pass `thought`, so most
harnesses do it without prompting.

## Versions and updates

- Add-on and helper share one version (`package.json`, `manifest.json`,
  `shared/src/version.ts`), plus a separate wire `PROTOCOL` number that only
  changes on breaking bridge changes.
- On connect they exchange both. Same protocol, different version → the popup
  suggests updating the helper but keeps working. Different protocol → the
  popup says which side is too old and shows the fix (the install command, or
  "update the add-on").
- Re-running the installer while Firefox is open replaces the helper in
  place; the running helper notices, exits, and the add-on reconnects to the
  new one.
- 0.3.4 renamed everything from firefox-mcp to WebMCP Controller (add-on ID,
  native host name, helper binary, install folder). Older add-ons and helpers
  don't talk to the new ones: install the new add-on and run the install
  command once. The new installer removes the old `firefox_mcp_bridge`
  registration; delete the old `firefox-mcp` folder once Firefox is closed.

## Development

```bash
npm install
npm run build && npm run typecheck
node test/e2e.mjs                 # real background.js (mocked browser APIs) + real helper over native messaging and HTTP
MISSING=1 node test/e2e.mjs       # the "helper not installed" flow
node test/host.mjs                # helper: reconfiguration, Host/Origin checks, size limits, cancellation, protocol mismatch
npx playwright install firefox && node test/content.mjs   # real content script in real Firefox
npm test                          # all of the above
node test/e2e.mjs dist/host/webmcp-host-linux-x64   # same, against a compiled helper
npx web-ext@8 lint -s extension
```

Load `extension/manifest.json` as a temporary add-on and `npm run install-host`
to point Firefox at your checkout.

## Releasing

```bash
node scripts/set-version.mjs 0.3.1   # bumps package.json ×3, manifest.json, shared/src/version.ts
git commit -am "v0.3.1" && git tag v0.3.1 && git push --follow-tags
```

The tag triggers `.github/workflows/release.yml`: it checks the versions
match the tag, runs the tests, builds every helper with bun and publishes the
binaries, `install.sh`/`install.ps1`, `SHA256SUMS` and the add-on zip as a
GitHub release. Locally the same thing is `npm run release` (needs bun and an
authenticated `gh`); `npm run build:host` alone just builds
(`TARGETS="linux-x64"` for one). Installers use `releases/latest/download/…`,
so a new release is picked up without changing the add-on.

Single-file executables via `bun build --compile` (60–85 MB, no runtime
needed). They are **unsigned**: expect SmartScreen on Windows and Gatekeeper
on macOS (`xattr -c <file>` clears the quarantine flag; if macOS still
refuses, `codesign -s - -f <file>` gives it an ad-hoc signature).

## Tools

Tool names below match `registerTool(` in `mcp-server/src/tools/*.ts` exactly
(44 total). All except `extension_status`, `wait_for_tab_event` and
`cursor_note` take the optional `thought` described under *AI cursor*.

### Inventory / manage (`tabs.ts`, 19)

| Tool | What it does |
|---|---|
| `tabs_list` | List all open tabs (id, url, title, active/pinned/audible state). |
| `tabs_query` | Find tabs by URL/title pattern and state flags. |
| `active_tab` | Get the currently active tab (id, url, title, window). |
| `tab_create` | Open a new tab, optionally with a URL. |
| `tab_navigate` | Navigate a tab to a URL (defaults to the active tab). |
| `tab_close` | Close one or more tabs (destructive: needs `confirm:true`). |
| `tab_duplicate` | Duplicate a tab (defaults to the active tab). |
| `tab_move` | Move a tab to a new index, optionally to another window. |
| `tab_pin` | Pin a tab (defaults to the active tab). |
| `tab_unpin` | Unpin a tab (defaults to the active tab). |
| `tab_mute` | Mute or unmute a tab (defaults to the active tab). |
| `window_list` | List open Firefox windows (id, focused, tab count). |
| `window_create` | Open a new window, optionally with a URL. |
| `window_focus` | Bring a window to the front. |
| `window_close` | Close a window and all its tabs (destructive: needs `confirm:true`). |
| `nav_back` | Go back in a tab's history (defaults to the active tab). |
| `nav_forward` | Go forward in a tab's history (defaults to the active tab). |
| `nav_reload` | Reload a tab (defaults to the active tab). |
| `focus_tab` | Activate (focus) a tab by id. |

### Understand (`understand.ts`, 5)

| Tool | What it does |
|---|---|
| `snapshot_ax` | Accessibility snapshot of the page for grounding `act_*` refs (`[ref=N]` nodes). |
| `page_text` | Extract the visible text of the page. |
| `page_html` | Extract page HTML, optionally limited to a CSS selector subtree. |
| `screenshot` | Capture a screenshot of the tab's visible area (returned as an image). |
| `page_info` | Basic page metadata: URL, title, load state. |

### Act (`act.ts`, 9)

| Tool | What it does |
|---|---|
| `act_click` | Click an element by snapshot ref or CSS selector. Fails on disabled elements. |
| `act_type` | Type into a text field (optional submit via its form or composer's own button, else Enter). |
| `act_fill_form` | Fill several fields in one call; every field is checked first, so a bad one changes nothing. |
| `act_select` | Select option(s) in a `<select>`: exact value first, then label. |
| `act_hover` | Send hover events (JS menus and tooltips; CSS `:hover` can't be triggered). |
| `act_scroll` | Scroll the page or an element (direction/pixels, or top/bottom). |
| `act_key` | Press a key, optionally with modifiers (e.g. `Enter`, `a` + Ctrl). |
| `act_wait` | Wait for text or a selector to appear (poll, `timeoutMs` default 10000 / max 60000). |
| `act_find` | Find text on the page (returns match locations/count). |

### Browser data (`browser.ts`, 8)

| Tool | What it does |
|---|---|
| `bookmarks_search` | Search bookmarks by title/URL query. |
| `bookmarks_create` | Create a bookmark. |
| `bookmarks_remove` | Delete a bookmark by id (destructive: needs `confirm:true`). |
| `history_search` | Search browsing history. |
| `downloads_list` | List recent downloads (filename, state, progress). |
| `cookies_for_tab` | Read cookies visible to a tab's page, from its container/private store, including ones partitioned under that site (read-only). |
| `sessions_recently_closed` | List recently closed tabs/windows available for restore. |
| `sessions_restore` | Restore a recently closed tab/window by session id. |

### Meta (`tools/index.ts`, 3)

| Tool | What it does |
|---|---|
| `extension_status` | Check whether the bridge extension is connected, plus profile details. |
| `wait_for_tab_event` | Wait for an extension-pushed event (`tab.updated/removed/activated`, `download.done`); pass a result's `seq` as `afterSeq` to catch events between calls. |
| `cursor_note` | Show a note in the AI cursor bubble without doing anything. |

## Troubleshooting

- **Popup says "Helper app not installed".** The install step didn't run or
  wrote to a different place than this Firefox reads. Run the binary with
  `status` to see where it registered. Snap Firefox on Ubuntu reads
  `~/snap/firefox/common/.mozilla/native-messaging-hosts` (the installer
  writes there too); **Flatpak** Firefox cannot run native hosts without
  extra sandbox overrides — use the deb/tarball build (the installer warns when
it sees one). Then press **Retry**.
- **Popup says the helper is too old / the add-on is too old.** Run the
  install command again, or update the add-on, as the popup says.
- **Worked before 0.3.4, "not installed" after.** Everything was renamed; run the
  install command once more.
- **"Port 8901 is already in use".** Another app, or this add-on in a second
  Firefox profile, holds it. Settings → Port → pick another → Save, then
  re-copy the MCP config.
- **401 unauthorized.** The token changed (Settings → New) — re-copy the config.
- **Temp add-on gone after restart.** Temporary add-ons unload when Firefox
  closes — reload via `about:debugging`, or install a signed `.xpi`. A
  temporary add-on keeps its token only while its ID stays the same (it does —
  the ID is fixed in `manifest.json`).
- **`RESTRICTED_PAGE`.** Content-script ops (`page.snapshot/text/html`, all
  `act.*`) are blocked on `about:*`, `chrome:*`, `resource:*`,
  `moz-extension:*`, `view-source:*`, `jar:`, `data:`/`blob:` pages and on
  `addons.mozilla.org` (Firefox forbids scripting there).
- **`act_wait` vs bridge timeout.** `act_wait` has its own `timeoutMs`
  (default 10000, max 60000); the helper extends its 30s watchdog to
  `timeoutMs + 15s` for that call.
- **`REF_STALE` / `REF_NOT_FOUND`.** Refs from `snapshot_ax` belong to one page
  load and are never reused. Pass the snapshot's `generation` with refs; after
  the page changes or navigates, take a new snapshot.
- **`PAYLOAD_TOO_LARGE`.** Firefox caps native messages at 1 MB; send less text
  per call.
- **Helper logs.** The helper writes to stderr, which Firefox shows in the
  Browser Console (Ctrl+Shift+J) prefixed `[webmcp]`.

## Limits & safety

- **Local only by default.** The helper binds `127.0.0.1`; every MCP request
  needs the 256-bit bearer token, compared in constant time.
- **Browser checks.** Requests with a foreign `Host` or `Origin` header get 403
  (DNS rebinding / cross-site protection); the token is checked before the
  body is parsed.
- **Cancellation.** Closing an HTTP request or sending
  `notifications/cancelled` stops the command in Firefox too.
- **Confirm guard.** `tab_close`, `window_close`, `bookmarks_remove` refuse
  without explicit `confirm:true`.
- **No password-store access.** There is no tool for saved logins / the
  Firefox password manager. `cookies_for_tab` is read-only.
- **Screenshots are viewport-only.** `screenshot` captures the tab's visible
  area (`page.shot`), not the full scrollable page or OS chrome.

## Publishing the extension (AMO)

Two lanes, same upload flow at
[addons.mozilla.org/developers](https://addons.mozilla.org/developers/):

- **Unlisted (fast, self-distribution).** Automated checks only, signed in
  minutes. Install the resulting `.xpi` via Add-ons Manager → gear icon →
  Install Add-on From File. No public listing, no manual review. This is the
  right lane for personal use. AMO only signs the add-on — the helper
  binaries are shipped separately (e.g. GitHub releases).
- **Listed (public store page).** Full human review — expect days to weeks,
  and scrutiny proportional to permissions. This extension requests `tabs`,
  `bookmarks`, `history`, `cookies`, and `<all_urls>` content scripting, i.e.
  it can read and drive every page. A public listing will need a convincing
  justification: why full control is the product (AI browsing agent), why the
  data stays local (native helper on the same machine, token-gated), plus a
  privacy policy. Plan on review iterations.

Steps (both lanes):

1. `bash scripts/pack-extension.sh` → `webmcp-controller.zip`
   (manifest at zip root, `web-ext lint` clean).
2. Either upload the zip at
   [addons.mozilla.org/developers](https://addons.mozilla.org/developers/)
   → Submit a New Add-on, or sign from the CLI:
   `AMO_JWT_ISSUER=… AMO_JWT_SECRET=… bash scripts/amo-sign.sh`
   (get API keys at
   [AMO API keys](https://addons.mozilla.org/developers/addon/api/key/);
   default channel is `unlisted`, set `AMO_CHANNEL=listed` for a public
   listing).
3. Fill in the submission: pick **On your own** (unlisted) vs **On this
   site** (listed), declare the
   [`data_collection_permissions`](https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/)
   already in `manifest.json` (`browsingActivity`, `websiteContent`,
   `websiteActivity`, `bookmarksInfo` — required because page content,
   URLs, interactions, and bookmarks flow to the local helper),
   and for listed also add icons (shipped in `extension/icons/`),
   description, and support info.