mcp-camoufox
RobithYusuf/mcp-camoufox/AGENTS.md
This guide applies to the entire repository and is the shared source of instructions for coding agents. Its purpose is not to govern every decision, but to preserve the workflows and invariants that are easy to miss — most of them were learned from a shipped bug. Developer requests may override working defaults; hard constraints may only be overridden when the developer explicitly asks for it. An MCP server (stdio JSON-RPC) that gives an agent a stealth browser: 133 tools…
AGENTS.md19 starsChanged 42 days ago
# mcp-camoufox — Agent Guide
This guide applies to the entire repository and is the shared source of instructions for coding agents.
Its purpose is not to govern every decision, but to preserve the workflows and invariants that are easy
to miss — most of them were learned from a shipped bug. Developer requests may override working
defaults; hard constraints may only be overridden when the developer explicitly asks for it.
## Product and repository map
An MCP server (stdio JSON-RPC) that gives an agent a stealth browser: 133 tools over Camoufox, a
Firefox build with C++-level anti-fingerprinting. Published on npm as `mcp-camoufox`.
```
npx -y mcp-camoufox → Node → camoufox-js → Playwright (Juggler) → Camoufox Firefox
└→ impit (Firefox JA4 TLS) → browserless HTTP path
```
- `src/index.ts` — entry: connects stdio, imports the tool modules.
- `src/state.ts` — the single mutable record `S`, page bookkeeping, shared capture buffers.
- `src/helpers.ts` — refs, clicks, fills, snapshots, paths, TOTP.
- `src/server.ts` — the `McpServer` instance and the `regTool` registry.
- `src/tools/*.ts` — the tools, grouped by area; each registers itself on import.
- `scripts/test/` — the regression suites; `scripts/*.mjs` — release checks.
- `docs/TOOLS.md` — the authoritative tool reference. `README.md` — the summary and the pitch.
- `docs/images/` — proof screenshots.
- Runtime data lives outside the repo: `~/.camoufox-mcp/{profile,screenshots,sessions}`.
## Hard constraints
- Never commit or print a credential. The npm token is set into `~/.npmrc` immediately before
`npm publish` and deleted immediately after; `.npm-token` and `*.tgz` stay ignored.
- Never publish from the working tree alone. A release is only verified once
`npm run smoke:install` has installed the **published** package and driven a real browser.
- `playwright-core` must stay pinned `<1.61.0`. Playwright 1.61+ sends `isMobile` in
`Browser.setDefaultViewport`, which Camoufox's Juggler schema rejects, and camoufox-js declares
the peer as `"*"`. Unpinning breaks `browser_launch` for every fresh install while this tree
keeps working.
- Do not weaken a security property to make a test pass: secret field values never leave the browser,
credential files are written `0600`, name parameters never escape their directory, and browser
cookies are recomputed per redirect hop.
- Do not add a tool that can fail silently. If an action may not have taken effect, the response says
so — see the click/wait invariants below.
- `reset_profile`, `browser_recover` and anything that deletes a profile need an explicit request; the
shared profile holds the developer's real logins.
## Working defaults
- Understand the affected flow, then make the smallest coherent change that solves the problem.
- Prefer the existing shared helpers (`refLocator`, `fillLocator`, `clickWithFallback`, `snapshotPage`,
`jsStr`, `resolveOutPath`) over a local variant. A new tool that hand-rolls one of these will drift
from the fixes the others already carry.
- Verify claims by running them. Several bugs in this repo survived for months because they were
reasoned about rather than reproduced, and two "already fixed" reports turned out to be untested.
- Report honestly: if a check was skipped, say so; if a test crashed rather than passed, say that.
- The developer manages releases. Bump, publish and push only when asked.
## Architecture invariants
### State and registration
- All mutable state lives on the single exported record `S` in `state.ts`. An imported `let` binding
cannot be assigned to from another module, so a plain export would silently split the state in two.
- Every tool registers through `regTool()`, never `server.tool()`. `regTool` stores `{schema, handler}`
in `toolRegistry` so `workflow_run` can invoke any tool by name with the same zod validation and
defaults a real MCP call gets. It is typed as `typeof server.tool`, so handler arguments keep their
inference.
- `PKG_VERSION` is read from `package.json` via `createRequire`, so the MCP handshake version cannot
drift from the published one.
### Refs, clicking and filling
- `browser_snapshot` injects `data-mcp-ref` attributes; `refLocator(page, ref)` is the only place that
selector is written. Refs are numbered before any role/offset/limit filtering, so they stay stable.
- `clickWithFallback` returns `"real" | "fallback"` and every click tool appends `clickNote(mode)`. A
blocked click must never read as a clean one. The fallback replays the full pointer sequence
(`pointerover/pointerdown/mousedown/pointerup/mouseup` then `el.click()`), because a bare
`el.click()` does nothing on Radix, Headless UI and MUI.
- `click_text` and `click_role` refuse to guess: more than one match fails with a numbered candidate
list. `within` (`"@dialog"`, a CSS selector, or `"ref:e5"`) or `index` disambiguates. Taking
`.first()` once clicked a page header's "Cancel" instead of the dialog's and destroyed a filled form.
- Always fill through `fillLocator`. Firefox's select-all is a no-op on `input[type=email]` and
`[type=number]`, so a plain `locator.fill()` appends to the old value.
- `formatSnapshot` caps its element list at `MAX_SNAPSHOT_CHARS` (60k). A 6,000-element page produced a
566,000-character response with no warning at all; it now stops and prints the exact `offset=` call to
continue, plus the `roles=` and `extract_structured` alternatives.
- Secret-looking fields (type=password, or `pass|secret|token|otp|cvv|card|pin` in name/id/autocomplete)
are masked everywhere they could surface: `fill`, `cookie_set`, `login_classic`, `browser_snapshot`
and `inspect_element`.
### Navigation
- Every navigation goes through `gotoReady` / `waitReady` in `helpers.ts` — never `page.goto` with a
lifecycle `waitUntil`. They commit the navigation and then poll the document, because the events
Playwright would otherwise wait for stop arriving (see the Camoufox facts below). `networkidle` uses
the same per-page in-flight counter as `wait_for_network_idle`, not the equally dead lifecycle event.
- A navigation is followed by `settle(page, capMs)`, never a blind `waitForTimeout`. Each navigation
used to sleep a flat 1000ms (1500 in launch and navigate_and_snapshot) — a guess from when nothing
here could tell whether a page was ready. Measured against a local fixture that was 85% of the whole
call: 1113ms for `navigate` versus 166ms for `tab_new` doing the same work without it. `settle`
watches the same in-flight counter and returns as soon as the page is quiet, keeping the old duration
only as the worst case. `navigate` now costs ~315ms locally and ~545ms on a real site.
- Do NOT use `settle` after a click or a form submit. It measures network quiet, and right after a
click the request often has not left yet — it would return before the thing it is meant to wait for
even starts. The fixed sleeps in `fill_form` and `login_classic` are deliberate for that reason.
- `tab_new` calls `trackPage` and sets the active page **before** navigating. The tab exists the moment
`newPage()` returns, so tracking it only after a successful `goto` left a dead tab nobody could
select or close.
### Tabs, capture and dialogs
- `browser_launch` registers `ctx.on("page", trackPage)`, so pages the *site* opens (`window.open`,
`target=_blank`, OAuth popups) are tracked automatically. `trackPage` is idempotent, removes a page
on `close`, arms the per-page in-flight request counter, and re-attaches console/network/dialog
handlers to new tabs.
- The active tab is tracked by page **identity**, not index. Closing a lower-indexed tab must not move
"active" to a different page.
- A one-shot `dialog_handle` arms tabs opened later too (`S.oneShotDialogHandler`, armed by `trackPage`).
It tells the persistent handler to stand down globally, so arming only the tabs that existed at the
time left a dialog on a newer tab with no handler at all — and a registered listener suppresses
Playwright's auto-dismiss, blocking that page forever.
- `console_start`/`network_start` attach to every page and detach any previous handler first, so
re-calling never stacks listeners. `browser_close` nulls the handler refs and clears the buffers.
- `browser_launch` claims its launch slot synchronously before the first `await`. The SDK dispatches
requests concurrently, and two launches otherwise each build a context — the loser unreachable by
`browser_close`.
### The browserless HTTP path
- `impit` presents a real Firefox JA4 (`t13d1715h2…`), matching the browser this server actually
drives. `http_request`, `scrape_markdown` and `smart_fetch` never launch a browser; `smart_fetch`
escalates only when `looksBlocked()` fires.
- Redirects are followed **manually**. impit replays a manually-set `Cookie` header onto the redirect
target, so an open redirect handed the session cookie to another host. Cookies are recomputed per hop
and caller credentials are dropped when the origin changes.
- `htmlToMarkdown` is regex-based on purpose — no jsdom or turndown dependency.
- `search` never reports an unreadable response as "no results". If the provider's container is missing
it says so and prints the top-level keys it did get — a shape mismatch reported as an empty result set
is the same confident lie this project keeps removing.
- No SERP scraping. `web_search`/`deep_research` shipped in 0.9.0 and were removed in 0.9.2: Bing
answers any "how does …" query with dictionary pages for the word "does", DuckDuckGo is
TLS-intercepted on some ISPs, and every alternative needs its own fragile parser. `search` is the
sanctioned replacement — it requires an endpoint the user controls (self-hosted SearXNG, or
Brave/Tavily/Exa with a key) and parses a documented JSON contract, never a result page. A new
provider is welcome there; a new HTML scraper is not.
## Firefox and Camoufox facts
These are platform truths, not preferences. Each one cost a release.
- `page.evaluate` string arrow functions may not auto-invoke — use an IIFE, and `var` rather than
`const`/`let` inside evaluate strings.
- Every value spliced into an evaluate string goes through `jsStr()` (`JSON.stringify`). A selector or
key containing a quote, backslash or newline otherwise breaks the whole expression.
- Camoufox stops delivering the `load` and `domcontentloaded` lifecycle events after the **fifth page**
in a context. Measured with a fresh browser per run: both events succeed 4 times, then time out every
time after (8/12 at 30s), while `commit` plus a `document.readyState` poll passes 12/12 with the DOM
verified present. Anything waiting on those events burns its whole timeout on a page that loaded
fine — this silently broke `navigate`, `tab_new`, `reload` and `go_back` for anyone who opened five
tabs. Never pass `waitUntil: "domcontentloaded"`/`"load"` to Playwright here; go through `gotoReady`.
- Camoufox sporadically never commits a navigation — measured at roughly 1-2% of them, on new tabs and
existing pages alike. `gotoReady` asks the document whether it arrived before believing the timeout,
and `tab_new` discards a tab whose first navigation never committed and retries on a fresh one. Do
NOT re-issue `goto` on a page whose navigation is still in flight: both calls then fail, which broke
every CSP navigation when it was tried. A 30s timeout from `navigate` is this flake — retry it.
- `page.url()` is a function, not a property.
- `mouse.wheel()` silently no-ops; scroll with `window.scrollBy` via evaluate.
- `page.pdf()` is Chromium-only and always throws here; `save_pdf` catches it and points at
`screenshot(full_page=true)`.
- `boundingBox()` is viewport-relative — scroll into view before any `page.mouse.*` targeting, or a
real click lands at negative coordinates and hits nothing.
- Firefox refuses synthetic `clipboardData`, so `paste_text` writes the real clipboard and presses
`ControlOrMeta+V` (needs `dom.events.testing.asyncClipboard` at launch).
- `indexedDB.databases()` exists; `layout-shift` does not, so CLS is permanently unavailable.
- There is **no CDPSession for Firefox** — perf traces, coverage, heap snapshots and CPU/network
throttling cannot be ported from mcp-stealth-chrome.
- `width`/`height` in `browser_launch` size the window **and** the viewport (width matches, height is
80px shorter — the measured chrome). Passing `window` alone left Playwright's default 1280x720
viewport in place, so `width=1400` produced outerWidth 1400 with innerWidth 1280: 120px of horizontal
chrome that Firefox does not have, and any script can subtract the two. `fingerprint_audit` now flags
that gap.
- A fixed viewport never follows the OS window, so dragging the frame wider adds empty space instead of
reflowing. That is Playwright's model, not a bug — `set_viewport_size` after the resize, or
`no_viewport: true` to track the window live, which then can exceed the spoofed screen (an anti-bot
tell the launch reply warns about).
- Camoufox randomises the spoofed `screen` per profile and sometimes picks one SMALLER than the default
1280x720 viewport, which makes the viewport exceed its own screen — an anti-bot tell that appears by
chance rather than by configuration. `browser_launch` and `set_viewport_size` both warn when it
happens. Never hard-code a "safe" viewport in a test; derive it from `screen.width/height`.
- `cookie_set` without `expires_days` creates a session cookie that Firefox never writes to disk — it
dies at `browser_close` even though the profile persists.
- npm `overrides` only apply to the root project, so ours do not reach a user's tree. Do not claim the
dependency audit is clean for users.
## Documentation on demand
- `docs/TOOLS.md` is the authoritative tool reference — every tool, with its parameters — and must be
updated in the same change that adds, removes or modifies a tool, including the `### Category (N)`
count in its heading. `README.md` carries the category summary, the hero count, the comparison table
and the troubleshooting rows; update those too when they are affected.
- Run `npm run audit:tools` instead of counting by eye. It diffs the live `tools/list` against
`docs/TOOLS.md` and the README's counts, and exits non-zero on a mismatch. Both failure modes it
catches had already happened: a tool documented nowhere, and a heading that still said "Debug (5)"
while its table listed six.
- Record a decision here when it is non-obvious and would otherwise be re-litigated or re-broken.
## Verification and debugging
```bash
npm test # 94 checks, three suites, real browser over MCP stdio
npm test -- core # one suite
npm test -- --parallel # all three at once (~27s vs ~52s); each owns its browser and port
MCPC_ONLY=snapshot npm test -- core # only checks whose name matches — debugging aid, NOT a verification
npm run test:schema # dump every tool name/description/schema
npm run smoke:install # install the PUBLISHED package and drive a real browser
npm run audit:tools # diff docs/TOOLS.md + README counts against the live tool registry
npm run smoke:real # drive real sites (CSP, inline SVG, 6 tabs, interception) — run before publishing
```
- Use evidence proportional to the risk, but a change to a shared helper means the whole suite.
- A local fixture is not a real site. Three bugs shipped while the local suite was green: `eval()`-based
polling died on every strict CSP, `browser_launch` produced a viewport no real window could have, and
`detect_content_pattern` threw on any inline SVG. No fixture had a CSP header, a window manager or an
SVG. `npm run smoke:real` drives the actual web and belongs in the release order; it found a real
fingerprint bug on its very first run. It is deliberately NOT part of `npm test` — the open web is not
deterministic and must never gate a release on someone else's outage.
- After editing `src/`, run `npx tsc` and reconnect the MCP client — a local registration points at
`dist/index.js`, so an unbuilt change means you are testing the old build.
- Before a refactor, snapshot the tool surface and diff it afterwards. On its first real use this
caught a rename leaking into English prose inside three tool descriptions — something TypeScript
cannot see.
- Release order: `npx tsc` → `npm test` → `npm run audit:tools` → `npm run smoke:real` → version bump →
`npm publish` → `npm run smoke:install`
against the published version → commit and push.
- The suites use `fresh_profile: true`; the shared profile may be locked by a browser the developer is
already running.
- Every suite cleans up in a `finally` (browser closed, fixture closed, server killed) and takes an
OS-assigned port. Both were added after crashed runs leaked Camoufox processes and left a fixed port
bound, so the *next* run died on `EADDRINUSE` and the real failure was never visible.
- Prefer `until(fn)` from the harness over a fixed sleep: a hard-coded delay both wastes time when the
event is instant and still fails on a slow machine.
- A hunt for an intermittent failure is over when the isolated repro is deterministic. The tab bug above
looked like a 1-in-5 test flake for days; a 12-iteration script pinned it at 8/12 in under a minute.
## Maintaining this guide
Add a rule only when it records a non-obvious invariant, prevents an expensive failure, or addresses a
repeated correction. Prefer the specific symptom over the general principle — "a click result
containing ⚠ means the real click was blocked" is useful; "be careful with clicks" is not. When a rule
becomes specific to one module, move it next to that module instead of growing this file.
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

