Skip to content

Repository files navigation

mcp-browser

Drive a real headless browser from Claude Code — over MCP, with session isolation and an SSRF guard that actually holds.

License Node TypeScript MCP


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.

Architecture

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
Loading

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.

Tools

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.

Documentation

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.

Quick start — local (stdio, no server)

Point it at a Chrome you started locally, no Docker or DNS:

google-chrome --headless=new --remote-debugging-port=9222 &
npx -y mcp-browser --stdio

Then 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.

Quick start — server (Docker + Caddy + HTTPS)

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 provisioning

Verify:

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). Returns 503 when 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.

Connect Claude Code

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" }
    }
  }
}

Security posture

Read this before pointing it at anything you care about.

Enforced by default

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.

Known gaps — the honest list

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_traffic therefore always reports a cold load — more honest for analysis, but not what a returning real user sees.
  • ALLOWED_HOSTS / ALLOWED_ORIGINS ship 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_IMAGE default to :latest. Pin a digest once you have vetted a build — a floating tag can drift CDP protocol compatibility out from under playwright-core.
  • Never run end-to-end. Test coverage is unit-level. See Before you trust it.

Configuration

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.

Development

pnpm install
pnpm run typecheck     # tsc --noEmit
pnpm test              # builds, then node --test
pnpm run dev           # tsc -w

Node ≥ 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.

Before you trust it

This has never been run end-to-end. Everything verified is typecheck, unit tests, and config parsing. Test these first:

  1. 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 of mcp-b. Point browser-b at a fingerprint checker and confirm.
  2. Browser healthchecks assume /json/version exists and wget or curl is present in those third-party images.
  3. read_only: true + tmpfs: /tmp on the browser containers. Chrome images often need more writable paths than /tmp.

License

MIT — see LICENSE.

About

MCP server exposing headless browser automation and traffic analysis to Claude Code over Streamable HTTP

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages