Secure Browser MCP
README.md
# Secure Browser MCP
An MCP server that gives an AI client (Claude, etc.) controlled access to a real
headless 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 gets
a pair of hands on a live browser that can reach anywhere on the internet
(and, if misconfigured, your internal network). This server closes the
common 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 to
an LLM. Keep `ALLOWED_DOMAINS` as narrow as your task allows, and run this on
a host/container with no access to anything sensitive — treat it like you
would 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 via
`DATA_DIR`):
1. **Browser state** — cookies + localStorage per session, captured via
Playwright's `storageState()` and restored on the next `browser_navigate`
call for that `sessionId`. This is what lets a session stay logged in to
a site across server restarts. Call `browser_persist_session` to save
explicitly, or `browser_close_session` (which persists automatically).
2. **Audit log** — every tool invocation, its params, and outcome, so you
can review what the browser actually did later (`browser_audit_log`).
If you'd rather keep this in Supabase instead of local SQLite (e.g. so
multiple server instances share state), swap `storage.ts` for Supabase calls
— the function signatures are small and self-contained, so it's a drop-in
replacement.
## Setup
```bash
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 HTTP
transport). Point your MCP client at that URL with:
```
Authorization: Bearer <your MCP_AUTH_TOKEN>
```
## Tools exposed
- `browser_navigate(sessionId, url)` — allowlist + SSRF-checked navigation
- `browser_get_text(sessionId, selector?)` — read page/element text
- `browser_click(sessionId, selector)`
- `browser_type(sessionId, selector, text)`
- `browser_screenshot(sessionId)` — base64 PNG
- `browser_persist_session(sessionId)` — force-save cookies/localStorage
- `browser_close_session(sessionId)` — persist + free browser resources
- `browser_list_sessions()`
- `browser_audit_log(sessionId, limit?)`
`sessionId` is any string you choose (e.g. `"pranav-github-login"`) — reuse
the 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 in
the dashboard (don't bake `MCP_AUTH_TOKEN` into the image), expose port
`8787`, and set the health check to `GET /mcp` returning 401 (expected,
since it's unauthenticated) rather than a 200.
- Put this behind HTTPS (Render/most PaaS do this for you) — the bearer
token is meaningless over plain HTTP.
- If the server also hosts other things, run this in its own container so
the idle-session reaper and `MAX_SESSIONS` cap actually bound its resource
use 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) rather
than opening `ALLOWED_DOMAINS` wide.
- 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 install` pulls a version with a
different `StreamableHTTPServerTransport` API, check
https://github.com/modelcontextprotocol/typescript-sdk for the current
signature.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues