Browser MCP Containers
Docker containers running Playwright MCP and Chrome DevTools MCP servers with built-in anti-detection, cookie import, and browser signature spoofing. Works with Claude Code, Cursor, or any MCP-compatible AI agent. Each container is an isolated browser instance -- one per project, no conflicts between parallel agent sessions.
Prerequisites
- Docker and Docker Compose
- Google Chrome or Chromium (for cookie export extension)
Quick Start
-
Clone the repo:
git clone https://github.com/kenpodragon/browser-mcp-containers.git cd browser-mcp-containers -
Install the cookie-exporter extension in Chrome:
- Navigate to
chrome://extensions - Enable Developer mode (top-right toggle)
- Click Load unpacked
- Select the
docker/extensions/cookie-exporter/directory
- Navigate to
-
Export your session data:
- Single site: Navigate to your app (logged in), click the extension icon, click Download All
- Multiple sites: Visit each site, click Grab DB on each, then click Download All once
-
Copy the exported file into the shared directory:
cp ~/Downloads/storageState.json docker/shared/ -
Start the containers:
cd docker && docker compose up -d -
Copy
.mcp.json.samplefrom the repo root into your project directory as.mcp.json. Rename the server keys to match your project (e.g.,playwright-webapp,chrome-devtools-extension-dev). -
If using Claude Code, copy
CLAUDE.md.sampleto your project asCLAUDE.mdfor tool reference and troubleshooting tips. -
Open Claude Code (or your MCP client) in the project directory. Browser tools are now available.
-
Verify:
curl http://localhost:9100/sseshould return an SSE event stream. In Claude Code, trybrowser_navigateto confirm tools are working.
Playwright vs Chrome DevTools
Export Pipeline
Why a browser extension? Starting with Chrome v146+, cookies are encrypted using app-bound DPAPI. External tools (Python scripts, standalone decryptors) can no longer decrypt Chrome cookies. IndexedDB (where Firebase and other frameworks store auth tokens) is also origin-scoped and inaccessible externally. A browser extension is the only reliable way to export both.
What the extension captures:
- All cookies from the current Chrome profile
- Full browser fingerprint: User-Agent, platform, screen dimensions, WebGL renderer, Client Hints, languages, timezone, connection info, hardware specs
- IndexedDB databases from the active tab's origin (Firebase auth, app state, etc.)
Multi-site workflow: IndexedDB is origin-scoped -- indexedDB.databases() only returns databases for the current page's origin. To capture auth state from multiple sites:
- Navigate to Site A (logged in) -- click Grab DB
- Navigate to Site B (logged in) -- click Grab DB
- Repeat for all sites needing auth
- Click Download All -- one
storageState.jsonwith everything
The extension accumulates IndexedDB data across sites using chrome.storage.local. A collapsible panel shows all collected origins and databases for review before downloading. The Clear button resets the collection.
Export format: Playwright-compatible storageState.json with added browserSignature and indexedDB fields. The indexedDB key is organized by origin:
{
"cookies": [...],
"browserSignature": {...},
"indexedDB": {
"http://localhost:5173": [{ "name": "firebaseLocalStorageDb", ... }],
"https://www.linkedin.com": [{ "name": "beacons", ... }]
}
}
Both Playwright and Chrome DevTools containers read from this same file. The injection is origin-aware -- each page only gets IndexedDB data matching its origin, so multiple sites with the same database name (e.g. firebaseLocalStorageDb) don't collide.
Anti-Detection and Auth Injection
At container startup, generate-anti-detect.py reads docker/shared/storageState.json and generates JavaScript patches that make the container's browser match your real browser's fingerprint AND inject IndexedDB auth data before page scripts run.
Spoofed signals:
navigator.platform,navigator.languages,navigator.userAgentscreen.width,screen.height,screen.availWidth,screen.availHeightnavigator.deviceMemory,navigator.hardwareConcurrencynavigator.connection(effectiveType, downlink, rtt)navigator.webdriver(set tofalse)- Client Hints (
Sec-CH-UA,Sec-CH-UA-Platform, etc.) colorDepth,pixelDepth,devicePixelRatio,doNotTrack
Matching Chrome flags: --user-agent, --accept-lang, --window-size are set to match the captured signature.
IndexedDB injection: If storageState.json contains an indexedDB field, the generated script intercepts indexedDB.open() calls and injects the exported records before page scripts run. This is origin-aware -- only databases matching the current page's origin are injected. This enables Firebase Auth, session tokens, and other IndexedDB-stored state to carry over into containers.
Known limitation: WebGL renderer cannot be spoofed without GPU passthrough. Containers report SwiftShader instead of your real GPU. This is the only remaining detectable signal.
MCP Configuration
Example .mcp.json for your project:
{
"mcpServers": {
"playwright-myproject": {
"type": "sse",
"url": "http://localhost:9100/sse"
}
}
}
Key points:
- Rename server keys per project (e.g.,
playwright-webapp,chrome-devtools-extension-dev) - Use the
/sseendpoint, NOT/mcp. Streamable HTTP returns 400. - Always set
"type": "sse"explicitly when you have other MCP servers using HTTP transport in the same.mcp.json. Without it, Claude Code silently skips the SSE server -- no error, no warning, just missing tools. - See
.mcp.json.samplein the repo root for a working example.
Localhost Forwarding
When your app runs on localhost:3000 on the host, the browser inside the container can't reach it by default. The FORWARD_PORTS environment variable fixes this.
How it works: At container startup, socat TCP forwarders start inside the container. Each one listens on localhost:PORT and transparently proxies traffic to host.docker.internal:PORT, reaching the host machine.
Configuration in docker-compose.yml:
environment:
FORWARD_PORTS: "5173,8080,8000"
This forwards three ports. The browser inside the container can now navigate to localhost:5173, localhost:8080, or localhost:8000 and reach your host services.
Port conflict warning: Do not include the container's internal MCP port in FORWARD_PORTS. Playwright uses port 3000 internally, Chrome DevTools uses 9222. Including these will cause socat to fail to bind (the entrypoint skips them with a warning).
When you need it: Whenever the browser in the container needs to access a dev server, API, or any service running on your host machine.
All port assignments — host mappings, internal MCP ports, and forwarded ports — are defined in docker-compose.yml, giving you a single place to see and manage the full port layout across all containers.
Adding More Containers
- Duplicate a service block in
docker/docker-compose.yml - Change these values:
- Service name (e.g.,
playwright-newproject) - Host port mapping (e.g.,
9102:3000for Playwright,9103:9222for Chrome DevTools) PROJECT_NAMEenvironment variableFORWARD_PORTSas needed for the project
- Service name (e.g.,
- Run
docker compose up -dto start the new container.
One container per project. MCP servers are single-client by design (fundamental protocol limitation). Two agent sessions connecting to the same container will conflict.
Port convention: Use ports 9100-9149 for browser MCP services.
Known Gotchas
- MCP single-client: Each container serves one AI agent session at a time. If the connection seems stuck, restart:
docker compose restart <service> "type": "sse"required: When mixing HTTP + SSE transports in.mcp.json, you must set"type"explicitly on each server entry. Claude Code silently skips SSE servers otherwise.- Stale project cache: If MCP tools don't appear after config changes, delete
~/.claude/projects/<path-encoded-project>/and reload your IDE. - Chrome DevTools screenshots: Saved inside the container at
/app/. Retrieve with:docker cp <container>:/app/screenshot.png ./ - Env var changes:
docker compose restartdoes NOT re-read environment variables. Usedocker compose up -dinstead. - Playwright base image: The official image runs as non-root. The Dockerfile uses
USER rootbeforeapt-get install. - Auth pages show login first: When navigating to auth-protected pages (e.g. Firebase), the initial page snapshot may show the login screen. Auth injection is async -- check
browser_console_messagesor take a second snapshot after a moment. ThePage URLwill show the redirected URL (e.g./gameor/feed) once auth completes. - ConstraintError warning: A
ConstraintError: An object store with the specified name already existswarning may appear in the console. This is harmless -- the IndexedDB interceptor and the page's framework both try to create the same store. Auth still works. - IndexedDB empty on export: Make sure you're on the actual site page (not
chrome://orabout:blank) and logged in before clicking Grab DB. IndexedDB is origin-scoped and only returns databases for the current tab's origin. - Auth stops working: Tokens in
storageState.jsonexpire. Re-export: visit the site (logged in) in Chrome, click Grab DB, then Download All, save todocker/shared/, anddocker compose up -d --force-recreate.
Credits
- Author: Stephen Salaka
- Blog: does-god-exist.org
- Built as part of multi-project AI agent infrastructure
- See
FINDINGS.mdfor the full story of how this was built and what we learned - See
Q_and_A.mdfor troubleshooting and common questions