Drive Firefox over MCP — screenshots, clicking, typing, navigation, and page interaction for any MCP client
  • JavaScript 54.3%
  • Python 39.3%
  • CSS 2.8%
  • Shell 2.7%
  • HTML 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
tant 93a26eece6 type: nhận "submit" như bí danh của press_enter và gửi Enter giàu sự kiện hơn
Tham số submit (tên nhiều người quen dùng) trước đây bị bỏ qua âm thầm, khiến
Enter "không làm gì" trông như lỗi. Nay submit là bí danh của press_enter.

Sự kiện Enter thêm cancelable/which/composed/view và keypress để các combobox
kén sự kiện (vd ô URL Inspection của Search Console) thực sự kích hoạt handler.
2026-10-13 19:35:37 +03:00
.claude/agents The permission that was not in any documentation 2026-08-15 11:32:57 +04:00
docs Tests that reach the code they claimed to cover, and four bugs they found 2026-09-23 16:47:23 +04:00
extension type: nhận "submit" như bí danh của press_enter và gửi Enter giàu sự kiện hơn 2026-10-13 19:35:37 +03:00
mcp-server type: nhận "submit" như bí danh của press_enter và gửi Enter giàu sự kiện hơn 2026-10-13 19:35:37 +03:00
native-host Tests that reach the code they claimed to cover, and four bugs they found 2026-09-23 16:47:23 +04:00
scripts An add-on id we do not own, and a Python nobody guaranteed 2026-09-09 14:10:07 +04:00
tests Tests that reach the code they claimed to cover, and four bugs they found 2026-09-23 16:47:23 +04:00
.gitignore Make the add-on signable, so it survives a Firefox restart 2026-08-12 17:48:35 +04:00
.mcp.json Multi-browser over FastMCP with a supervised service 2026-07-24 15:56:14 +04:00
LICENSE Foxhand — Firefox browser automation over MCP 2026-07-23 19:32:47 +04:00
README.md Tests that reach the code they claimed to cover, and four bugs they found 2026-09-23 16:47:23 +04:00

Foxhand

License: MIT Firefox Add-on

Drive Firefox over the Model Context Protocol. Foxhand exposes a browser as a set of MCP tools — screenshots, clicks, typing, navigation, page inspection, console/network capture, and cache-busting refreshes — usable by any MCP client (Claude Code, Cline, Cursor, and others).

Because it drives your real Firefox profile, it reuses your existing logins and clears bot walls (Cloudflare, Google sign-in) that headless browsers trip.

Author: Tom Q · Homepage: tetra.vn · License: MIT · © 2026 Tom Q, Tetra Software


Features

  • Screenshots — visible area, one element cropped to its box, the whole scrolling page, or every open tab
  • Interaction — click by selector / XPath / text / coordinates, type into inputs and rich editors, scroll, hover, select
  • Navigation — open URLs, manage tabs, and force-refresh past the cache after a dev-server restart
  • Inspection — page info, element queries, and arbitrary JavaScript (isolated userScripts world, CSP-proof)
  • Observation — wait for elements, DOM changes, or network idle; watch a node for mutations
  • Console & network capture — console.* with source locations, the errors nobody handled (uncaught, rejections, failed loads, CSP blocks), and traffic at three depths: what loaded, the bodies, and the headers
  • Inspector data — computed styles, box model, matching CSS rules, web storage, and cookies including HttpOnly (read and write)
  • Multiple browsers at once — one server drives several Firefox instances, each addressable by a stable id
  • Any MCP client — a standard Streamable-HTTP MCP server; no vendor lock-in

Architecture

One FastMCP server on port 8765 is the whole backend. It is at once the MCP server for clients (Streamable HTTP at /mcp) and the bridge for the browsers (custom routes /browser/poll, /browser/response, /mcp/call, /health):

Component Role
MCP server (server.py, FastMCP) Exposes tools over HTTP and routes each command to the right browser
Native host (foxhand_host.py) A thin per-Firefox client — registers its browser id, polls for its commands, forwards results
Service control (service.py) Registers the server with launchd/systemd so the OS owns its lifecycle
Firefox extension Executes commands in the page (background + content script)

