Stealth Chrome MCP server for AI agents. Real Chrome • 31/31 sannysoft • 0% creepjs • 77 tools • multi-browser • proxy pools • encrypted sessions • live human handoff over Cloudflare tunnel.
umbra — the darkest part of a shadow, where light is fully blocked.
Built by merging the best parts of obscura 536072b (per-session fingerprint payload) + fantoma 86f20eb (zero-mouse ARIA driver) + stealth-browser-mcp def424d (nodriver + MCP surface) — and filling in their gaps: the Page.enable() injection bug, real-GPU headless via --headless=new + ANGLE Vulkan, dynamic UA-CH version pinning, mDNS-aware WebRTC SDP filter, MCP token-efficient minification, and the _untrusted: true cognitive-separation flag on every page-sourced response.
| umbra | |
|---|---|
| bot.sannysoft.com | 31 / 31 ✓ (perfect) |
creepjs headless |
0 % (matches vanilla Chrome) |
creepjs stealth |
0 % |
| headless | real GPU via --headless=new + ANGLE Vulkan (no SwiftShader tell) |
| CDP automation tells stripped | webdriver cdc_* $cdc_ _phantom _selenium __webdriver_* __nightmare ... |
| TLS / JA3 / JA4 | real Chrome stack + optional curl_cffi for raw HTTP |
| WebRTC | mDNS-aware SDP filter (real-Chrome behavior, no LAN IP leak) |
| MCP tools | 77 — broad primitives + batch flagship + proxy_pool_* (not 95 narrow ones) |
| token efficiency | 75% tokens / 83% bytes saved vs raw output, measured over 79 calls |
| proxy support | pool w/ 5 rotation strategies, CDP auth (any provider), sticky sessions, geo filters |
| handoff | live remote-view via cloudflared Quick Tunnel (works VPS → home laptop) |
| CDP schema drift | resilient — survives Chrome field churn (e.g. dropped sameParty) without hangs |
git clone https://github.com/GabriWar/umbra.git
cd umbra
pip install -e ".[all]"
python -m umbra.server # ctrl+c after a few seconds — verify tools registerrequirements: Python 3.10+, a Chromium-based browser (Chrome / Chromium / Edge — auto-detected).
| extra | enables | install |
|---|---|---|
| (default) | core 50 tools, encrypted sessions, proxy pool | pip install -e . |
[markdown] |
extract_markdown (readability + markdownify) |
pip install -e ".[markdown]" |
[tls] |
tls_fetch (curl_cffi w/ Chrome JA3+JA4) |
pip install -e ".[tls]" |
[playwright] |
optional Playwright backend | pip install -e ".[playwright]" |
[test] |
pytest + asyncio for regression suite | pip install -e ".[test]" |
[all] |
everything above | pip install -e ".[all]" |
recommended companion: cloudflared — handoff_* exposes a live remote-view of the browser via a Cloudflare Quick Tunnel (no signup, no auth). Without it, handoff falls back to localhost-only.
sudo pacman -S cloudflared # arch / cachyos
sudo apt install cloudflared # debian / ubuntu
brew install cloudflared # macos
# else: github.com/cloudflare/cloudflared/releases/latestWire into Claude Code (or any MCP client w/ the same shape):
claude mcp add-json umbra '{
"type":"stdio",
"command":"/full/path/to/your/python",
"args":["-m","umbra.server"],
"env":{
"UMBRA_CONTAINER":"1",
"PYTHONPATH":"/full/path/to/umbra/src"
}
}'Restart Claude Code → /mcp shows umbra w/ 77 tools. For Cursor / Claude Desktop / Cline / others, edit their mcp_servers config with the same shape.
batch([
{"tool": "navigate", "args": {"tab_id": "t0", "url": "https://news.ycombinator.com"}},
{"tool": "wait_for_text", "args": {"tab_id": "t0", "text": "Hacker News"}},
{"tool": "aria_snapshot", "args": {"tab_id": "t0"}},
{"tool": "extract_links", "args": {"tab_id": "t0", "limit": 30}},
{"tool": "extract_markdown","args": {"tab_id": "t0"}},
])
# → {"results":[...5 entries with ok/data/ms each...],
# "elapsed_ms":1840, "ok_count":5, "fail_count":0}Serial in declared order, single MCP round-trip. Saves protocol framing per call AND composes with cross-call dedup (identical re-calls inside the batch return _unchanged_since instead of full payloads). Use it whenever you have ≥2 calls in mind — it's almost always the right choice.
stop_on_error=True short-circuits on first failure (default: keep going + report fail_count).
5 rotation strategies, rolling health, geo + tag filters, sticky sessions. Plugs into spawn(use_proxy_pool=True) — picks one entry per browser process (Chrome locks proxy per-process; for parallel distinct egress IPs use multiple browser_ids).
input formats — auto-detected, mix-and-match in same load:
# standard URL (auth optional, scheme optional, defaults to http://)
http://user:pass@gateway.provider.com:8080
socks5://1.2.3.4:1080
# provider IP-list export (host:port:user:pass — webshare, IPRoyal, Decodo, ...)
31.59.20.176:6754:user:pass
# sticky-session gateway (one URL, N session-suffixed users)
gw.bright.com:22225:user-session-abc123-country-US:pass
# inline metadata for filtering
http://gw.proxy.com:8080#country=US,tags=residential|sticky
rotation strategies — round_robin (default), random, least_used, best_health, sticky_browser (same browser_id always gets same entry).
creds-stripped flag + CDP auth — Chrome's --proxy-server= silently strips inline creds; umbra feeds Chrome a creds-free URL and answers proxy auth challenges via CDP Fetch.authRequired. Works for HTTP / HTTPS proxies w/ Basic auth — Bright Data, Oxylabs, Smartproxy/Decodo, IPRoyal, SOAX, NetNut, Webshare, ProxyMesh, etc.
SOCKS5 + auth caveat — Chromium has no support for SOCKS5 username/password auth (RFC 1929) — open since 2014, effectively wontfix. CDP Fetch.authRequired is HTTP-layer only; SOCKS5 auth is a TCP-subnegotiation that completes BEFORE any HTTP fires, so the Fetch domain never sees it. Workaround matrix:
| transport | auth | works in umbra? |
|---|---|---|
http://host:port |
none | ✓ |
http://user:pass@host:port |
basic | ✓ (via CDP) |
socks5://host:port |
none | ✓ |
socks5://user:pass@host:port |
RFC 1929 | ✗ — unfixable in chrome |
If u need SOCKS5 + auth, run a local HTTP→SOCKS5 forwarder (gost -L=http://:8080 -F=socks5://user:pass@upstream) and point umbra at the local HTTP port instead.
# MCP usage
proxy_pool_load(data="/path/to/proxies.txt", rotation="round_robin")
proxy_pool_health_check(timeout_s=8.0, parallel=8) # parallel probe, updates rolling health
spawn(url="https://target.com", browser_id="us-1",
use_proxy_pool=True, proxy_country="US", proxy_tag="residential")
# → {"tab_id":"t0", "proxy":{"id":"a3b1...", "host":"http://1.2.3.4:8080",
# "country":"US", "tags":["residential"], "health":1.0}}
proxy_pool_remove("a3b1...") # bad rep? drop it7 MCP tools: proxy_pool_load, proxy_pool_add, proxy_pool_remove, proxy_pool_clear, proxy_pool_list, proxy_pool_health_check, proxy_pool_export.
Tor's SOCKS5 supports stream isolation — different SOCKS user/pass = different circuit = different exit IP. One tor daemon, N distinct exits, zero provider cost:
sudo systemctl enable --now tor # binds 127.0.0.1:9050# ~/.umbra/proxies.txt — each line = one isolated circuit (user/pass arbitrary)
socks5://circ1:x@127.0.0.1:9050#tags=tor
socks5://circ2:x@127.0.0.1:9050#tags=tor
socks5://circ3:x@127.0.0.1:9050#tags=tor
socks5://circ4:x@127.0.0.1:9050#tags=tor
socks5://circ5:x@127.0.0.1:9050#tags=tor
proxy_pool_load(data="~/.umbra/proxies.txt")
spawn(use_proxy_pool=True, proxy_tag="tor")caveats — Tor exits are publicly listed (check.torproject.org/exit-addresses); most anti-bot stacks (Cloudflare Bot Mgmt, DataDome, Akamai, PerimeterX) blocklist them. Useful for archive sites / IP-leak testing / gov forms. Useless against hardened scraping targets. Slow: ~3–10s per first req per circuit, 1–3s after warm. Pin exit country via ExitNodes {us} in /etc/tor/torrc + systemctl reload tor.
agent → handoff_start("t0", "solve recaptcha")
→ returns https://random.trycloudflare.com/h-XYZ/
agent → tells user: "open this URL"
user → opens URL on phone/laptop, sees live page, clicks/types
user → hits "I'M DONE"
agent → handoff_wait("t0") blocks until done, returns post-handoff URL+title
agent → continues automation
Built on Cloudflare Quick Tunnels (no signup, instant). URL contains a 192-bit auth token in the path → URL knowledge = auth. Forces HTTP/2 for sustained WebSocket reliability.
extract_markdown('t0')
# → {"_untrusted": True,
# "title": "Web Scraping - Wikipedia",
# "markdown": "# Web Scraping\n\nMethod of extracting data...",
# "source_html_len": 87432}Mozilla Readability + markdownify. Falls back to <body> for list pages (HN, reddit) where readability gives up.
session_save('t0', 'github-me', passphrase='hunter2')
# → encrypted blob in ~/.local/share/umbra/sessions/github.com/github-me.fern
# Next time:
session_load('t0', 'github-me', passphrase='hunter2')
# → cookies + localStorage injected, you're logged inFernet (AES-128-CBC + HMAC-SHA256) + PBKDF2-HMAC-SHA256 200k iterations. Per-(domain, name) namespace, path-traversal-safe.
tls_fetch('https://api.example.com/users')
# → {"status": 200, "body": "{...}"}curl_cffi pinned to running Chrome version — JA3+JA4+HTTP/2 SETTINGS frames match Chrome exactly. ~50ms vs ~500ms via spawn+navigate.
spawn(url='...', browser_id='alice')
spawn(url='...', browser_id='bob')
# alice and bob have fully isolated cookies, profiles, identities
list_browsers()
# → [{"browser_id":"alice","tab_count":3}, {"browser_id":"bob","tab_count":1}]Match DSL: url_pattern, url_regex, method, resource_type, header_match, status_min/max. Actions: block (14 custom error_reasons; response-stage block synthesizes 5xx via fulfill), fulfill (status+headers+body|body_b64), continue (request rewrite: new_url/new_method/new_post_data/headers — headers MERGED w/ originals, not replaced), modify (response-stage getResponseBody → body_replace=[[regex,repl],...] or outright body/status/headers override), tee (pure spy: pass-through + capture body), redirect (synth 302 + Location). Per-rule delay_ms (latency injection), times (auto-disable after N hits), priority (higher fires first), capture (cross-stage body buffer for any action), enabled (pause without remove). HAR-1.2 record/replay (loose URL-only mode for query-string drift). Tracker/resource blocking from StealthOptions(block_trackers=, block_resources=) integrated into the same engine — single Fetch handler, no double-fire race. Engine: src/umbra/driver/intercept.py.
┌─ browser spawn / close / list_browsers / close_browser /
│ navigate / list_tabs / switch_tab / back / forward /
│ reload / kill_all
│
├─ ARIA aria_snapshot / aria_click / aria_type / find_by_text
│ (zero mouse) fill_form / current_state
│
├─ input (CDP) click_at / press_key / scroll / drag / hover /
│ humanized paste_text / select_option / wait_for / wait_for_text
│
├─ extraction extract_text / extract_links / grep_text / dom_query /
│ _untrusted=true inspect_element / extract_markdown / clone_element
│
├─ visual screenshot / screenshot_region
│
├─ JS evaluate / inject_css
│
├─ devtools get_console_logs / get_network_requests / clear_logs /
│ get_response_body / memory_metrics / get_cookies /
│ set_cookies / clear_cookies
│
├─ stealth ops check_detection / warm_session / rotate_fingerprint /
│ set_verbosity
│
├─ network ctrl block_urls / set_extra_headers / set_viewport /
│ dynamic_hook
│
├─ interception route_add / route_add_many / route_remove /
│ (full graph) route_set_enabled / route_block_set / route_list /
│ route_captures / har_record_start / har_record_stop /
│ har_dump / har_clear / har_replay_load
│
├─ proxy pool ⭐ proxy_pool_load / proxy_pool_add / proxy_pool_remove /
│ multi-provider proxy_pool_clear / proxy_pool_list /
│ proxy_pool_health_check / proxy_pool_export
│
├─ handoff handoff_start / handoff_wait / request_user_input
│ (cloudflared)
│
├─ sessions session_save / session_load / session_list /
│ (encrypted) session_delete
│
├─ files upload_file / setup_downloads / wait_for_download
│
├─ TLS tls_fetch (raw HTTP w/ Chrome JA3+JA4)
│
└─ batch ⭐ flagship batch (N tools in one round-trip; composes w/ dedup)
import asyncio
from umbra import stealth_browser
async def main():
async with stealth_browser(timezone="America/New_York", block_trackers=True) as b:
tab = await b.new_tab("https://news.ycombinator.com")
await asyncio.sleep(2)
await tab.save_screenshot("hn.png")
asyncio.run(main())# ARIA driver — zero mouse coords
from umbra import stealth_browser, AriaDriver
async with stealth_browser() as b:
tab = await b.new_tab("https://github.com/login")
drv = AriaDriver(tab)
await drv.snapshot()
print(drv.render_tree())
# [0] textbox "Login or email"
# [1] textbox "Password"
# [2] button "Sign in"
await drv.type(0, "me@example.com")
await drv.type(1, "...")
await drv.click(2)# Proxy pool — parallel browsers w/ distinct egress IPs
from umbra import StealthBrowser, StealthOptions
from umbra.proxypool import ProxyPool
pool = ProxyPool(rotation="round_robin")
pool.load_lines(open("proxies.txt").read()) # or load_json / load_csv / load_file
async def main():
for i in range(3):
b = StealthBrowser(StealthOptions(proxy_pool=pool))
b._pool_browser_id = f"scraper-{i}"
async with b:
tab = await b.new_tab("https://api.ipify.org")
print(await tab.evaluate("document.body.innerText"))Counter-intuitively, umbra costs LESS context than minimal browser-MCPs (incl. playwright-mcp) on any real agent session — its 77-tool catalog adds ~13KB upfront, but per-call savings recover that within 3 calls and dominate after.
measured (79-call e2e session against real Chrome + httpbin, all 77 tools exercised, see tests/test_token_audit.py):
uncompressed (set_verbosity='full') |
compressed (default) | saved | |
|---|---|---|---|
| total tokens (cl100k_base) | 136,285 | 33,357 | 75 % |
| total bytes (JSON) | 420,648 | 71,282 | 83 % |
| median per call | — | 14 tokens / 4 ms | — |
Top per-tool wins: clone_element 97 %, dom_query 52 %, tls_fetch 40 %, proxy_pool_export 45 %. The handful of zero-save tools (screenshot*, aria_snapshot, inspect_element) either ship base64 binaries (incompressible) or are already pre-RLE'd in the driver before _compact sees them.
Every MCP tool response goes through _compact():
- drops
Noneonly — empty[]/""/0/FalseKEPT (they're informative) - columnar layout for 4+ homogeneous-dict arrays:
{"_columnar":true,"keys":[...],"rows":[[...]]}— 44% smaller on realdom_query/cookies/network - constant-column hoist — shared values factored to
_constant: {col: val} - word-boundary string truncation w/ explicit
...[+Nc, raise max_str to see full]marker - list truncation w/
{_truncated, shown, total, more_via}marker — caller sees what was cut and how to lift the cap _untrusted: trueflag (8 bytes) instead of wrapping content in<external>...</external>tags- cross-call dedup ledger — identical repeat calls return
{"_unchanged_since": "cN", "_hash": "..."}instead of full payload (force_refresh=Trueto bypass) - ARIA pattern grouping (RLE) — long lists w/ repeating
(role,name)cycles collapse to a single[range] cycle×N (period P): ...line. Real HN comments page: ~70% smaller snapshot. - URL footnoting in
extract_links— repeated hosts factored to_hosts: {h1: "https://..."}then referenced. ~50% smaller on link-heavy pages.
Toggle off via set_verbosity('full') when you need raw byte-exact output. Lossless: zero failures, zero inflations across all 79 audit calls.
| detection vector | obscura | fantoma | sb-mcp | umbra |
|---|---|---|---|---|
| canvas / audio / WebGL fp | ✓ | partial | ✗ | ✓ (per-session noise, deterministic w/in session) |
navigator.webdriver |
✓ | ✓ | ✓ | ✓ |
cdc_* / _phantom / _selenium |
✗ | n/a | ✓ (nodriver) | ✓ delete-only (no in operator tell) |
event.isTrusted |
✗ | ✓ (no synth events) | ✗ | ✓ (CDP Input.dispatch* only) |
| mouse / scroll behavioral fp | n/a | ✓ | ✗ | ✓ (ARIA driver default) |
| keystroke timing fp | n/a | ✓ key-pair | ✗ flat 50ms | ✓ key-pair + log-normal jitter |
| Cloudflare turnstile (passive) | ✗ | partial | ✓ | ✓ (real Chrome) |
| TLS / JA3 / JA4 | ✗ | ✗ | ✓ (real Chrome) | ✓ + tls_fetch for raw HTTP |
| WebGL real GPU in headless | ✗ no GL | ✗ | ✗ SwiftShader | ✓ ANGLE Vulkan |
WebRTC outgoing SDP host |
partial | ✗ | ✗ | ✓ (mDNS-aware filter, real-Chrome behavior) |
| UA-CH version mismatch | ✗ | ✗ | ✗ | ✓ (dynamic Chrome version + setUserAgentOverride) |
| iframe + shadow DOM piercing | ✗ | ✓ | ✗ | ✓ |
| tracker/fp-script blocking | ✓ (3520) | ✗ | ✗ | ✓ (3520 + dynamic hooks) |
| session warming (cookie age) | ✗ | ✗ | ✗ | ✓ (4 profiles) |
| live human handoff | ✗ | ✗ | ✗ | ✓ (cloudflared tunnel) |
| proxy auth (CDP, any provider) | ✗ | ✗ | ✗ | ✓ + 5-strategy rotation pool |
| MCP tool surface | ✗ | ✗ | ✓ (95 narrow) | ✓ (77 broad) |
| prompt-injection signaling | ✗ | ✗ | ✗ | ✓ (_untrusted: true on all extraction) |
nodriver's CDP parser hardcodes Chrome protocol field names — when Chrome changes the schema between releases, the parser KeyErrors. Worse, the listener task dies on the unhandled raise → every subsequent CDP call on that tab hangs forever (no awaiter ever wakes up).
umbra ships three monkey-patches in umbra/nodriver_patch.py to make this class of bug impossible:
Transaction.__call__— every parser exception becomesfuture.set_exception(...)so the awaiter gets a real error, never a hang.Connection._listener— wraps the per-message dispatch so a single bad parse can't kill the listener task; future calls keep working.Cookie.from_json— tolerant of Chrome 146+ droppingsameParty(matches the pattern already used inCookieParam.from_json; upstream inconsistency).
Patches are idempotent (per-class flag + module-level short-circuit, safe to call N times) and partial-failure tolerant (each patch runs in its own try/except — one failing doesn't block the others). Applied automatically at umbra.browser import — zero config.
┌────────────────────────────────────────────┐
│ FastMCP server (umbra.server, 77 tools) │
│ + _compact() minification │
│ + _untrusted prompt-injection signaling │
│ + cross-call dedup ledger │
└────────────────────────────────────────────┘
│
┌────────────┬──────────────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ Drivers │ │ Stealth │ │ Proxy │ │ Misc │
│ multi │ │ ARIA │ │ payload │ │ pool │ │ session │
│ inst. │ │ CDP │ │ 3520 list │ │ CDP auth│ │ handoff │
└────┬────┘ │ humanizer│ │ detection │ │ rotation│ │ tls │
│ │ intercept│ └─────┬──────┘ └────┬─────┘ └──────────┘
│ └────┬─────┘ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│nodriver (real Chrome via CDP) + Page.addScriptToEvaluateOnNewDocument│
│ --headless=new + --use-angle=vulkan + dynamic UA-CH version pinning │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ real Chrome │
│ 146.0.7680.x │
└─────────────────┘
pip install -e ".[test]"
pytest -m e2e -v -s # full e2e
.venv/bin/python tests/test_token_audit.py # token efficiency audit (79 calls)
UMBRA_PROXY_LIST=/path/to/proxies.txt \ # opt-in: also exercise proxy pool
.venv/bin/python tests/test_token_audit.pyCovers bot.sannysoft.com + creepjs + UA-CH consistency + automation-tell checks + (when enabled) end-to-end proxy pool spawn/auth/rotation. Catches drift if Chrome / nodriver update breaks something.
- Submit to Smithery.ai registry — add
smithery.yaml+ tag a release. Auto-indexes for Claude Desktop / Cursor / Cline users. - Add
.claude-plugin/plugin.jsonfor Claude Code's plugin marketplace. - Submit to Anthropic's official marketplace via
claude.ai/settings/plugins/submit.
- Proxy pool rotation — shipped.
ProxyPoolw/ 5 rotation strategies, rolling health, geo + tag filters, sticky sessions, multi-format loaders (URL,host:port:user:pass, sticky-session gateway, JSON, CSV). CDPFetch.authRequiredhandler so creds work on any provider despite Chrome's flag stripping. 7 MCP tools. - Full request interception graph — shipped as
route_*+har_*(see recipes section). - Battle-test the ARIA tree on edge cases — fantoma-derived snapshot covers the 95% case (forms, lists, dialogs, nav) but real-world weirdness still exposes gaps: shadow-DOM-inside-iframe-inside-shadow-DOM, custom elements w/ delegated focus,
<canvas>-rendered "trees" (Figma/Notion), virtual-scroll lists where ARIA indexes shift mid-snapshot,aria-ownscross-references, RTL/i18n role inflections. Need a regression corpus (gmail, github, notion, figma, linear, jira, gov forms) + property-based tests. - Network API ergonomics — current
route_add(...)is declarative; Playwright'sroute(pattern, async (route, request) => {...})is callback-based. Addroute_handler(tab_id, pattern, js_handler_src)that lets the caller register a JS expression evaluated per paused request — returns{action: 'fulfill'|'continue'|...}per-call. Tradeoffs: sandbox the JS, network round-trip per request (slow), but unbeatable for "fulfill only if request body contains X" / "rewrite based on prior response" / dynamic decisions. - HAR tooling polish — current HAR record/replay is HAR-1.2 byte-exact +
looseURL-only fallback. Add per-entry matchers (matchUrl(regex),matchPostData(json_path),matchHeaders(...)for query-drift / session-token tolerance), body morphing (updateContent(transform)to mutate a recorded body before serving), strict vs fallback modes, HAR sanitization (strip Authorization/Cookie/Set-Cookie/PII before commit). Unlocks committing HAR fixtures to test repos without leaking secrets. - Per-browser exit-node selection via Tailscale — userspace
tailscaledper-browser w/ distinct exit nodes for self-hosted residential proxy farms (alternative to paid providers).
MIT + Attribution Requirement. Free for any use (commercial, research, hobby) — but if you ship it in a product or publish research using it, please credit:
Powered by [umbra](https://github.com/GabriWar/umbra) by Gabriel Duarte Guerra.(in your README, docs, about page, or paper acknowledgements — anywhere a human reading your project can see it).
Third-party attributions in LICENSE:
stealth/payload.jspatterns from h4ckf0r0day/obscura @536072b(Apache-2.0)stealth/tracker_domains.txtfrom obscura (Peter Lowe ad/tracker host file)driver/aria.py+humanizer.pypatterns from Huzy85/fantoma @86f20eb(MIT)- MCP tool surface convention from vibheksoni/stealth-browser-mcp @
def424d(MIT)
