Drive a real headless browser from Claude Code — over MCP, with session isolation and an SSRF guard that actually holds.
Two headless browser backends, one MCP server image, exposed to Claude Code over HTTPS:
- Obscura — generic headless Chrome.
- CloakBrowser — stealth / anti-detection fingerprinting.
Ask Claude "use browser-b to visit example.com and analyze the traffic" and it routes to the stealth backend. "use browser-a to screenshot example.com" goes to the generic one.
flowchart LR
CC["Claude Code"]
subgraph VPS [VPS]
direction LR
CADDY["Caddy<br/>auto-HTTPS"]
A["mcp-browser"]
B["mcp-cloak"]
O["Obscura<br/>generic"]
CB["CloakBrowser<br/>stealth"]
CADDY -->|"/mcp-a"| A
CADDY -->|"/mcp-b"| B
A -->|CDP| O
B -->|CDP| CB
end
CC -->|"HTTPS · x-api-key"| CADDY
Both MCP services run the same image against a different CDP_URL. Only
Caddy is reachable from outside; the CDP ports never leave the compose
network.
Every MCP session gets its own BrowserContext, page, and traffic log.
Sessions cannot read each other's pages, cookies, or captured requests.
| Tool | Purpose | Requires |
|---|---|---|
navigate |
Go to a URL | |
analyze_traffic |
Navigate + capture the request/response log with an aggregated summary | |
get_traffic |
Return the accumulated log, filterable by URL substring / resource type | |
clear_traffic |
Reset the log | |
click |
Wait for a selector to be visible, then click it | |
wait_for_selector |
Wait for a selector before proceeding | |
get_html |
Dump the current page's HTML (size-capped) | |
screenshot |
PNG screenshot | |
evaluate |
Run arbitrary JS in the page | ALLOW_EVALUATE=true |
set_cookies · get_cookies |
Read/write the context's cookies | ALLOW_COOKIES=true |
Gated tools are not registered at all unless their flag is on — the model never even sees them in the tool list.
docs/tools.md |
Per-tool reference: input schema, return shape, example call. |
docs/architecture.md |
How Caddy, the two backends, and per-session isolation fit together. |
docs/threat-model.md |
STRIDE write-up: what's defended against, what isn't. |
CHANGELOG.md |
Release history. |
CONTRIBUTING.md |
Dev setup, PR process. |
Point it at a Chrome you started locally, no Docker or DNS:
google-chrome --headless=new --remote-debugging-port=9222 &
npx -y mcp-browser --stdioThen add the stdio block from examples/ to your MCP client
config. No API_KEY needed in stdio mode — the auth layer only fires on the
HTTP transport.
Important
DNS (A/AAAA) must already point $DOMAIN at the host before you start,
or Let's Encrypt provisioning fails and nothing is reachable.
git clone <this-repo> && cd mcp-browser
cp .env.example .env
# Required config
sed -i "s|^API_KEY=.*|API_KEY=$(openssl rand -base64 32)|" .env
sed -i "s|^DOMAIN=.*|DOMAIN=mcp.yourdomain.com|" .env
sed -i "s|^ACME_EMAIL=.*|ACME_EMAIL=you@yourdomain.com|" .env
sed -i "s|^ALLOWED_HOSTS=.*|ALLOWED_HOSTS=mcp.yourdomain.com|" .env
docker compose up -d --build
docker compose logs -f caddy # watch cert provisioningVerify:
curl https://mcp.yourdomain.com/mcp-a/health # {"ok":true,"sessions":0}
curl https://mcp.yourdomain.com/mcp-b/health/health, /live, /ready, and /metrics are the unauthenticated routes.
/live— process is up (Kubernetes liveness / systemd)./ready— browser is connected and this server can serve tool calls (Kubernetes readiness / load balancers). Returns503when the browser backend is unreachable, and doubles as the container healthcheck./health— alias for/ready, kept for the pre-P5 probe path./metrics— Prometheus text (tool call counts + latency histogram, SSRF blocks, rate-limit hits, browser reconnects, active sessions, download bytes).
Caddy will not start until both MCP servers report healthy.
cp .mcp.json.example .mcp.json # gitignored — holds your real key{
"mcpServers": {
"browser-a": {
"type": "http",
"url": "https://mcp.yourdomain.com/mcp-a/mcp",
"headers": { "x-api-key": "YOUR_KEY" }
},
"browser-b": {
"type": "http",
"url": "https://mcp.yourdomain.com/mcp-b/mcp",
"headers": { "x-api-key": "YOUR_KEY" }
}
}
}Read this before pointing it at anything you care about.
| Auth | x-api-key required. Process refuses to boot without a key of ≥32 chars, and rejects the .env.example placeholder. Constant-time comparison, rate limited per client IP, checked before body parsing. |
| SSRF guard | http/https only. The resolved address must not be loopback, private, link-local, CGNAT, multicast, or reserved — blocking cloud metadata (169.254.169.254) and the CDP control port. Re-checked on every redirect hop, not just the URL you passed in. URL_ALLOWLIST narrows it further. |
| Session isolation | One BrowserContext + page + traffic log per MCP session. |
| Bounded everything | Traffic ring buffer, POST-body truncation, response size caps, session cap, idle session reaping, evaluate timeout with page recovery, navigation timeouts, bounded shutdown. |
| Transport | DNS-rebinding protection on the MCP transport. Async route errors caught — an unhandled rejection no longer kills the process. |
| Container | Non-root, cap_drop: ALL, no-new-privileges, read-only root filesystem, memory/CPU limits, shm_size: 1gb for Chrome. |
Warning
evaluate and the cookie tools form a lethal
trifecta.
get_html pulls attacker-controlled page text into the model's context,
evaluate runs arbitrary JS, and navigate is an exfiltration channel.
They default off for exactly that reason. Turning them on means any page
you visit can attempt to drive your browser session. Only enable against
sites you trust.
- DNS rebinding. The SSRF check resolves once; Chrome resolves again when it connects. A record that flips between the two can still slip through. Closing this needs IP pinning per request.
- No HTTP cache. The redirect guard is a Playwright route handler, and
enabling routing disables Chrome's cache.
analyze_traffictherefore always reports a cold load — more honest for analysis, but not what a returning real user sees. ALLOWED_HOSTS/ALLOWED_ORIGINSship empty. DNS-rebinding protection is wired on but does not bite until you put your real domain in them.- Third-party images, unpinned.
OBSCURA_IMAGE/CLOAKBROWSER_IMAGEdefault to:latest. Pin a digest once you have vetted a build — a floating tag can drift CDP protocol compatibility out from underplaywright-core. - Never run end-to-end. Test coverage is unit-level. See Before you trust it.
Everything is environment-driven. No domain, port, or key is hardcoded in the Caddyfile, the compose file, or the source.
Required: API_KEY · DOMAIN · ACME_EMAIL
Feature gates — all default to the safe value:
| Variable | Default | Effect |
|---|---|---|
ALLOW_EVALUATE |
false |
Registers the evaluate tool (arbitrary JS). |
ALLOW_COOKIES |
false |
Registers the cookie read/write tools. |
ALLOW_PRIVATE_IPS |
false |
Disables the SSRF guard entirely. |
URL_ALLOWLIST |
(empty) | Comma-separated host suffixes the browser may reach. |
Full variable reference
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
App listen port (compose overrides per service). |
CDP_URL |
ws://127.0.0.1:9222 |
CDP endpoint of the browser backend. |
MCP_A_PORT / MCP_B_PORT |
3000 / 3001 |
Internal service ports, fed to both the app and Caddy. |
TRUST_PROXY |
true |
Honor X-Forwarded-For for client-IP attribution. |
ALLOWED_HOSTS |
(empty) | Host header allowlist. |
ALLOWED_ORIGINS |
(empty) | CORS origin allowlist. |
MAX_TRAFFIC_ENTRIES |
1000 |
Traffic ring buffer size. |
MAX_POST_BODY_BYTES |
4096 |
POST body truncation cap. |
MAX_RESPONSE_BYTES |
1048576 |
Cap on HTML / traffic payloads returned to the model. |
MAX_BODY_MEASURE_BYTES |
262144 |
Cap on reading a body just to report its size. |
EVAL_TIMEOUT_MS |
5000 |
evaluate timeout; the page is reset on expiry. |
NAV_TIMEOUT_MS |
30000 |
Navigation timeout. |
SESSION_TTL_MS |
1800000 |
Idle session reaping threshold. |
MAX_SESSIONS |
50 |
Hard cap on concurrent sessions. |
SHUTDOWN_TIMEOUT_MS |
10000 |
Forced exit if graceful shutdown stalls. |
RATE_LIMIT_MAX |
600 |
Requests per IP per window, across all MCP traffic. |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate limit window. |
OBSCURA_IMAGE · CLOAKBROWSER_IMAGE · CADDY_IMAGE |
:latest |
Image pins. |
pnpm install
pnpm run typecheck # tsc --noEmit
pnpm test # builds, then node --test
pnpm run dev # tsc -wNode ≥ 22.13 (required by the pinned pnpm@11.2.2).
Tests are node:test + node:assert — no framework, no fixtures. They cover
the logic that fails silently: the SSRF predicate, the traffic ring buffer,
truncation, boolean config parsing, and the rate-limit window.
This has never been run end-to-end. Everything verified is typecheck, unit tests, and config parsing. Test these first:
- Stealth survives context isolation. Per-session isolation uses
browser.newContext()over CDP. Playwright supports it — but CloakBrowser's fingerprint patching may only apply to its default context, and a fresh one could silently lose the anti-detection that is the entire point ofmcp-b. Pointbrowser-bat a fingerprint checker and confirm. - Browser healthchecks assume
/json/versionexists andwgetorcurlis present in those third-party images. read_only: true+tmpfs: /tmpon the browser containers. Chrome images often need more writable paths than/tmp.
MIT — see LICENSE.