Foxhand: MCP clients connect to one FastMCP server over Streamable HTTP /mcp (46 tools), which also serves /mcp/call and /health; the server routes per-browser commands to each Firefox's native host over a long poll on /browser/poll and /browser/response, with replies over 512 KB posted back directly; launchd/systemd owns the server lifecycle

Drive several Firefox instances at once. Each Firefox spawns its own native host and mints a stable browser id per profile; commands are queued and routed per id, so two browsers never cross-talk. A tool call resolves a target — the explicit browser argument, or the default (oldest connected) — enqueues a command, and the matching browser's native host polls it and answers.

Request flow: MCP client → POST/stream to /mcp → the tool enqueues a command for the target browser → that browser's native host is already parked on GET /browser/poll?browser=<id> (long poll) and is woken immediately, handing the command to the extension over native messaging → the extension acts on the page → the result returns via the native host's POST /browser/response to the awaiting tool call. A response is only accepted from the browser the command was queued for.

Large replies skip the port. Native messaging tears down past about a megabyte, so a reply bigger than 512 KB — a full-page screenshot, a capture buffer read with a raised body_max — is POSTed by the extension straight to /browser/response, the same endpoint the native host would have used. The host is only a relay for that leg. Small messages keep the port, where it is fastest.

One owner for the server. The server is shared infrastructure, so no browser manages it. A launchd agent (macOS) / systemd --user unit (Linux) runs it with start-at-login and crash-restart. Native hosts are pure clients; opening or closing a Firefox never touches the server process.


The popup

The toolbar popup is a read-only status console — it tells you whether the stack is live and which browser this profile is. It does not control the server: the server is a self-managed OS service, and the extension only connects to it (manage the service with service.py — see Usage).

