- JavaScript 54.3%
- Python 39.3%
- CSS 2.8%
- Shell 2.7%
- HTML 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .claude/agents | ||
| docs | ||
| extension | ||
| mcp-server | ||
| native-host | ||
| scripts | ||
| tests | ||
| .gitignore | ||
| .mcp.json | ||
| LICENSE | ||
| README.md | ||
Foxhand
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
userScriptsworld, 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) |
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).
- 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
userScriptspermission thatbrowser_execute_scriptneeds (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/ensurepippackage is needed. Not installed yet:curl -LsSf https://astral.sh/uv/install.sh | sh(orbrew install uv), then open a new shell souvis onPATH. - Firefox 153+ —
userScripts.execute, which the page tap andbrowser_execute_scriptboth depend on, is not available before it - Node.js — only to sign the extension (
scripts/sign.shrunsweb-extthroughnpx). 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 — copyfoxhand.jsonthere 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
- Install it:
about:addons→ gear → Install Add-on From File → the.xpiindist/. - Grant all three permissions — see the table below. Two of them are not in the install prompt.
- Point your MCP client at
http://127.0.0.1:8765/mcp— config snippet below. - Verify:
curl -s localhost:8765/healthshould reportbrowsers_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,paddingin 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 inmethod:
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
userScriptspermission — the same popup Enable buttonbrowser_execute_scriptneeds. Without it the logging tools return an error. browser_start_loggingalso arms it atdocument_startfor subsequent navigations, so after a reload the buffer covers the page from its first request.browser_stop_loggingdisarms it (tabs where it was already injected keep it until they navigate).- Calling a read tool without
browser_start_logginginjects 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_starttap worth having.browser_stop_loggingstops 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(andstartif it was disabled). If the native host row is also disconnected, reload the extension — a temporary add-on must be re-loaded fromabout:debuggingafter each Firefox restart, which is what./scripts/sign.shexists to avoid. browser_execute_scriptreturns "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.logandnative_host.log. browser_get_requestsreturns entries with nobody— readbodyCaptureStatsin the same reply; it names the broken link. AcreateErrorscount withlastErrormentioning a permission means Firefox is holding one back:about:addons→ Foxhand → accept it.browser_get_requestsreturnsrecording: false— it records only betweenbrowser_start_loggingandbrowser_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 (
webRequestandcookiesfor headers and the cookie store;webRequestBlocking,webRequestFilterResponseandwebNavigationfor 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. Unauthorizedfrom the HTTP API — send theX-API-Keyheader (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.1only. The browser bridge (/browser/*,/mcp/call) requires the local API token;/mcp— which exposesbrowser_execute_script— and/healthare open to anything on localhost. - Native messaging is restricted to the Foxhand extension ID (
foxhand@tantran.dev). browser_execute_scriptruns in an isolated world the page's own scripts can't read, and theuserScriptspermission is opt-in per install.webRequestandcookiesreach real credentials — request headers carryCookieandAuthorization, andbrowser_get_cookiesreturns HttpOnly cookies that page script cannot touch. So: the request recorder is off untilbrowser_start_loggingnames a tab, its buffer lives in memory only (500 entries per tab, dropped when the tab closes),include_headers: falsereturns the index without them, and nothing is ever written to disk. Whoever can reach/mcpon localhost can read them — that is the same trust boundarybrowser_execute_scriptalready 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.