Stealth Chrome DevTools MCP
Undetectable browser automation for AI agents via the Model Context Protocol — one shared backend built for 50+ concurrent Claude Code sessions.
Onboard your agent to Stealth Chrome DevTools MCP — copy the sentence below into any AI coding agent (Claude Code, Codex, Cursor, Windsurf, OpenCode, …) and it installs the server, registers it in that agent, and verifies the backend:
Fetch and execute the appropriate instructions to set me up for Stealth Chrome DevTools MCP from https://raw.githubusercontent.com/DevinoSolutions/stealth-chrome-devtools-mcp/main/agent-setup/prompt.md
The instructions it fetches are agent-setup/prompt.md —
readable by humans too. Signing in to websites stays yours: the agent prepares the
browser, you prepare the logins.
A self-contained stealth Chrome DevTools MCP server with smart profile management, anti-detection stealth arg filtering, and robust process lifecycle handling. Built on nodriver (CDP-based) for full anti-bot evasion.
Demos
Cloudflare Turnstile Bypass
https://github.com/user-attachments/assets/c4de61ae-6878-4fff-9bfd-65cdd4fadc2f
Persistent Login Sessions
https://github.com/user-attachments/assets/f81fc0c2-9233-48cd-8a9d-2577b1d33d57
Key Features
- Undetectable by anti-bot systems — Cloudflare, DataDome, PerimeterX, etc.
- Named sessions — a session keeps its cookies and logins; new ones are seeded from
default - Stealth arg filtering — automatically strips 30+ detectable Chrome flags (Puppeteer/Playwright signatures, automation markers)
- Multi-instance support — spawn and manage multiple browsers simultaneously
- Built for fleets of Claude Code sessions — a session costs a thin stdio proxy (≈ 60 MB resident), not a browser: every session shares one backend per desktop, and a Chrome exists only where a session spawned one. Measured with 62 sessions attached at once; simultaneous cold start is scale-tested at 50 concurrent sessions, all usable in seconds against one backend — see Built for fleets
- A busy session re-attaches, and only then suffixes — spawning with a
sessiona live browser still holds joins THAT browser;github-sessionbecomesgithub-session-2only when the re-attach declines - Orphan recovery — safely cleans up leaked browser processes without killing live ones
- Session persistence — a new session carries the cookies, logins and Web Data of
default - Zero idle timeout — browsers stay alive until explicitly closed
- Full CDP access — DOM manipulation, network interception, JavaScript execution, screenshots
Installation
The right way — uv tool install (persistent, fleet-safe)
uv tool install stealth-chrome-devtools-mcp==2.1.47
This installs a version-pinned executable at ~/.local/bin/stealth-chrome-devtools-mcp
(Windows: %USERPROFILE%\.local\bin\stealth-chrome-devtools-mcp.exe). Point your MCP
config (claude_desktop_config.json, ~/.claude.json, etc.) at it:
{
"mcpServers": {
"stealth-chrome-devtools-mcp": {
"command": "C:\\Users\\<you>\\.local\\bin\\stealth-chrome-devtools-mcp.exe",
"args": []
}
}
}
Claude Code one-liner (use the .exe path above on Windows):
claude mcp add --scope user stealth-chrome-devtools-mcp -- ~/.local/bin/stealth-chrome-devtools-mcp
Why not uvx in the config? It works, but uvx re-resolves the package on
every client session start. Each Claude Code session launches its own stdio
proxy, so a fleet of concurrent sessions (the shared backend is scale-tested at
50) turns startup into a package-resolution storm. A uv tool install gives
every proxy an instant, pinned executable — the shared backend, profile
handling, and per-session browser isolation behave identically.
To upgrade later: uv tool install stealth-chrome-devtools-mcp==<new-version>
(or uv tool upgrade stealth-chrome-devtools-mcp to track the latest release).
Alternatives
Zero-install trial (fine for a first look, not for fleets):
{
"mcpServers": {
"stealth-chrome-devtools-mcp": {
"command": "uvx",
"args": ["stealth-chrome-devtools-mcp==2.1.47"]
}
}
}
Or via pip (pip install stealth-chrome-devtools-mcp==2.1.47), then use the
stealth-chrome-devtools-mcp console script from that environment as the
command.
Crashes are reported to the maintainers by default, with your username and machine name scrubbed out. See Error Reporting for what a report contains and how to turn it off.
Local Development
{
"mcpServers": {
"stealth-chrome-devtools-mcp": {
"command": "uv",
"args": [
"--directory", "/path/to/stealth-chrome-devtools-mcp",
"run", "stealth-chrome-devtools-mcp"
]
}
}
}
How It Works
Built for fleets: 50+ Claude Code sessions, one backend
The usual MCP browser server runs one server process and one Chrome per client session. That is fine for one session and ruinous for fifty: every session pays for a whole browser before it has done anything. This server is shaped differently. A Claude Code session runs only a thin stdio proxy. The browsers live in one shared backend per desktop, and a Chrome exists only where a session asked for one.
Measured on a Windows 11 workstation (2026-09-11) with 62 Claude Code sessions attached at once:
What that buys a fleet:
- Memory scales with the browsers you use, not the sessions you open. An idle session holds a proxy, not a Chrome. Sixty sessions with five browsers between them pay for five browsers and sixty proxies, not sixty browsers.
- One cold start per backend. The first session boots the backend under a file lock; every other session converges on it and is usable in seconds. A session that arrives later joins in about a second.
- Nothing is left behind. Orphaned Chrome processes are reaped without killing live ones, and a spawn that fails after Chrome launched cleans up its own browser.
- Startup is not a package-resolution storm. With
uv tool installevery proxy is a pinned executable, so fifty sessions starting together do not re-resolve the package fifty times.
The per-session and per-browser figures above are the ones to plan capacity around. The backend's own footprint depends on what the sessions do with it (captured network bodies, stored element clones, live tabs), so it is not quoted as a constant.
Sessions
A session is a named, persistent Chrome profile: spawn_browser(session="acme"),
or stealthy spawn --session acme. Ask for the same name again and you get the
same cookies and the same logins — and if a browser is still open on it, that
browser, not a new one.
default is the session you get when you name none. It is where a human
logs in, and every new session starts as a copy of it. session="default" opens
it explicitly. It is reserved: you cannot create a session of your own by that
name (nor by master or master-snapshot, which are the mechanism's own
directories).
seed_from / --from copies a NEW session from an existing one instead of
from default:
stealthy spawn --session work --headed # log in by hand in this window
stealthy spawn --session work2 --from work # a second session, already logged in
It applies only when the session is CREATED. Passing it for a session that already exists is an error that names where that session actually came from — never a silent no-op, and never a re-seed over a login you typed by hand.
The source may be open, as long as its browser is one this backend is
driving — which it is if you spawned it (the example above leaves the work
window up). Its cookies are then read out of the running browser and written
into the new session, and the spawn says so:
seeded : seeded from work at 2026-09-21 14:02
cookies : 14 handed over from the running source
That hand-off carries cookies and nothing else. Every kind — session,
persistent, HttpOnly, Secure, SameSite=None, Partitioned — and it
carries every site the source is logged into, not only the one you meant. It
does NOT carry localStorage, sessionStorage, IndexedDB, Cache Storage,
service workers or saved passwords, so a site that keeps its token in
localStorage will not be logged in; for those, close the source first so the
file copy can read them. If the hand-off fails the session is still created and
still works — the answer then says cookies : NOT carried (…).
A source open in a browser this backend does NOT drive — another backend's, or a Chrome you started yourself — is refused by name, because there is no connection of ours to ask it for its cookies and a file copy of a live profile carries none at all.
--from default — and an unset --from, which means the same thing — has
three outcomes, because default is the session you log in to by hand and its
window is usually still open:
The seed is a separate, closed, copyable form of default (below) and it is
refreshed when that window closes. The one case that IS refused is a machine
where no seed exists yet and default is open: there is nothing safe to
copy, and copying the live directory would hand you a session missing exactly
the logins you wanted. Close the default window once — that writes the seed —
and it works from then on, open or not.
C:\stealth-mcp-browser-sessions\
master/ # the `default` session — your logins, cookies, extensions
master-snapshot/ # its seed: a safe copy, refreshed while `default` is closed
sessions/ # your named sessions, and disposable copies
acme/
acme-2/ # auto-suffixed when a browser already holds `acme`
The directory names are historical and are what you will see in a path; the
words you type and the words the tool answers with are default and the seed.
- An unnamed spawn is always a disposable copy of the seed and never opens
default, whether or not anything has it open (F-966); onlysession="default"opens it - Before opening
defaultby name, the server refreshes its seed - When
defaultis busy, a copy is made from the seed with its live jar handed over - Copies carry all cookies, logins, and session data. On a brand-new install
there is no seed yet, so an unnamed spawn gets an empty copy and a
seed_warningthat says how to createdefault - A stale seed is auto-refreshed when auth files change
--from <session>copies a NEW session from that session instead
Whatever a session was copied from is recorded in it: stealthy profiles and a
spawn's own answer both print seeded from <name> at <when>, and flag
SEED CHANGED SINCE when the source has taken a login write since the copy was
made. A login done in one session still does not reach another on its own —
that is deliberate, and --from is how you ask.
Clones exclude regenerable Chrome caches, so each is a few MB rather than
multiple GB. Disposable auto-clones are deleted on close, and a storage cap
(STEALTH_MCP_CLONE_STORAGE_CAP_GB, default 10 GB) reclaims the oldest idle
clones if any ever leak — so sessions/ stays bounded. Cap eviction is
recoverable: an evicted clone is moved into sessions/.trash/ and only
purged after a retention window (STEALTH_MCP_CLONE_TRASH_RETENTION_HOURS,
default 24 h), so a mistaken eviction can be restored rather than lost.
Named profiles you create explicitly (e.g. github-session) persist and are
never deleted. But even a "persistent" profile is ~98% regenerable (caches plus
Chrome's multi-GB on-device AI model). So when sessions/ exceeds
STEALTH_MCP_BROWSER_SESSION_STORAGE_CAP_GB (default 20 GB), the largest idle named
profiles are trimmed of those regenerable dirs while every login is
preserved — Chrome rebuilds them on next launch. In-use profiles are never
touched.
Shared-machine note: the browser-session root defaults to
C:\stealth-mcp-browser-sessions(drive root), which holds your logged-in cookies and session data. On a single-user machine this is fine. On a shared multi-user Windows box, other local users may be able to read it — pointSTEALTH_MCP_BROWSER_SESSION_ROOTat a location inside your user profile (e.g.%LOCALAPPDATA%\stealth-mcp) so the OS user ACLs protect it.
The shared fleet session and the session lock
One signed-in browser that several agents work from, and that outlives all of them.
spawn_browser(session="fleet") # first call creates it (a copy of the default seed)
spawn_browser(session="fleet", seed_from="upup-b9be57135713-84488-27")
# or: make a browser that is ALREADY running the fleet
fleet is an ordinary named session, so everything named sessions promise holds:
close_instance keeps the profile, a backend restart re-attaches the browser under the
same instance_id (and restores fleet first), and nothing reaps it except
kill-orphans --force. A failed re-attach to fleet leaves its browser running and
recorded instead of reaping it, because the login in it cannot be re-created.
Asking for fleet while it is already open in this backend returns that instance with
already_running: true and the current session_lock; it is never walked to fleet-2
and never a copy. A spawn that names nothing is still never handed it. Closing it needs the
name too: close_instance(instance_id=..., session="fleet") (and session="default" for
default); without it the close is refused and the browser is left running, because other
chats work in it. If the running browser's
headless state differs from the headless you passed (the default False counts), the answer
adds headless_mismatch; a running browser cannot change it, so close it and spawn again. A
new session whose live cookie hand-off failed returns a top-level seed_warning (it is then
probably signed out), in addition to seeded_via: "copy" in spawn_diagnostics.
seed_from accepts any session name in the sessions directory, including the
<name>-<n> an agent's browser was walked to. If this backend drives the source, its live
cookie jar is carried over; if another process holds it, the request is refused by name.
Cookies only: no localStorage or IndexedDB from a running source (see spawn_browser).
Make it the default source for every new clone and new named session, instead of
master-snapshot: set STEALTH_MCP_SEED_SESSION=fleet. It is off by default. The
fleet session itself still bootstraps from the snapshot, and a misnamed value is refused
naming the setting. If the seed is held by a browser this backend does not drive, an
unnamed clone falls back to the snapshot with a seed_warning; an explicit seed_from or
a new named session is refused by name. Only the source rotates Google cookies (F-939);
clones, and a new named session seeded from a live browser, stay fenced.
The lock is advisory. Tool calls carry no caller identity, so a lock cannot stop another agent's calls; it is the shared answer to "is somebody using this?".
Leases expire on their own and are held in memory, so a backend restart clears them.
Stealth Arg Filtering
The server automatically strips Chrome flags that would compromise stealth:
Stripped args are reported in spawn_diagnostics.stealth_args_stripped.
On Windows a spawn also launches with --disable-features=CalculateNativeWinOcclusion, so a headed window that other windows cover keeps visibilityState visible at Normal priority instead of being backgrounded (F-1010); the Puppeteer backgrounding switches above stay stripped.
The backend's HTTP port is local-only and token-protected (F-1013, F-1018)
The shared backend listens on 127.0.0.1 and refuses what a web page could send it: a request whose Host is not a loopback name
(a DNS-rebound domain, 421) or whose Origin is a foreign web origin (403). It also demands a per-user bearer token on every
request: the backend keeps one in ~/.stealth-mcp/backend.token (readable by your OS user only; mode 0600, or a single-user ACL on
Windows) and answers 401 without Authorization: Bearer <token>. The stdio proxy, stealthy, doctor and status read the file and
send it, so nothing changes for them. A raw curl or a uvx url-transport client must send the header itself. After upgrading, restart
Claude Code sessions that were open on an older release if they cannot reconnect. If you bind another address on purpose
(--host backend.lan), that name is allowed too; --allowed-host NAME (repeatable) adds a name such as a reverse proxy's.
Orphan Recovery
On server restart, the process cleanup system:
- Reaps only browsers whose owner backend is dead — every tracked browser records which backend started it, so two backends running side by side never reap each other's browsers
- Keeps
create_timetracking as a second net: never kills a process that started after the current server session began - Safely handles
psutil.AccessDeniedon Windows elevated processes
Headed Browsing and Where the Window Opens
A headed browser appears on the desktop of whichever process launched it, not of whichever session asked. Because sessions share a backend, a backend that was first started from an SSH login or a Windows service session cannot show a window to anyone — including the sessions running on the physical desktop.
So the backend is keyed by display context: one per desktop, plus one for a
headless context. Discovery prefers a backend that can show a window, which means
an SSH-driven spawn_browser(headless=False) automatically uses the desktop
backend and its window opens on the real screen. Where no such backend exists, the
spawn raises instead of handing back an invisible browser; run
stealth-chrome-devtools doctor to see which contexts have a backend. Headless
spawns work from anywhere.
Usage Examples
# A disposable copy of the shared default session (what you get when you name none)
spawn_browser()
# A named session, with login persistence
spawn_browser(session="github-session")
# Same name while a browser still holds it → re-attached to THAT browser
spawn_browser(session="github-session")
# Headless with stealth (bad args auto-stripped)
spawn_browser(headless=True, browser_args=["--enable-automation"])
# → stealth_args_stripped: ["--enable-automation stripped: sets navigator.webdriver=true"]
MCP Tools
97 tools across 12 sections — the count is derived from the live tool registry, never hand-maintained. See the full navigation map →.
That is what the server serves, which is not the same as what the release
gate proves. At the release SHA in the evidence ledger, 3 of those 97 are
release-qualified: asserted end-to-end over the real stdio transport a client
actually speaks. The rest are driven against real Chrome by the E2E suite but
through an in-process seam, so they are served-unqualified at the wire — tested,
not proved there. RELEASE_CONTRACT.md lists the state of
each tool and is the only source for those numbers.
Testing
# Unit tests only (no Chrome needed)
uv run pytest -m "not integration"
# All tests (needs Chrome installed)
uv run pytest
# Verbose with short tracebacks
uv run pytest -v --tb=short
If your checkout path contains spaces or an
&,uv run pytestfails withFailed to canonicalize script path— use the venv Python directly:.venv\Scripts\python.exe -m pytest -m "not integration". See CONTRIBUTING.md for the full test/gate workflow.
A comprehensive suite covers stealth arg filtering, profile resolution, orphan recovery, storage-cap sweeps, the ops CLI, and full browser integration.
Environment Variables
All optional. Defaults work for normal use. Set them in your shell, or in
~/.stealth-mcp/.env — every key is documented in .env.example.
A .env in your project directory is deliberately ignored. The backend is a
shared process launched with whatever folder your MCP client had open, so
reading the project's .env meant reading someone else's application config —
which crashed the server outright on an ordinary DATABASE_URL and silently
adopted that app's PORT, DEBUG, and SENTRY_DSN as the server's own.
CLI
Installs a stealthy command. It both operates the backend and drives its
tools, so a shell can do anything an AI client can. (stealth-chrome-devtools is
the same command under its older name — one CLI, two names, not two tools.)
Drive the browser from a shell
Every one of these talks to the backend your shell would be served by — the
same one status reports — and starts one if none is running (--no-start makes
that an error instead: a live backend is always used as-is, whatever build it is,
but starting one can evict a wedged backend of another build). Output is a
table on a terminal and JSON in a pipe:
stealthy ls # browser instances
stealthy spawn --session seller-central --headed # recover a stranded login
stealthy spawn --session staging --from seller-central # a new session, already logged in
stealthy spawn --headed # a disposable copy of `default`
stealthy spawn --headless --url https://example.com # no window; navigate once it is up
stealthy nav e364 https://example.com --wait load # ids resolve by prefix
stealthy call get_cookies --arg instance_id=e364c31b --arg domain=example.com
stealthy tools --section browser-management
stealthy spawn takes --headed (show a window) or --headless (no window) -- mutually exclusive -- and --url to navigate after spawning.
The server entry point (stealth-chrome-devtools-mcp) also accepts --standalone (run the stdio server in this process instead of bridging to the shared backend), --singleton-port PORT (the backend port to find or start; defaults to the standard backend port, and tests pass a free one to get a private backend) and --exit-if-unadopted (internal; HTTP only; the backend exits if no client reaches it within five minutes and it hosts no browser -- passed by the cold start that spawns a backend for a proxy, never by an operator running serve).
call reaches any tool the backend serves — there is no per-tool mirror
here, so a newly added tool is callable the day it ships. --arg key=value
values are JSON when they parse (headless=false, viewport_width=1200,
browser_args=["--x"]) and plain strings when they do not; --json '<object>'
passes the whole arguments object at once.
spawn --session <name> is the stranded-login recovery: if a browser is
already holding that session it is re-attached to over CDP — same window, same
open page — and the answer says REATTACHED. See RUNBOOK, Recover a stranded
login. A session is a NAME; to open a directory by PATH use
stealthy call spawn_browser --arg user_data_dir=<path>. A spawn with no
--session is a disposable copy of default, never default itself. Either way the
answer prints the profile_selection the backend actually made — role and
directory — so you can see what you got rather than infer it.
Exit codes are a closed set
Every failure there is becomes one of these, and errors are one line on stderr — a script never gets a raw traceback paired with Python's own exit 1:
--traceback adds the full stack after that one line; it never replaces it
and never changes the code.
Operate the backend
These four only read and preview — they change nothing, and the test suite runs them on every commit, so they are known to work:
stealthy status
stealthy profiles
stealthy cleanup
stealthy cleanup --browser-session-cap-gb 12
status reports whether the backend is up plus the browser-session root and both
caps; profiles lists profiles with size / role / in-use; cleanup previews the
reclaimable disk (dry run), and --browser-session-cap-gb previews it at a
tighter cap.
These are not auto-executed — --apply deletes, serve does not return, and
doctor needs Chrome installed:
stealthy cleanup --apply # actually reclaim
stealthy doctor # check Chrome / environment
stealthy serve --http --port 19222 # start the server
cleanup deletes idle auto-clones over the clone cap and trims idle named
profiles down to their session state — logins kept — over the browser-session cap. It
is a dry run unless you pass --apply, never touches in-use profiles, and
uses the same selectors as the automatic sweep, so the preview matches --apply.
Preparing the default session
- Start the MCP server
- Call
spawn_browser(session="default")— or, from a shell,stealthy spawn --session default --headed - Sign in to your accounts in the browser that opens
- Close it with its session name —
close_instance(instance_id=..., session="default"), orstealthy close <instance> --session default— which also refreshes the seed every other session is copied from
A spawn with no session never reaches default (F-966): it is always a
disposable copy of its seed, whether or not default is open, so closing your
own browser cannot close the shared one. close_instance refuses the default
and fleet browsers unless it is given that session's name. The
profile_selection in the answer (profile_role + the directory) says which you
got — default, or clone.
Requirements
- Python 3.11+
- Chrome, Chromium, or Microsoft Edge
- uv (recommended) or pip
- A desktop session for headed browsing (headless works from SSH, CI, and services)
Error Reporting
Crashes and errors are reported to Sentry by default, so that a failure you hit is a failure we can see and fix. There is nothing to install and nothing to configure: the SDK ships with the package and the destination is built in.
What a report contains. The exception type and message, the stack trace, the package version, and the platform. Three things are kept out of it:
- your machine name (Sentry's
server_name) is dropped entirely; - your username is removed from every path, so a stack frame reads
C:\Users\~\...,/home/~/...or/Users/~/...instead of your home directory; - local variables are not captured at all. The Sentry SDK sends them by
default; we turn that off, because a local in this tool can hold a proxy
password, an
AuthorizationorCookieheader, or a script you asked it to run — secrets that no path rule could rescue.
That is universal — it runs on every install, ours included, and there is no way to opt back into sending those fields. What it deliberately leaves alone is the part that makes a report useful: the error type, the module path after the home segment, the source line that failed, and the release it came from.
An error message still quotes whatever the failing call was working with — a URL you navigated to, a file you asked for. If that is not a trade you want to make, turn reporting off.
To turn it off, set one variable in your shell or in ~/.stealth-mcp/.env:
STEALTH_MCP_NO_ERROR_REPORTING=true
Earlier releases read SENTRY_DSN from the environment. They no longer do —
that variable belongs to your application, and a shared backend launched from
your project folder was picking it up. See
Environment Variables for why this tool ignores your
project's .env entirely.
Development setup
git clone https://github.com/DevinoSolutions/stealth-chrome-devtools-mcp
cd stealth-chrome-devtools-mcp
uv sync --extra dev --extra test # install linters + test deps
npm install # arm husky pre-commit/pre-push hooks
The six quality gates run automatically on every commit: ruff format, ruff check, ty check, vulture, suppression-owner check, file-budget check. Unit tests run on pre-push.
Documentation
- CLAUDE.md — conventions + glossary; the navigation map of the source tree is NAVMAP.md
- DESIGN.md — architecture invariants and the why behind them
- RUNBOOK.md — operating the backend: verbs, logs, recovery, MCP smoke path
- CONTRIBUTING.md — clone → install → test, the quality gate, conventions
License
See LICENSE.
Built by Devino Solutions