Connected — this browser named ‘left’, 2 browsers connected, server/native host/script access all up    Disconnected — server offline, script access not granted, browser not yet named

  • Status console — an indicator per component: server (reachable + version), native host (pid), browser (this profile's id + how many browsers are connected), and script access.
  • Enable — grants the userScripts permission that browser_execute_script needs (Firefox requires a click to grant it).
  • Refresh — re-probe the stack.
  • Screenshot — capture the current tab and download it as a PNG. Independent of the server, so it also tells you whether the extension half is healthy on its own.
  • Diagnostics — server, browser id, native host, permission, tab, and user-agent details on the clipboard for bug reports.
  • copy (beside the browser id) — the raw id on the clipboard.

Installation

Prerequisites

  • uv — builds the venv and installs FastMCP. It fetches a Python 3.10+ itself if the system has none, so no python3-venv/ensurepip package is needed. Not installed yet: curl -LsSf https://astral.sh/uv/install.sh | sh (or brew install uv), then open a new shell so uv is on PATH.
  • Firefox 153+ — userScripts.execute, which the page tap and browser_execute_script both depend on, is not available before it
  • Node.js — only to sign the extension (scripts/sign.sh runs web-ext through npx). Not needed if you load it as a temporary add-on.
  • macOS (launchd) or Linux (systemd --user)
  • A normally-installed Firefox. Flatpak and Snap builds keep their native messaging manifests inside the sandbox (e.g. ~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts), so the installer writes to a path they cannot see — copy foxhand.json there afterwards, and expect Snap's confinement to block the host outright.

On a new machine

Six steps, start to finish.

# 1. Get the source and install the backend.
git clone https://git.tetra.vn/tantran/foxhand-extension.git
cd foxhand-extension
./scripts/install.sh          # ~/.foxhand + uv venv + native-messaging manifest + OS service

# 2. Build a signed .xpi. The repo ships none — dist/ is generated and gitignored.
export AMO_JWT_ISSUER='user:…' AMO_JWT_SECRET='…'   # addons.mozilla.org/developers/addon/api/key/
./scripts/sign.sh
  1. Install it: about:addons → gear → Install Add-on From File → the .xpi in dist/.
  2. Grant all three permissions — see the table below. Two of them are not in the install prompt.
  3. Point your MCP client at http://127.0.0.1:8765/mcp — config snippet below.
  4. Verify: curl -s localhost:8765/health should report browsers_connected: 1.

No AMO account? Skip steps 2–3 and load it as a temporary add-on: about:debugging → This Firefox → Load Temporary Add-on… → extension/manifest.json. It works identically but is gone after every Firefox restart, along with the script-access grant.

Details and traps

The installer copies the components to ~/.foxhand/, has uv build a venv with FastMCP, registers the Firefox native messaging manifest, and registers + starts the server as a launchd/systemd service (start-at-login, crash-restart).

Signing is "unlisted": Mozilla signs it for you, it is not published or reviewed. Release Firefox refuses unsigned add-ons, so this is the only route to an install that survives a restart. Firefox will not treat the signed .xpi as an update to a temporary copy of the same add-on — remove the temporary one from about:debugging first, or the install appears to do nothing.

Forking this? Change browser_specific_settings.gecko.id in extension/manifest.json to your own, and the matching allowed_extensions in scripts/install.sh. AMO gives an add-on id to whoever signs it first, so signing foxhand@tantran.dev under a different account is refused outright — web-ext sign reports it as a flat Forbidden, after the upload, with nothing about ownership. A test keeps those two files in step; nothing keeps you from picking your own id.

A version number is spent forever once signed, for the add-on id, on either channel — rolling the manifest back does not free it. The numbers consumed under the current id and the retired ones (foxhand@tetra.vn, foxhand-mcp@tetra.vn) are listed in scripts/signed-versions.txt, and sign.sh refuses a repeat rather than letting you find out after a slow upload. That file is the record, not dist/, which is generated and gitignored. A new id starts with an empty namespace — that is the only way a spent number becomes available again.

Three separate grants — Firefox MV3 splits them, and each one fails differently when missing:

Grant Where Missing it looks like
The add-on's listed permissions The prompt shown on install Individual tools report the permission by name
Host access to all sites about:addons → Foxhand → Permissions → Access your data for all websites Missing host permission for the tab
Script access (userScripts) The Foxhand popup → Enable userScripts permission not granted

Host access is the one that surprises people: it is not in the install prompt, and a freshly-installed add-on id starts without it, so the browser connects and lists tabs while everything that touches a page fails.

Point your MCP client at the HTTP endpoint. The server speaks Streamable HTTP, so clients connect by URL — no per-client subprocess. Claude Code example (~/.claude/settings.json; other clients use the same shape):

{
  "mcpServers": {
    "foxhand": { "type": "http", "url": "http://127.0.0.1:8765/mcp" }
  }
}

Check it worked, in order of what each proves:

curl -s localhost:8765/health      # {"status":"ok", "browsers_connected":1, ...}
uv run tests/smoke_browser.py      # end to end, through the real Firefox

browsers_connected: 0 means the extension is installed but its native host is not reaching the server — reload the extension and check ~/.foxhand/logs/native_host.log.

To remove everything (stops + unregisters the service): ./scripts/uninstall.sh.


Usage

The server runs as an OS service — always up after install, restarted on crash, started again at login. You don't launch it by hand. Manage it with:

~/.foxhand/venv/bin/python ~/.foxhand/native-host/service.py status   # {installed,running,enabled}
~/.foxhand/venv/bin/python ~/.foxhand/native-host/service.py stop|start|restart

The popup does not manage the server — it only shows whether it's reachable.


MCP tools

46 tools, grouped by purpose.

Targeting elements

Don't guess CSS selectors — take an accessibility snapshot first, the way Playwright does. browser_snapshot returns the interactive elements by role and accessible name, each with a stable ref:

[e5]  textbox "Token name" (value="foxhand-extension")
[e9]  button "Add permissions" (expanded=false)
[e14] checkbox "Administration" (unchecked)
[e21] button "Generate token"

Then act on a ref (most reliable), or on a role + name pair:

// browser_type — fill the field found in the snapshot
{ "ref": "e5", "text": "foxhand-extension (local dev)" }

// browser_click — by ref, or by role + accessible name
{ "ref": "e14" }
{ "role": "checkbox", "name": "Administration" }

refs are valid until the page navigates; re-snapshot after a reload. Selector, XPath, text, and coordinate targeting still work when you need them. Selector, text, and the snapshot pierce open Shadow DOM (Web Components); shadow elements are tagged (shadow).

Dialogs & navigation. The snapshot header reports the current url and any open dialog — and when a true modal (aria-modal, <dialog open>) is up, it lists only the modal's controls, since the rest of the page is inert:

url: https://example.com/settings
modal: "Confirm deletion"  (background inert; listing modal only)
[e1] button "Cancel"
[e2] button "Delete"

browser_click also returns { navigated, url, dialogOpened }, so you can tell a click that opened an in-page dialog from one that navigated — without a follow-up snapshot.

Group context. Each element carries the nearest preceding label/heading after an em dash, so repeated controls stay unambiguous — you know which row a generic button belongs to without inspecting the DOM:

button "Access: Read and write" — Administration
button "Access: Read-only" — Metadata

Navigation & tabs

Tool Description
browser_navigate Navigate to a URL, optionally in a new tab
browser_refresh Reload the current page or a specific tab
browser_hard_refresh Force reload bypassing all caches (Ctrl+Shift+R)
browser_reload_all Reload every open tab, bypassing the cache by default (optionally filtered by url_pattern)
browser_get_tabs List open tabs with state (URL, title, active, audio, …)
browser_get_tab_info Detailed info for one tab, including page info
browser_find_tabs Find tabs by URL pattern, title, or other criteria
browser_create_tab Open a new tab
browser_close_tab Close a tab by ID
browser_focus_tab Activate a tab by ID

Screenshots

Tool Description
browser_screenshot The visible area, one element, or the whole page — see below
browser_screenshot_all_tabs Capture every open tab (optionally filtered by URL)

browser_screenshot has three modes, all on the one tool:

  • Visible area (default) — returns viewport: CSS size + device pixel ratio, so a point read off the image converts to click coordinates.
  • One element — pass ref / selector / xpath / text / role+name. The element is scrolled into view and the image cropped to it, padding in CSS pixels for context. This moves the page's scroll position, because a thing has to be on screen to be photographed. Coordinates are corrected by each ancestor frame's offset; if a cross-origin frame blocks that walk the reply says so rather than cropping the wrong region confidently.
  • Whole page — full_page: true. Two paths, and the reply names the one taken in method:
method How Trade
renderDocument Firefox renders the document in one pass (tabs.captureTab with a page-coordinate rect) No seams, nothing repeats, scroll position untouched. Never scrolls, so content that loads on scroll is still a placeholder
stitched Scrolls a viewport at a time and stacks the captures Triggers lazy loading on the way. Content that shifts between two captures leaves a seam artifact, which no amount of care removes

The one-pass path is tried first and its result is measured: an image merely viewport-height means the rect was clipped, and it falls back rather than returning the top third of a page as if it were the whole thing. force_stitch skips it; prime_lazy walks the page once before capturing so both paths get the loaded version (opt-in — on an infinite feed, scrolling is a request for more). Stitching hides fixed/sticky elements after the first strip so they do not march down the seams; keep_sticky turns that off for a sticky sidebar that is content. truncated appears whenever the image covers less than the page — a very long page is capped, and a page that refuses to scroll to its end stops early.

Interaction

Tool Description
browser_click Click by selector, XPath, text, or coordinates
browser_type Type into an input or editable element
browser_scroll Scroll the page or an element
browser_hover Hover to trigger hover effects
browser_drag Drag an element onto another with a human-paced, curved motion (jQuery-UI & HTML5 DnD)
browser_get_value Read an input's value
browser_set_value Set an input's value without typing simulation
browser_select_option Choose an option in a <select>
browser_highlight Outline an element for visual debugging
browser_click_and_wait Click and wait for dynamic content to load

Inspection

Tool Description
browser_snapshot Accessibility tree of interactive elements by role + name, each with a stable ref — target it from browser_click/browser_type instead of guessing selectors
browser_get_page_info URL, title, forms, headings, interactive elements
browser_get_elements Find elements matching a CSS selector
browser_execute_script Run JavaScript in an isolated userScripts world (CSP-proof), or in the page's own world with world: "MAIN"
browser_handle_dialog Arm native alert/confirm/prompt so they don't block — auto-accept/dismiss + prompt text
browser_get_styles Computed styles, the full box model, and the author CSS rules that match one element
browser_scroll_and_capture Scroll the page, collecting element info at each viewport

browser_get_styles is the Inspector's three panes for a single element: the computed values (a curated ~60 by default, all: true for every one, or name your own in properties), the box model down to each margin/border/padding edge plus scroll size and whether it is in the viewport, and the author rules that actually match — @media conditions evaluated, cross-origin stylesheets listed under unreadableStylesheets rather than silently skipped. Target it exactly like browser_click (ref, role+name, selector, XPath, text). It cannot list event listeners added with addEventListener: getEventListeners() is a devtools console built-in, not a web API, so no extension can reach them.

world: "MAIN" — reaching the page's own JavaScript. By default a script runs in the isolated userScripts world: CSP-proof, invisible to the page, but it cannot see the page's globals. An app that keeps its state in closures and draws to a canvas — most games, many SPAs — has nothing in the DOM to read, and the isolated world can't reach the object that does hold it. world: "MAIN" runs your script as the page itself, so it can read and call that object directly. It is the same back door as browser_get_captured_traffic, from the other side: the tap reads what the app sends, MAIN reads what the app is.

The trade is real — code in MAIN is subject to the page's own environment and is visible to its scripts, so keep it to reads and the calls you mean to make. Combine with frame_id when the app lives in an iframe.

timeout (default 60000 ms) covers the other half: a page whose main thread is busy — a game rendering every frame — can take seconds just to run the injection, and the default is generous for that reason. Lower it when you want a stuck page to fail fast.

Waiting & synchronization

Tool Description
browser_wait_for_element Wait for an element to appear
browser_wait_for_change Wait for DOM changes (useful after clicks)
browser_wait_for_network_idle Wait for fetch/XHR requests to settle

Observation

Tool Description
browser_observe_element Start watching an element for changes
browser_stop_observing Stop watching and return accumulated changes

Console & network logging

Tool Description
browser_start_logging Begin capturing console logs and network requests
browser_stop_logging Stop capturing (captured logs are kept)
browser_get_console_logs Read captured console.log/error/warn/info/debug
browser_get_network_logs The index of everything the page loaded — page traffic and subresources — no bodies
browser_get_captured_traffic The page's own fetch/XHR/WebSocket/Worker traffic with bodies, tapped in the MAIN world from page load
browser_get_requests The browser-level request log: request and response headers, real status, redirects, cache hits, resource type
browser_get_cookies Cookies from the browser's store, HttpOnly included
browser_set_cookie Write or delete a cookie, HttpOnly ones too
browser_get_storage localStorage, sessionStorage, and a listing of IndexedDB and Cache Storage
browser_set_storage Write, delete, or clear a key in local/session storage
browser_clear_logs Clear captured console and/or network logs

Every tool takes an optional frame_id (default 0, the top document) and returns a frames list — {frameId, url, parentFrameId} per frame — whenever the tab has more than one. The list comes from webNavigation, so it includes frames that never ran a content script.

You rarely need to pass frame_id. When you do not name a frame and the top document cannot find the element, the command is retried in the tab's other frames and the first one that answers wins — the reply names it in frameUsed. So an app living inside an iframe just works. Name a frame explicitly when you mean a specific one, or when two frames both contain a matching element. Frame ids are per-load and change on every navigation, so read them from the response rather than caching them.

Three sources, three questions. A browser's network panel is not one feed, and neither is this — each tool answers something the others structurally cannot:

Ask Tool Sees Blind to
What did the page load? browser_get_network_logs Everything: the tap's fetch/XHR/WS/Worker plus every subresource (document, CSS, JS, images, fonts, beacons) from PerformanceResourceTiming Bodies, headers
What did it send and receive? browser_get_captured_traffic Request and response bodies, WS and Worker message frames Anything no JavaScript requested
What actually went over the wire? browser_get_requests Request and response headers, the real status, redirect chains, cache hits, server IP, resource type — including the document itself and requests the page never issued. With capture_bodies it also carries their bodies —

The reason for the split is mechanical. The tap wraps fetch/XHR/ WebSocket/Worker in the page, so it sees bodies but only for calls that went through those globals — an <img> or a stylesheet never touches them. Resource timing is the page's own load record, free and permission-less, but carries no headers or bodies. webRequest sits in the browser and sees every request including the navigation. Reach for the one that answers your question; browser_get_network_logs is the cheap first look.

Reading a body nothing fetched. Pass capture_bodies: true to browser_start_logging and the extension reads each response as it streams past (Firefox's filterResponseData), so browser_get_requests returns the bytes of the document, its scripts and its stylesheets — the one thing no other tool can reach, because those never pass through a JavaScript global anyone can wrap. It is off by default: this sits in the real response stream, costs memory and latency on every response, and is limited to text-ish resource types. body_max caps what is kept per response; the page always receives its bytes in full.

It needs the webRequestFilterResponse permission — MV3 split stream filtering out of webRequestBlocking into its own, and without it filterResponseData throws on every request while the listener, the headers and the flag all keep working. The only symptom is bodies that are never there, so browser_get_requests returns bodyCaptureStats whenever capture is on: requests seen, skippedType, filters created, createErrors with the lastError verbatim, data chunks, stopped, stored. Each number rules out one link, which is faster than guessing which of five is broken.

Two properties of the resource-timing half would otherwise read as missing data: a request that failed leaves no entry at all — a 404 image is simply absent, and browser_get_requests is where failures show up — and a load started from Foxhand's own isolated world is attributed to the extension, not the page, so it never reaches the page's timeline. Use world: "MAIN" when you inject something whose load you intend to observe.

Each buffer holds the last 500 entries.

Bodies are cut to 4000 characters as they are captured, so raising the cap afterwards cannot recover them — pass body_max to browser_start_logging before the traffic happens. The cost is paid in the page's own heap: body_max times the 500 buffered entries.

The tap is injected on demand, not always on. It is not a content script: it exists only in tabs Foxhand is driving, under a random per-session name, and lives in the page world just long enough to be useful. That means:

  • It needs the userScripts permission — the same popup Enable button browser_execute_script needs. Without it the logging tools return an error.
  • browser_start_logging also arms it at document_start for subsequent navigations, so after a reload the buffer covers the page from its first request. browser_stop_logging disarms it (tabs where it was already injected keep it until they navigate).
  • Calling a read tool without browser_start_logging injects the tap right then, so it only sees traffic from that moment on.
  • An injected tap records network traffic immediately — that is what makes a document_start tap worth having. browser_stop_logging stops that recording as well as the console capture; the buffer it already filled is kept.

The isolated world reads the buffer directly through Firefox's wrappedJSObject rather than postMessage, so captured bodies are never broadcast to the page's own scripts.

Errors nobody handled are captured too. Wrapping console.* misses exactly the failures that matter most, because each is reported by the browser rather than by the code that failed. browser_get_console_logs returns them as level: "error" with a kind:

kind What it was Why console.* never saw it
uncaught An exception nobody caught It goes to the console through the browser, with stack and source
rejection A promise rejected with no .catch Same, and it carries the reason's stack
resource An <img>/<script>/<link> that failed to load Fires on the element, not the console
csp Content-Security-Policy refused something The script never ran, so it could not complain. Carries directive and disposition — a report disposition blocked nothing

A clean log used to mean "nothing was recorded", not "nothing went wrong".

Every console entry also carries source — the file:line:col it was called from, which is what devtools prints beside each line. It costs one Error object per call; console_trace: false on browser_start_logging turns it off for a page that logs inside a render loop.

browser_get_requests records nothing until you ask. Its entries carry Cookie and Authorization headers, so it starts with browser_start_logging on the tab you name and stops with browser_stop_logging; nothing is written to disk, and closing the tab drops the buffer. Pass include_headers: false for a compact index that leaves credentials out of the reply. It needs the webRequest permission, and browser_get_cookies the cookies permission — both declared in the manifest, so Firefox asks once when the add-on is installed or updated. Decline them and every other tool still works; these two report why they cannot.

Driving multiple browsers

Tool Description
browser_list_sessions List the connected Firefox instances — each with a stable browser id, label, and which one is the default

One server can drive several Firefox instances at once (e.g. two chat sessions talking to each other). Each extension mints a stable id per profile and reports it to the server, so commands never cross-talk. Call browser_list_sessions to discover the ids, then pass browser on any tool to target one:

// send to a specific browser
{ "browser": "b3f1c2a8-…", "text": "hello from A", "selector": "#composer" }

Omit browser and the command goes to the default — the oldest still-connected instance. With a single Firefox open you never need it; the lone instance is the default.

Name your browsers. In each Firefox's popup, type a name in the Browser row (e.g. left, right). Then address it by that label instead of the raw id:

{ "browser": "left", "text": "hello from the left window", "selector": "#composer" }

browser accepts an id or a label; a label must be unique among connected browsers (otherwise you get an error listing the candidates). The popup's copy button puts the raw id on the clipboard when you need it.

Key parameters

// browser_click — any one targeting method
{ "selector": "button.login", "xpath": "...", "text": "Sign in",
  "x": 100, "y": 200, "double_click": false, "right_click": false }

// browser_type
{ "text": "hello", "selector": "#q", "placeholder": "Search",
  "clear": true, "press_enter": true, "delay": 50 }

// browser_scroll
{ "direction": "down", "amount": 300, "to_element": ".footer" }

// browser_execute_script — the script is an *expression*, so wrap statements
// in an IIFE. Here: the page's own world, inside the app's iframe.
{ "script": "(() => Object.keys(window.app.state))()",
  "world": "MAIN", "frame_id": 4294967300, "timeout": 60000 }
// frame_id comes from the `frames` list in any response — never hardcode it

HTTP API

The server listens on http://127.0.0.1:8765.

Endpoint Method Auth Description
/mcp POST/stream — The MCP endpoint (Streamable HTTP); clients connect here
/health GET — Server health + connected-browser count (open)
/browser/poll GET ✔ Native host long polls for its browser's commands (?browser=<id>); held open up to 25s, answers the instant one is queued
/browser/response POST ✔ Native host posts a command result
/mcp/call POST ✔ JSON wrapper over the same tools for curl/scripts that don't speak MCP (used by tests/smoke_browser.py)

Authentication. The browser bridge (/browser/*) and the JSON wrapper (/mcp/call) require an X-API-Key header. On first run the server generates a token and stores it (mode 0600) at ~/.foxhand/api_token; the native host reads it automatically. /mcp and /health are open but bound to localhost.

curl http://localhost:8765/health   # {"status":"ok", "browsers_connected": 2, ...}

Configuration

All settings are environment variables (read by the server and native host):

Variable Default Purpose
FOXHAND_HOST 127.0.0.1 HTTP bind address
FOXHAND_HTTP_PORT 8765 HTTP port
FOXHAND_SCREENSHOTS_DIR /tmp/foxhand/screenshots Where screenshots are saved
FOXHAND_DEBUG (off) Verbose logging, including per-request access lines
FOXHAND_MCP_HOST 127.0.0.1 Server address the native host polls
FOXHAND_MCP_PORT 8765 Server port the native host polls
FOXHAND_HOME ~/.foxhand Install directory the service control reads

File locations

Path Description
~/.foxhand/ Installed components
~/.foxhand/venv/ uv-built Python venv running the FastMCP server
~/.foxhand/api_token Generated HTTP API token
~/.foxhand/logs/ Server, native-host, and service logs (rotated)
/tmp/foxhand/screenshots/ Saved screenshots
~/Library/LaunchAgents/vn.tetra.foxhand.plist launchd service (macOS)
~/.config/systemd/user/foxhand.service systemd service (Linux)
Firefox NativeMessagingHosts/ foxhand.json manifest

Troubleshooting

Open the popup and read the status console first; Diagnostics puts the same information on your clipboard.

  • Popup shows Disconnected — the server isn't reachable. Check the service: ~/.foxhand/venv/bin/python ~/.foxhand/native-host/service.py status (and start if it was disabled). If the native host row is also disconnected, reload the extension — a temporary add-on must be re-loaded from about:debugging after each Firefox restart, which is what ./scripts/sign.sh exists to avoid.
  • browser_execute_script returns "userScripts permission not granted" — open the popup and click Enable.
  • Anything touching a page returns "Missing host permission for the tab" — about:addons → Foxhand → Permissions → Access your data for all websites. Tab listing and cookies keep working without it, so the stack looks healthy while every page action fails.
  • Commands time out — confirm the server responds: curl http://localhost:8765/health. Check ~/.foxhand/logs/mcp_server.log and native_host.log.
  • browser_get_requests returns entries with no body — read bodyCaptureStats in the same reply; it names the broken link. A createErrors count with lastError mentioning a permission means Firefox is holding one back: about:addons → Foxhand → accept it.
  • browser_get_requests returns recording: false — it records only between browser_start_logging and browser_stop_logging, per tab, on purpose (its entries hold credentials). Start logging on that tab, then reload the page to capture the navigation itself.
  • A tool reports a permission it does not have — the add-on has gained permissions over several versions (webRequest and cookies for headers and the cookie store; webRequestBlocking, webRequestFilterResponse and webNavigation for response bodies and frame discovery), and Firefox holds an update until you accept them: about:addons → Foxhand → accept the pending permission request. Declining only disables the tools that need it.
  • Unauthorized from the HTTP API — send the X-API-Key header (see HTTP API).
  • Screenshots not saving — check write permissions on /tmp/foxhand/screenshots/.

Logs live in ~/.foxhand/logs/ and rotate automatically. Routine poll/health traffic is logged at DEBUG, so the INFO log stays readable; set FOXHAND_DEBUG=1 to see everything.


Development

Taking this over? docs/DESIGN.md is the handover spec: architecture, the three JavaScript worlds, the permission model, every limit and timeout, and the traps that are not visible from the code.

# Stop the service first so it doesn't hold the port, then run in the foreground:
~/.foxhand/venv/bin/python ~/.foxhand/native-host/service.py stop
uv run --with 'fastmcp>=3.4,<4' mcp-server/server.py   # the repo copy, console log

Four test entry points, cheapest first:

Command Needs Covers
node tests/test_tap.js nothing The MAIN-world tap's logic: the body cap baked in at injection, raising it live, and that stop_logging really stops the buffer while wait_for_network_idle stays sighted
node tests/test_media.js nothing Media link detection for the popup, and the ffmpeg command it copies
uv run tests/test_foxhand.py nothing Service control, per-browser routing, the HTTP bridge (token gate, long poll, reply ownership), command deadlines, screenshot saving, the token file, the native host's message framing, and that every tool's action and arguments reach a handler in the extension
uv run tests/smoke_browser.py server + a connected Firefox with script access granted + internet End to end, through the extension; opens and closes its own tab

Load the extension as a temporary add-on and filter the Firefox console (F12) for Foxhand. The diagram source is docs/architecture.html (plain HTML/CSS); re-render with Firefox headless:

firefox --headless --profile "$(mktemp -d)" --window-size=820,745 \
  --screenshot "$PWD/docs/architecture.png" "file://$PWD/docs/architecture.html"

The window size matches the diagram's own width; without it the render is padded with dead space, and without a scratch profile it collides with a running Firefox.


Security

  • The server binds to 127.0.0.1 only. The browser bridge (/browser/*, /mcp/call) requires the local API token; /mcp — which exposes browser_execute_script — and /health are open to anything on localhost.
  • Native messaging is restricted to the Foxhand extension ID (foxhand@tantran.dev).
  • browser_execute_script runs in an isolated world the page's own scripts can't read, and the userScripts permission is opt-in per install.
  • webRequest and cookies reach real credentials — request headers carry Cookie and Authorization, and browser_get_cookies returns HttpOnly cookies that page script cannot touch. So: the request recorder is off until browser_start_logging names a tab, its buffer lives in memory only (500 entries per tab, dropped when the tab closes), include_headers: false returns the index without them, and nothing is ever written to disk. Whoever can reach /mcp on localhost can read them — that is the same trust boundary browser_execute_script already sets.
  • Screenshots and logs stay on the local machine; nothing is sent to external servers.

License

MIT License — © 2026 Tom Q, Tetra Software. See LICENSE.