umbra
Provides live remote-view of the browser via Cloudflare Quick Tunnels, enabling human handoff over the internet.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@umbra Navigate to example.com and extract the page title"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
umbra
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.
⚡ at a glance
umbra | |
bot.sannysoft.com | 31 / 31 ✓ (perfect) |
creepjs | 0 % (matches vanilla Chrome) |
creepjs | 0 % |
headless | real GPU via |
CDP automation tells stripped |
|
TLS / JA3 / JA4 | real Chrome stack + optional |
WebRTC | mDNS-aware SDP filter (real-Chrome behavior, no LAN IP leak) |
MCP tools | 77 — broad primitives + |
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 |
CDP schema drift | resilient — survives Chrome field churn (e.g. dropped |
Related MCP server: zendriver-mcp
🚀 quick start
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 |
|
|
|
|
|
|
|
| optional Playwright backend |
|
| pytest + asyncio for regression suite |
|
| everything above |
|
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/latest🤖 MCP setup
Wire 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.
🌐 HTTP API mode
stdio is for one MCP client per process. For remote agents, n8n, OpenAI function-calling, plain curl, or anything that isn't an MCP client — run umbra as an HTTPS server with API-key auth.
# local HTTPS (self-signed cert auto-generated + cached in ~/.cache/umbra/tls/)
UMBRA_API_KEYS="$(openssl rand -hex 32)" \
uv run umbra-server --transport http --host 127.0.0.1 --port 8765 --tls-self-signed
# prod TLS (use a real cert from caddy/nginx/letsencrypt or pass directly)
UMBRA_API_KEYS=key1,key2 \
uv run umbra-server --transport http --host 0.0.0.0 --port 443 \
--tls-cert /etc/ssl/umbra.crt --tls-key /etc/ssl/umbra.keyEndpoints (all gated by X-API-Key: <key> or Authorization: Bearer <key>, except /healthz):
route | method | purpose |
| GET | liveness, no auth |
| GET | list all 84 tools + JSON schemas |
| POST | call tool, body = |
| POST | generic dispatch, body = |
| POST | native streamable-http MCP for proper MCP clients |
# discover tools
curl -k -H "x-api-key: $KEY" https://localhost:8765/api/tools | jq '[.tools[].name]'
# call a tool
curl -k -H "x-api-key: $KEY" -X POST https://localhost:8765/api/tools/spawn \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'server flags
--transport stdio|sse|http stdio = MCP only (default), http = REST + MCP + auth
--host 127.0.0.1 bind address (use 0.0.0.0 for LAN)
--port 8765
--path /mcp native MCP mount path
--api-key KEY repeatable; or set UMBRA_API_KEYS=k1,k2
--no-auth disable auth (dev only — bearer leak hazard)
--tls-cert PATH --tls-key PATH enable HTTPS with your cert
--tls-self-signed auto-generate + cache a self-signed cert
--idle-timeout 1800 reap tabs idle ≥ this many seconds (0 disables GC)
--gc-interval 60 how often the idle GC runs
--no-orphan-sweep skip startup chrome cleanup
-v verbose logslifecycle hygiene
idle GC — tabs not touched in
--idle-timeoutget auto-closed. Browsers with no remaining tabs follow. Manual trigger: call thecleanup_staletool withidle_seconds.startup orphan sweep — chrome procs from prior umbra-server crashes (matched by
--user-data-dir=/tmp/uc_*w/ a parent pid that isn't us) are SIGTERM'd + their profile dirsrmtreed. Skip with--no-orphan-sweep.graceful shutdown — SIGTERM/SIGINT closes every browser + sweeps profile dirs before exit.
TODOs
multi-tenancy. Right now all API keys share one global
_state— every key sees every browser/tab/proxy/session by id. For multiple users with isolation, we need to:tag every browser/tab/route/hook/handoff/session entry with the calling key (
owner_key_id).scope
list_browsers/list_tabs/proxy_pool_list/session_listto the caller's namespace.reject cross-tenant
tab_id/browser_idreferences with 403.per-key proxy pools and quota (max tabs, max bandwidth, idle-timeout override).
audit log keyed by
owner_key_id.
For now: one server = one trust domain. Run separate
umbra-serverprocesses on different ports if you need real isolation.mTLS option (client-cert auth) instead of bearer keys.
per-key rate limiting + quotas.
CloakBrowser integration ✓ shipped — patched chromium is now the default. See § CloakBrowser. Deferred follow-ons:
humanize layer port (bezier mouse curves w/ aim points, per-char typing w/ typos+self-correct, scroll accel/decel). Opt-in on
aria_click/aria_type/click_at/scroll/drag. Works on stock chromium too.geoip-from-proxy → lookup proxy exit IP, derive timezone+locale, apply via CDP
Emulation.setTimezoneOverride+setLocaleOverride. Opt-ingeoip: trueon spawn.deterministic fingerprint seed —
spawn(fingerprint_seed='abc')→ seedable PRNG forrotate_fingerprintreproducibility.storage quota normalization via CDP
Storage.overrideQuotaForOrigin.WebRTC IP override via CDP — covered by cloak's native patch when cloak is active; CDP fallback only useful on stock.
🎯 recipes
batch ⭐ flagship — N tools in one MCP round-trip
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).
proxy_pool_* — multi-provider rotation, any provider, any format
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|stickyrotation 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? |
| none | ✓ |
| basic | ✓ (via CDP) |
| none | ✓ |
| 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.
route through Tor (free, multi-exit, no provider)
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=torproxy_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.
handoff_start — captcha / 2FA wall? hand the wheel back
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 automationBuilt 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 — page → clean markdown (firecrawl-style)
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 / session_load — log in once, skip auth forever
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 — skip the DOM entirely for JSON APIs
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.
multi-browser orchestration
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}]request interception graph (route_* + HAR record/replay)
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.
🧰 the 77 tools
┌─ 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)🐍 use as a python library
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"))🔬 token efficiency
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 ( | 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.
how the savings happen
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/networkconstant-column hoist — shared values factored to
_constant: {col: val}word-boundary string truncation w/ explicit
...[+Nc, raise max_str to see full]markerlist 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>tagscross-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.
🥷 CloakBrowser (default chromium)
spawn defaults to chromium="cloak" — when the
CloakBrowser patched chromium
build is installed under ~/.umbra/cloak/<tag>/, every browser uses it.
Cloak ships 49-57 C++ source patches against canvas, WebGL, audio, font,
GPU, WebRTC, screen, and timing fingerprint surfaces. Native patches beat
JS shims because detectors check the underlying API surface, not just
property values — so umbra auto-downgrades its own stealth_mode to
minimal (automation-tell cleanup only) when cloak is active, to avoid
double-fingerprinting.
setup (one-time)
Cloak is not bundled (license: free use, no redistribute) and is not silently auto-downloaded. Install once:
python -m umbra --setup # download + verify + cache
python -m umbra --setup --force # re-download
python -m umbra --setup --tag <t> # pin a specific release
python -m umbra --status # show install state (no network)
python -m umbra --uninstall # wipe ~/.umbra/cloak/First spawn() with cloak missing on an interactive TTY prompts to
install. Non-TTY (MCP/HTTP server, CI, scripts) silently falls back to
stock chromium with a one-line warning — spawn never hangs on input.
A .declined marker is written if the user says no, suppressing future
prompts; delete ~/.umbra/cloak/.declined to re-enable.
MCP tools: cloak_status(), cloak_install(force=False, tag=None).
opt-out
# per-spawn:
spawn(chromium="stock") # MCP / python
# globally:
export UMBRA_NO_CLOAK=1 # kill-switch — every spawn uses stock
# or point at your own build:
export UMBRA_CLOAK_BINARY=/path/to/chromeplatforms
platform | cloak build | umbra behavior |
linux x64 | ✓ | auto |
linux arm64 | ✓ | auto |
windows x64 | ✓ | auto |
darwin arm64 | ✓ (separate tag) | auto |
darwin x64 | ✗ | fall back to stock + warn |
windows arm64 | ✗ | fall back to stock + warn |
GitHub anon API rate limit is 60/h — set GITHUB_TOKEN to lift it.
Manifest cached 24h.
measured impact (2026-05, linux-x64, headless)
Public aggregate detectors do not visibly shift with cloak — they mostly
probe the surfaces JS shims already cover (navigator.webdriver, basic
canvas hash, automation flags). The C++ patches harden deeper surfaces those
aggregates don't score:
signal | stock + JS shim | + cloak | note |
creepjs headless % | ≤5 | ≤5 | unchanged — aggregate baseline |
creepjs stealth % | ≤5 | ≤5 | unchanged |
sannysoft pass | 30+/31 | 29/31 | WebGL Vendor/Renderer now report "no webgl context" — cloak strips the uniquely-identifying GPU strings on purpose (intentional surface cut, not a regression) |
automation tells | 0 | 0 | unchanged |
UA-CH brands.Chromium version | matches UA | matches UA | ✓ fixed — cloak's internal UA-CH stub clobbered our |
Where cloak actually helps (not aggregate-scored by the public detectors):
canvas/audio per-pixel noise patterns, font enumeration consistency,
exact GPU info strings, screen geometry edge cases, WebRTC IP leak at the
C++ level, timing-API quantization. If your adversary fingerprints those
specifically (FingerprintJS Pro, sift, akamai bot manager), cloak shifts
the needle in ways sannysoft/creepjs summaries won't show.
license note (read this)
CloakBrowser's binary license permits free personal and commercial use
but forbids redistribution. umbra never bundles the binary — it always
pulls from upstream releases on your machine. Don't repackage ~/.umbra/cloak
into your own product or container image you ship to third parties; the
auto-download flow exists exactly so each user fetches their own copy. See
BINARY-LICENSE.md
upstream for the exact terms.
🛡️ stealth coverage matrix
detection vector | obscura | fantoma | sb-mcp | umbra |
canvas / audio / WebGL fp | ✓ | partial | ✗ | ✓ (per-session noise, deterministic w/in session) |
| ✓ | ✓ | ✓ | ✓ |
| ✗ | n/a | ✓ (nodriver) | ✓ delete-only (no |
| ✗ | ✓ (no synth events) | ✗ | ✓ (CDP |
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) | ✓ + |
WebGL real GPU in headless | ✗ no GL | ✗ | ✗ SwiftShader | ✓ ANGLE Vulkan |
WebRTC outgoing SDP | partial | ✗ | ✗ | ✓ (mDNS-aware filter, real-Chrome behavior) |
UA-CH version mismatch | ✗ | ✗ | ✗ | ✓ (dynamic Chrome version + |
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 | ✗ | ✗ | ✗ | ✓ ( |
🩹 CDP schema resilience
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.
🏗️ architecture
┌────────────────────────────────────────────┐
│ 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 │
└─────────────────┘🧪 regression tests
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.
🛠️ roadmap
distribution
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.
features
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).
📜 license
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)
Available Tools
87 toolsaria_clickAria ClickA
Click ARIA element by idx (zero mouse, semantic). Always prefer over click_at.
Get idx from aria_snapshot or find_by_text.
Ex: aria_click('t0', 3) → {"ok":true}
Inputs: idx (required), tab_id (required). Returns: object
aria_snapshotAria SnapshotA
ARIA tree of interactive elements w/ idx for click/type. ~50ms. Call BEFORE
first interaction + after any DOM change (idx invalidates). Repeats group as
[12-77] cycle×13: link('A'),link('B') (lossless — click any idx in range).
Ex: aria_snapshot('t0') → {"tree":"[0] button "Sign in"\n[1] textbox "email"\n...","count":12}
Inputs: tab_id (required), max_items, force_refresh. Returns: object
aria_typeAria TypeA
Type into ARIA input by idx. humanize=True (default)=log-normal keystroke +
pair-classification (~80-150ms/char, defeats cadence detectors). False=instant
CDP keys (detectable). For multi-field use fill_form; for big paste use paste_text.
Ex: aria_type('t0', 1, 'me@example.com') → {"ok":true}
Inputs: idx (required), text (required), tab_id (required), clear, +1 more. Returns: object
backBackB
History.back(). When: undo a nav step (e.g. clicked wrong link, retry from prev page).
Inputs: tab_id (required). Returns: object
batchBatchC
Run N tools in one round-trip, serial. Saves MCP framing + composes w/ dedup.
Inputs: calls (required), stop_on_error. Returns: object
block_urlsBlock UrlsB
Block URL patterns (wildcards). [] to clear. Speed up + skip trackers/heavy assets.
Ex: block_urls('t0', ['googletag', '*.gif']) → {"blocked_patterns":2}
Inputs: tab_id (required), patterns (required). Returns: object
check_detectionCheck DetectionA
Stealth self-test: sannysoft (~7s) + creepjs if deep (~40s). Before sensitive nav.
Ex: check_detection() → {"sannysoft":{"passed":31,"failed":0,...},"verdict":"good"} Ex: check_detection(deep=True) → {...,"creepjs":{"detected_headless":0,"stealth":0},"verdict":"good"}
Inputs: deep. Returns: object
cleanup_staleCleanup StaleA
Manually reap tabs idle ≥ idle_seconds + browsers with no remaining tabs.
Ex: cleanup_stale(idle_seconds=300) → {"closed_tabs":["t2"],"closed_browsers":[]}
Inputs: idle_seconds. Returns: object
clear_cookiesClear CookiesA
Wipe ALL cookies. Returns count cleared.
When: testing logged-out flow, simulating a fresh visitor without spawning a new browser, or recovering from a stuck auth state. Note: also wipes OAuth/SSO state — re-login required after.
Inputs: tab_id (required). Returns: object
clear_logsClear LogsB
Wipe console+network buffers. For clean post-action trace.
Ex: clear_logs('t0') → {"cleared":true}
Inputs: tab_id (required). Returns: object
click_atClick AtA
Pixel click for captcha/canvas/PDF where ARIA misses. Coords from screenshot/ inspect_element rect. Default to aria_click — pixel is fragile.
Ex: click_at('t0', 320, 480) → {"ok":true}
Inputs: x (required), y (required), tab_id (required), button. Returns: object
cloak_installCloak InstallA
Download + verify the latest (or pinned) CloakBrowser chromium build.
Idempotent — already-cached installs return their path. Pass force=True
to re-download, or tag='<release-tag>' to pin a specific version. Runs
sha256 verification before extraction. On unsupported platforms returns
{ok:false, error:...} instead of raising.
Ex: cloak_install() → {"ok":true,"path":"/home/u/.umbra/cloak/.../chrome","tag":"..."}
Inputs: tag, force. Returns: object
cloak_statusCloak StatusA
Report CloakBrowser install state. No network.
Returns {platform, supported, kill_switch, env_binary, cache_root, installed[]}.
installed lists cached release tags + entry-point paths.
Inputs: no named parameters. Returns: object
clone_elementClone ElementA
Pixel-approx clone: DOM+CSS+asset URLs+renderable doc. ~80% fidelity. For UI
lifting / bug repro / training pairs. Use when you need the RENDER not just text.
Ex: clone_element('t0', '.product-card') → {"_untrusted":true,"html":"...","css":"...","doc":"","assets":[...]}
Inputs: tab_id (required), selector (required), max_doc_chars. Returns: object
closeCloseA
Close a tab. Browser stays for other tabs. Free RAM after one-off tasks.
Ex: close('t0') → {"closed":true}
Inputs: tab_id (required). Returns: object
close_browserClose BrowserB
Close all tabs in a browser + stop the Chrome process. Use after dedicated isolated session.
Ex: close_browser('alice') → {"closed_browser":"alice","closed_tabs":["t3","t4"]}
Inputs: browser_id (required). Returns: object
current_stateCurrent StateA
Cheap orientation: URL+title+h1/h2+forms+interactive_count. Lighter than aria_snapshot.
Good first move after navigate. Dedups identical state.
Ex: current_state('t0') → {"url":"...","title":"...","h1_h2":[...],"forms":[...],"interactive_count":42}
Inputs: tab_id (required), force_refresh. Returns: object
dom_queryDom QueryB
querySelectorAll → element list w/ attrs (href/data-*/src/rect). pierce walks shadow + iframes. Columnar-compressed.
Ex: dom_query('t0', 'a.btn', 5) → {"_untrusted":true,"elements":{"_columnar":true,"keys":["tag","href","rect"],...}}
Inputs: tab_id (required), selector (required), pierce, max_results, +1 more. Returns: object
dragDragA
Humanized bezier-jitter mouse drag. Slider captchas, drag-drop UI, range sliders.
Ex: drag('t0', 100, 200, 400, 200) → {"ok":true}
Inputs: x1 (required), x2 (required), y1 (required), y2 (required), +2 more. Returns: object
dynamic_hookDynamic HookA
Network rule (legacy thin wrapper around route_add). action='block'/'fulfill'/'continue'.
url_pattern=substring match. Tab-scoped. For richer matching/modify/HAR use route_add.
Ex: dynamic_hook('t0', '/api/items', 'fulfill', new_status=200, new_body='{"items":[]}')
Inputs: action (required), tab_id (required), url_pattern (required), new_body, +2 more. Returns: object
evaluateEvaluateA
Run JS in page (last-resort escape hatch). Prefer dom_query/extract_text/aria_*.
Ex: evaluate('t0', 'document.title') → {"_untrusted":true,"result":"Hacker News"} Ex: evaluate('t0', 'fetch("/api/me").then(r=>r.json())', await_promise=True)
Inputs: tab_id (required), expression (required), max_chars, await_promise. Returns: object
extract_linksExtract LinksB
Visible links, columnar + URL footnoting (_refs table dedups host across rows;
full URL = _refs[ref] + path). same_origin=True to skip outbound.
Ex: extract_links('t0', same_origin=True) → {"_untrusted":true,"links":[...],"_refs":{"1":"https://x.com"}} where rows=[text,ref,path]
Inputs: tab_id (required), max_links, same_origin, force_refresh. Returns: object
extract_markdownExtract MarkdownA
Page → clean MD (Readability + markdownify). DEFAULT for "read this page". Keeps
headings/lists/code/links. content_only skips nav/footer/ads (fallback to body).
Needs pip install umbra-browser[markdown].
Ex: extract_markdown('t0') → {"_untrusted":true,"markdown":"# Title\n\nlink...","title":"...","source_html_len":4521} Ex: extract_markdown('t0', selector='.main-content', max_chars=50000)
Inputs: tab_id (required), selector, max_chars, content_only, +1 more. Returns: object
extract_textExtract TextA
innerText of selector. pierce walks shadow+same-origin iframes. denoise (default)
strips zero-width chars + collapses 3+ blank lines lossless. For full articles
prefer extract_markdown.
Ex: extract_text('t0','.article',2000) → {"_untrusted":true,"text":"Body…"} Ex: extract_text('t0','body',8000,denoise=False) → byte-exact
Inputs: tab_id (required), pierce, denoise, selector, +2 more. Returns: object
fill_formFill FormA
Fill N inputs from {label:value} in one call. Labels fuzzy-match placeholder/ aria-label/. Beats N aria_type round-trips.
Ex: fill_form('t0', {'email':'a@b.c','password':'hunter2'}) → {"filled":["email","password"],"missed":[]}
Inputs: fields (required), tab_id (required), clear. Returns: object
find_by_textFind By TextA
Fuzzy "the button/link that says X" → ARIA idx, in one call. Skip aria_snapshot. role_hint='button'|'link'|'textbox' to disambiguate.
Ex: find_by_text('t0', 'Sign in', role_hint='button') → {"idx":3,"found":true}
Inputs: text (required), tab_id (required), role_hint, force_refresh. Returns: object
forwardForwardB
History.forward(). When: redo a nav step after back. Rare — usually you just re-navigate.
Inputs: tab_id (required). Returns: object
get_console_logsGet Console LogsA
Buffered console output since tab spawn. First-line check when page misbehaves.
Ex: get_console_logs('t0') → {"_untrusted":true,"logs":[{"level":"error","text":"..."}]}
Inputs: tab_id (required), max_n, force_refresh. Returns: object
get_cookiesGet CookiesB
All cookies in tab context. For session debug, audit tracking.
Ex: get_cookies('t0') → {"_untrusted":true,"cookies":[{"name":"...","value":"...","domain":"..."}]}
Inputs: tab_id (required). Returns: object
get_network_requestsGet Network RequestsB
Recent requests by the page. Discover hidden APIs (then tls_fetch to skip DOM).
Pair w/ get_response_body by request_id.
Ex: get_network_requests('t0') → {"_untrusted":true,"requests":[{"id":"...","url":"...","method":"GET","type":"XHR"}]}
Inputs: tab_id (required), max_n, force_refresh. Returns: object
get_response_bodyGet Response BodyA
Fetch a buffered HTTP response body by request_id. _untrusted=true (server-controlled).
When: paired with get_network_requests — you saw a request, now you
want its actual JSON/HTML payload (to extract data the page never
rendered, or debug a 4xx/5xx response).
Inputs: tab_id (required), request_id (required), max_chars. Returns: object
grep_textGrep TextB
Regex-hunt page text. Cheaper than extract_text+scan. Returns [{match,context,line}].
Ex: grep_text('t0', 'API_KEY=\w+') → {"_untrusted":true,"matches":[{"match":"API_KEY=abc","context":"...","line":42}]}
Inputs: tab_id (required), pattern (required), selector, max_matches, +1 more. Returns: object
handoff_startHandoff StartA
Spin up live remote-view URL (192-bit auth token in path). For captcha/2FA/manual steps. tunnel=True (default) → public via cloudflared Quick Tunnel; falls back to 127.0.0.1. Use start/wait PAIR to announce URL before blocking. fps 3 default (8-12 local).
Ex: handoff_start('t0', 'solve recaptcha') → {"url":"https://abc.trycloudflare.com/h-XYZ/","reason":"...","next":"tell user to open URL, then call handoff_wait"}
Inputs: tab_id (required), fps, reason, tunnel. Returns: object
handoff_waitHandoff WaitB
Block until user clicks I'M DONE (or timeout). Pair with handoff_start.
Ex: handoff_wait('t0', 120) → {"completed":true,"current_url":"...","current_title":"..."}
Inputs: tab_id (required), timeout_s. Returns: object
har_clearHar ClearB
Drop buffered HAR entries (recording=True, default) and/or the replay corpus.
Ex: har_clear('t0') → {"cleared_entries":42} Ex: har_clear('t0', recording=False, replay=True) → {"cleared_replay":120}
Inputs: tab_id (required), replay, recording. Returns: object
har_dumpHar DumpA
Return recorded HAR. With path, write full JSON to disk (no truncation, no
_compact) and return summary. Without path, returns the HAR inline through
_compact w/ tunable max_str (per body) and max_list / max_entries
(entry count).
For full-fidelity inline dump, raise both caps OR write to disk. Disk write is byte-exact.
Ex: har_dump('t0') → {"_untrusted":true,"har":{...},"entries":42} Ex: har_dump('t0', path='/tmp/session.har', clear=True) → {"wrote":"/tmp/session.har","bytes":98231,"entries":42,"cleared":true} Ex: har_dump('t0', max_str=50000) # don't truncate JSON bodies up to 50KB
Inputs: tab_id (required), path, clear, max_str, +2 more. Returns: object
har_record_startHar Record StartA
Begin buffering every paused req+resp into a HAR-1.2 archive (tab-scoped). Body capture is best-effort (skipped for fulfilled/blocked requests). Forces response-stage interception to capture status+body.
Ex: har_record_start('t0') → {"recording":true,"existing_entries":0}
Inputs: tab_id (required). Returns: object
har_record_stopHar Record StopA
Stop recording (keeps buffered entries — call har_dump to retrieve, har_clear to drop).
Ex: har_record_stop('t0') → {"recording":false,"entries":42}
Inputs: tab_id (required). Returns: object
har_replay_loadHar Replay LoadA
Load a HAR file (path) or raw JSON string (har_json) into the replay corpus.
Subsequent matching requests (method+url, or url-only when loose=True) are fulfilled
from the HAR entry instead of hitting the network. Replay is fallthrough — it only
fires on requests no route_add rule already matched.
Ex: har_replay_load('t0', path='/tmp/session.har') → {"loaded":42,"loose":false} Ex: har_replay_load('t0', har_json='{"log":{"entries":[...]}}', loose=True) → {"loaded":N,"loose":true,"total_loaded":N}
Inputs: tab_id (required), path, loose, har_json, +1 more. Returns: object
hoverHoverB
Hover at (x,y) — reveals :hover menus, tooltips, hover-to-show buttons.
Ex: hover('t0', 200, 100) → {"ok":true}
Inputs: x (required), y (required), tab_id (required). Returns: object
inject_cssInject CssB
Inject , persists page lifetime. Hide cookie banner/paywall/modal, highlight for screenshot.
Ex: inject_css('t0', '.cookie-banner{display:none!important}') → {"injected":true}
Inputs: css (required), tab_id (required). Returns: object
inspect_elementInspect ElementB
Attrs + computed style + rect of one element. For debug, click_at coords, audit.
Ex: inspect_element('t0', '#submit') → {"_untrusted":true,"tag":"button","attrs":{...},"rect":{x,y,w,h},"computed":{...}}
Inputs: tab_id (required), selector (required), force_refresh. Returns: object
kill_allKill AllA
Nuke ALL browsers/tabs/handoffs/hooks. Last resort — prefer close/close_browser.
Ex: kill_all() → {"browsers":3,"tabs":7,"handoffs":1}
Inputs: no named parameters. Returns: object
list_browsersList BrowsersA
List browser instances + tab counts. Audit parallel sessions.
Ex: list_browsers() → {"browsers":[{"browser_id":"default","tab_count":2,"tab_ids":["t0","t1"]}]}
Inputs: no named parameters. Returns: object
list_tabsList TabsA
List open tabs w/ URL+title+browser_id. Cheap.
Ex: list_tabs() → {"tabs":[{"id":"t0","browser":"default","url":"https://...","title":"..."}]}
Inputs: no named parameters. Returns: object
memory_metricsMemory MetricsB
Performance counters: JS heap size, DOM nodes, layout count, etc.
When: profiling a slow page, hunting a memory leak across a long
automation run, or deciding whether to close a tab vs reuse it.
Inputs: tab_id (required), force_refresh. Returns: object
paste_textPaste TextA
Instant paste via CDP (no humanize, ~1000x faster). For big blobs on trusted pages. Skip on hostile sites (cadence-detectable).
Ex: paste_text('t0', long_blob) → {"ok":true,"len":1234}
Inputs: text (required), tab_id (required). Returns: object
press_keyPress KeyA
Press key (Enter/Esc/Tab) + optional modifiers ['ctrl','shift','alt','meta']. Form submit, dismiss modal, Ctrl+K palette.
Ex: press_key('t0', 'Enter') / press_key('t0', 'k', modifiers=['ctrl'])
Inputs: key (required), tab_id (required), modifiers. Returns: object
proxy_pool_addProxy Pool AddB
Add ONE proxy to the pool.
url may include inline auth (http://user:pass@host:port); explicit
username/password override. session_template is a provider-specific
sticky-session pattern, e.g. 'user-session-{sid}-country-{cc}'.
Ex: proxy_pool_add('http://gw.proxy.com:8080', username='u123', password='p', country='US')
Inputs: url (required), tags, country, password, +2 more. Returns: object
proxy_pool_clearProxy Pool ClearA
Drop ALL entries. Stickies cleared. Rotation strategy preserved.
Ex: proxy_pool_clear() → {"cleared":12}
Inputs: no named parameters. Returns: object
proxy_pool_exportProxy Pool ExportA
Dump pool state — round-trippable via proxy_pool_load(format='json'). redact=False (default) emits real creds; redact=True for safe sharing.
Ex: proxy_pool_export() → {"rotation":"...","entries":[{...creds...}]}
Inputs: redact. Returns: object
proxy_pool_health_checkProxy Pool Health CheckA
Probe every entry via tls_fetch-through-proxy + report rolling health.
NB: tls_fetch uses curl_cffi which only honors HTTP/HTTPS proxies, not SOCKS5. SOCKS5 entries get a single connect-test instead.
Ex: proxy_pool_health_check() → {"checked":12,"alive":10,"results":[...]}
Inputs: parallel, test_url, timeout_s. Returns: object
proxy_pool_listProxy Pool ListB
List all entries (creds redacted by default). Columnar.
Ex: proxy_pool_list() → {"rotation":"round_robin","entries":[...]}
Inputs: redact. Returns: object
proxy_pool_loadProxy Pool LoadA
Bulk-load proxies into the pool.
format='lines': one URL per line (http://user:pass@host:port[#country=US,tags=a|b])
format='json': list of {url, username?, password?, country?, tags?, session_template?}
OR full pool dict from proxy_pool_export.
format='csv': header w/ columns url, username, password, country, tags, session_template
data may be inline text OR a file path (auto-detected by existence).
rotation strategies: round_robin (default), random, least_used, best_health, sticky_browser (same browser_id always gets same entry).
Ex: proxy_pool_load(data='http://u:p@gw1:8080\nhttp://u:p@gw2:8080') Ex: proxy_pool_load(data='/path/to/proxies.csv', format='csv')
Inputs: data (required), format, replace, rotation. Returns: object
proxy_pool_removeProxy Pool RemoveA
Remove one entry by id. Use proxy_pool_list to see ids.
Ex: proxy_pool_remove('a1b2c3d4') → {"removed":true,"total":4}
Inputs: entry_id (required). Returns: object
reloadReloadA
Reload page. hard=True bypasses cache (CDN debug, fresh build).
Ex: reload('t0') → {"reloaded":true}
Inputs: tab_id (required), hard. Returns: object
request_user_inputRequest User InputA
One-shot handoff: spin up + block. URL is in return. Use only if you don't need to announce URL early; otherwise prefer handoff_start + handoff_wait pair.
Ex: request_user_input('t0', 'solve captcha', timeout_s=300) → {"url":"...","completed":true,"current_url":"..."}
Inputs: tab_id (required), reason, timeout_s. Returns: object
rotate_fingerprintRotate FingerprintA
Re-seed canvas/audio/WebGL noise mid-session. Needs stealth_mode='full' at spawn.
Ex: rotate_fingerprint('t0') → {"new_seed":2937184832}
Inputs: tab_id (required). Returns: object
route_addRoute AddB
Install rich interception rule. action: block|fulfill|continue|modify|tee|redirect.
MATCH (AND of any provided): url_pattern substring (cheap, default) url_regex re.fullmatch method GET/POST/... resource_type Document/XHR/Fetch/Script/Stylesheet/Image/Font/Media/... header_match {header_lower: regex} — re.search per header status_min/max response-stage filter (forces response-stage interception)
Inputs: action (required), tab_id (required), body, times, +20 more. Returns: object
route_add_manyRoute Add ManyA
Bulk-install rules in one round-trip. Each item is a dict matching route_add kwargs
(must contain at least action). Returns list of installed rule ids.
Inputs: rules (required), tab_id (required). Returns: object
route_block_setRoute Block SetA
Toggle the engine's inherited blocking. trackers: enable/disable the
bundled 3520-domain tracker blocklist (yoyo). resource_types: replace the
blocked resource-type set (e.g. ['Image','Media','Font'] for fast scraping).
Pass nothing to just inspect current state.
Ex: route_block_set('t0', trackers=False) → {"trackers":false,"resource_types":[]} Ex: route_block_set('t0', resource_types=['Image','Font','Media']) Ex: route_block_set('t0') → {"trackers":true,"resource_types":[],"tracker_blocks":42}
Inputs: tab_id (required), trackers, resource_types. Returns: object
route_capturesRoute CapturesA
Return per-rule capture buffer (filled when rule has capture=N).
Each entry: {ts, url, method, status, request_headers, body, body_encoding?, error?}.
Use clear=True to drain after read.
Ex: route_captures('t0','r2') → {"_untrusted":true,"id":"r2","captures":[...]}
Inputs: tab_id (required), rule_id (required), clear, max_str. Returns: object
route_listRoute ListB
List active rules + per-rule hit counts.
Ex: route_list('t0') → {"routes":[{"id":"r0","action":"block","hits":12,...}]}
Inputs: tab_id (required). Returns: object
route_removeRoute RemoveA
Remove one rule (rule_id) or all rules (all=True). Doesn't disable Fetch domain.
Ex: route_remove('t0', 'r2') → {"removed":"r2","active_routes":3} Ex: route_remove('t0', all=True) → {"cleared":5,"active_routes":0}
Inputs: tab_id (required), all, rule_id. Returns: object
route_set_enabledRoute Set EnabledA
Pause or resume a rule without removing it. Preserves hits/captures.
Ex: route_set_enabled('t0','r2',False) → {"id":"r2","enabled":false,"hits":12}
Inputs: tab_id (required), enabled (required), rule_id (required). Returns: object
screenshotScreenshotA
JPEG → base64 (q=65 cheap). For VLM visual reasoning. For text use extract_markdown.
Ex: screenshot('t0', full_page=True) → {"b64":"...","fmt":"jpeg","len":48201}
Inputs: tab_id (required), quality, full_page. Returns: object
screenshot_regionScreenshot RegionA
JPEG of a (x,y,w,h) region. For captcha tiles, isolated VLM crops. Rect from inspect_element.
Ex: screenshot_region('t0', 100, 200, 300, 100) → {"b64":"...","fmt":"jpeg","rect":{x,y,w,h}}
Inputs: h (required), w (required), x (required), y (required), +2 more. Returns: object
scrollScrollA
Scroll by (dx,dy), to_bottom=True, or scrollIntoView selector. Lazy-load / reveal.
Ex: scroll('t0', to_bottom=True) / scroll('t0', selector='#footer') → {"ok":true}
Inputs: tab_id (required), dx, dy, selector, +1 more. Returns: object
select_optionSelect OptionA
Set HTML .value + change/input events. For custom dropdowns use aria_click.
Ex: select_option('t0', '#country', 'BR') → {"ok":true}
Inputs: value (required), tab_id (required), selector (required). Returns: object
session_deleteSession DeleteB
Delete a saved session blob by name.
Ex: session_delete('github-me') → {"deleted":true}
Inputs: name (required). Returns: object
session_listSession ListA
List all saved session blobs (metadata, no passphrase needed).
Ex: session_list() → {"sessions":[{"name":"github-me","domain":"github.com","saved_at":...,"size":1234}]}
Inputs: no named parameters. Returns: object
session_loadSession LoadA
Decrypt + inject saved cookies/localStorage. Skips login. Load BEFORE auth-walled nav.
Ex: session_load('t0', 'github-me', 'hunter2') → {"name":"github-me","cookies":12,"localStorage_keys":4,"origin":"https://github.com"}
Inputs: name (required), tab_id (required), passphrase (required). Returns: object
session_saveSession SaveA
Save cookies+localStorage encrypted (Fernet+PBKDF2-200k). Per-(domain,name). Run AFTER login on target tab. Reuse via session_load.
Ex: session_save('t0', 'github-me', 'hunter2') → {"path":"~/.local/share/umbra/sessions/github.com/github-me.fern"}
Inputs: name (required), tab_id (required), passphrase (required). Returns: object
set_cookiesSet CookiesB
Set cookies (each: name/value/domain required). For ad-hoc auth injection.
Ex: set_cookies('t0', [{'name':'sid','value':'abc','domain':'.x.com','secure':True}]) → {"set":1}
Inputs: tab_id (required), cookies (required). Returns: object
set_extra_headersSet Extra HeadersB
Inject headers into every outgoing request. Auth, A/B flags, tracing.
Ex: set_extra_headers('t0', {'X-Custom':'v', 'Authorization':'Bearer ...'}) → {"set":["X-Custom","Authorization"]}
Inputs: tab_id (required), headers (required). Returns: object
setup_downloadsSetup DownloadsA
Allow + redirect downloads to dir. Call BEFORE click that downloads (else silent
block). Pair w/ wait_for_download.
Ex: setup_downloads('t0', '/tmp/dl') → {"download_dir":"/tmp/dl"}
Inputs: tab_id (required), download_dir (required). Returns: object
set_verbositySet VerbosityA
Toggle response minification. 'compact'=default (drops nones, columnar, trunc). 'full'=raw bytes, no trunc. Flip 'full' for byte-exact ops, then back.
Ex: set_verbosity('full') → {"prev":"compact","now":"full"}
Inputs: level. Returns: object
set_viewportSet ViewportB
Change viewport size mid-session. mobile=True triggers touch events + UA. For mobile UI / breakpoint reveal.
Ex: set_viewport('t0', 375, 667, mobile=True) → {"width":375,"height":667}
Inputs: width (required), height (required), tab_id (required), mobile, +1 more. Returns: object
spawnSpawnA
Open stealth tab. browser_id='alice'=isolated Chrome (own cookies/identity, ~1.5s boot).
For same-identity new pages prefer navigate (cheaper). stealth_mode='minimal'
(default)=mimics vanilla Chrome, 'full'=adds anti-tracking noise.
Proxy: pass proxy='http://user:pass@host:port' for one-off, OR set
use_proxy_pool=True (after proxy_pool_load) to pick from the pool —
optionally filter by proxy_country='US' / proxy_tag='residential'.
Pool wins over proxy if both set. Auth wired via CDP (Chrome flag
strips inline auth).
Ex: spawn('https://news.ycombinator.com') → {"tab_id":"t0","browser_id":"default","url":"..."} Ex: spawn('about:blank', browser_id='alice', proxy='http://1.2.3.4:8080') Ex: spawn(use_proxy_pool=True, proxy_country='US', browser_id='scraper-1')
chromium: 'cloak' (default) auto-downloads CloakBrowser's patched chromium (C++ fingerprint patches — beats JS shims). 'stock'=system chromium. Pass an absolute path to use a custom binary. Env: UMBRA_NO_CLOAK=1 forces stock; UMBRA_CLOAK_BINARY= uses that as cloak. See cloak_status.
Inputs: url, proxy, chromium, headless, +8 more. Returns: object
switch_tabSwitch TabA
Focus a tab. Needed for visual capture; other tools work on background tabs.
Ex: switch_tab('t1') → {"focused":"t1"}
Inputs: tab_id (required). Returns: object
tls_fetchTls FetchA
Raw HTTP w/ Chrome JA3+JA4. Skip DOM when you only need JSON/HTML. ~50ms vs ~500ms.
Skip on client-side-rendered SPAs. Needs pip install umbra-browser[tls].
Ex: tls_fetch('https://api.example.com/users') → {"status":200,"headers":{...},"body":"{"users":[...]}"}
Inputs: url (required), body, method, headers, +1 more. Returns: object
upload_fileUpload FileA
Set .files (skip OS picker). Paths absolute on server FS.
Security: if UMBRA_UPLOAD_ROOT env is set (colon-sep allowlist of dirs), every path must resolve under one of those roots. Unset → no restriction (legacy default; set the env in any agent-driven deployment). Ex: upload_file('t0', 'input[type=file]', ['/abs/img.png']) → {"ok":true}
Inputs: paths (required), tab_id (required), selector (required). Returns: object
wait_forWait ForA
Wait — one of: selector / url_contains / network_idle_ms. For text use wait_for_text.
Ex (DOM): wait_for('t0', selector='.submit-btn') → {"ok":true,"why":"selector_found"} Ex (URL): wait_for('t0', url_contains='/dashboard') → {"ok":true,"why":"url_match","url":"..."} Ex (network): wait_for('t0', network_idle_ms=500) → {"ok":true,"why":"network_idle"}
Inputs: tab_id (required), selector, timeout_s, url_contains, +1 more. Returns: object
wait_for_downloadWait For DownloadB
Wait for new file in dir, return path once size stable. Pair w/ setup_downloads.
Ex: wait_for_download('t0', '/tmp/dl', 30) → {"path":"/tmp/dl/file.pdf","size_bytes":102400,"name":"file.pdf"}
Inputs: tab_id (required), download_dir (required), min_bytes, timeout_s. Returns: object
wait_for_textWait For TextB
Wait until text appears in selector (default body). For AJAX/SPA text signals.
Ex: wait_for_text('t0', 'Welcome back', timeout_s=10) → {"ok":true,"found_in_ms":1840}
Inputs: text (required), tab_id (required), selector, timeout_s, +1 more. Returns: object
warm_sessionWarm SessionA
Pre-warm w/ plausible browsing (profile: general/shopping/news/minimal). Before bot-detection-heavy targets so they don't see 0-history → checkout. ~10-30s.
Ex: warm_session('shopping') → {"warmed":true,"profile":"shopping"}
Inputs: profile, max_sites. Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 87 tools
Most tools have distinct purposes, but there are notable overlaps: aria_click vs click_at, aria_type vs paste_text vs fill_form, extract_text vs extract_markdown vs dom_query vs grep_text, dynamic_hook vs route_add, handoff_start+handoff_wait vs request_user_input, and close vs close_browser vs kill_all vs cleanup_stale. The descriptions usually state which tool to prefer, but an agent still has to internalize many nuanced precedence rules.
The surface is overwhelmingly snake_case with recognizable domain prefixes (proxy_pool_*, route_*, har_*, session_*, aria_*, extract_*, wait_for_*). Minor deviations exist, such as bare verb names (spawn, close, back, forward), noun-ish names (current_state, cloak_status), and the legacy dynamic_hook, but the convention remains readable and predictable.
With 87 tools, the server is far beyond a well-scoped set and includes many niche capabilities that could be consolidated or made optional. The count creates a large discovery and selection burden even though the individual tools are mostly meaningful.
The tool surface covers browser lifecycle, navigation, interaction, extraction, stealth/fingerprinting, proxy pools, request routing, HAR recording/replay, cookies/sessions, downloads, uploads, handoff, batching, and cleanup. For a browser-automation server, there are no obvious core gaps or dead ends.
Maintenance
Related MCP Connectors
Cloud browsers for AI agents: saved logins, residential IPs, live human takeover, 57 browser tools.
Browser agents and stealth cloud browsers for AI: run web tasks, fill forms, extract data.
Undetectable cloud browser sessions for AI agents and scrapers. Navigate, extract, click, captcha.
Cloud browser, web extraction, screenshots, and remote CDP tools for AI agents.
Related MCP Servers
- -licenseNot gradedqualityNot gradedmaintenanceUndetectable browser automation server for MCP-compatible AI agents, offering 225 tools across 32 sections to navigate, extract, clone pages, and bypass antibot systems like Cloudflare.1-
- AlicenseBqualityDmaintenanceProvides undetectable browser automation for LLM agents via MCP, enabling real Chrome interaction with stealth features, DOM accessibility, and DevTools integration.984MIT
- AlicenseAqualityDmaintenanceMCP server that lets AI assistants browse the web through your real Chrome with your cookies, sessions, and fingerprint, avoiding bot detection and CAPTCHAs. Enables web browsing, content extraction, and multi-step workflows via persistent tabs.1523 npm3MIT
- AlicenseNot gradedqualityDmaintenanceA stealth-enhanced browser automation MCP server for AI agents to interact with websites while bypassing anti-bot detection mechanisms like Cloudflare and reCAPTCHA.9MIT