Secure Browser MCP
An MCP server that gives an AI client (Claude, etc.) controlled access to a realheadless browser on your server — with domain allowlisting, SSRF protection,per-session isolation, an audit log, and cookies/state that survive restarts.
Why "secure" specifically
Browser-automation MCPs are risky by default because the LLM effectively getsa pair of hands on a live browser that can reach anywhere on the internet(and, if misconfigured, your internal network). This server closes thecommon holes:
| Risk | Mitigation |
|---|---|
| SSRF (browser tricked into hitting internal services / cloud metadata endpoint) | src/security.ts resolves DNS itself and blocks private/loopback/link-local IP ranges, independent of what hostname was requested |
| DNS rebinding (domain allowlisted, but later resolves to an internal IP) | DNS is re-resolved and IP-checked on every navigation, not cached |
javascript: / data: / file: URL abuse |
Scheme is rejected before anything touches the browser |
| Unrestricted destinations | Hard allowlist via ALLOWED_DOMAINS — fails closed if empty |
| Unauthenticated access to the MCP endpoint | Bearer token required on every request (MCP_AUTH_TOKEN) |
| Session/cookie leakage across tasks | Each sessionId gets its own isolated BrowserContext (separate cookie jar, storage, cache) |
| Resource exhaustion | MAX_SESSIONS cap + idle-session reaper (closes contexts unused for 30 min) |
| Silent/undetectable misuse | Every tool call is written to a SQLite audit_log table with session, params, and result |
| Oversized responses blowing up context | Text and screenshot payloads are size-capped |
| Drive-by downloads | acceptDownloads: false by default |
This covers the common attack surface, but you're still exposing a browser toan LLM. Keep ALLOWED_DOMAINS as narrow as your task allows, and run this ona host/container with no access to anything sensitive — treat it like youwould a CI runner that executes untrusted code.
Persistent storage — what's actually persisted
Two things, both in SQLite at ./data/browser-mcp.db (path configurable viaDATA_DIR):
- Browser state — cookies + localStorage per session, captured viaPlaywright's
storageState()and restored on the nextbrowser_navigatecall for thatsessionId. This is what lets a session stay logged in toa site across server restarts. Callbrowser_persist_sessionto saveexplicitly, orbrowser_close_session(which persists automatically). - Audit log — every tool invocation, its params, and outcome, so youcan review what the browser actually did later (
browser_audit_log).
If you'd rather keep this in Supabase instead of local SQLite (e.g. somultiple server instances share state), swap storage.ts for Supabase calls— the function signatures are small and self-contained, so it's a drop-inreplacement.
Setup
npm install
npx playwright install --with-deps chromium # downloads the browser binary
cp .env.example .env
# edit .env: set MCP_AUTH_TOKEN and ALLOWED_DOMAINS
npm run build
npm start
For local iteration without building: npm run dev.
The server listens on POST http://localhost:8787/mcp (Streamable HTTPtransport). Point your MCP client at that URL with:
Authorization: Bearer <your MCP_AUTH_TOKEN>
Tools exposed
browser_navigate(sessionId, url)— allowlist + SSRF-checked navigationbrowser_get_text(sessionId, selector?)— read page/element textbrowser_click(sessionId, selector)browser_type(sessionId, selector, text)browser_screenshot(sessionId)— base64 PNGbrowser_persist_session(sessionId)— force-save cookies/localStoragebrowser_close_session(sessionId)— persist + free browser resourcesbrowser_list_sessions()browser_audit_log(sessionId, limit?)
sessionId is any string you choose (e.g. "pranav-github-login") — reusethe same one to keep continuity (logged-in state, cookies) across calls.
Deploying on your existing server
- Render: same pattern you used for the MongoDB MCP — set env vars inthe dashboard (don't bake
MCP_AUTH_TOKENinto the image), expose port8787, and set the health check toGET /mcpreturning 401 (expected,since it's unauthenticated) rather than a 200. - Put this behind HTTPS (Render/most PaaS do this for you) — the bearertoken is meaningless over plain HTTP.
- If the server also hosts other things, run this in its own container sothe idle-session reaper and
MAX_SESSIONScap actually bound its resourceuse independently.
Extending
- To let the LLM choose domains dynamically instead of a static allowlist,add an approval step (return a tool result asking for confirmation) ratherthan opening
ALLOWED_DOMAINSwide. - To persist to Supabase instead of SQLite, replace the functions in
src/storage.ts; the audit log schema maps directly to a Postgres table. - The MCP TypeScript SDK evolves — if
npm installpulls a version with adifferentStreamableHTTPServerTransportAPI, checkhttps://github.com/modelcontextprotocol/typescript-sdk for the currentsignature.