Skip to main content
Glama

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 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


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 register

requirements: 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/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.key

Endpoints (all gated by X-API-Key: <key> or Authorization: Bearer <key>, except /healthz):

route

method

purpose

/healthz

GET

liveness, no auth

/api/tools

GET

list all 84 tools + JSON schemas

/api/tools/{name}

POST

call tool, body = {args...}

/api/call

POST

generic dispatch, body = {"tool":"...","args":{...}}

/mcp

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 logs

lifecycle hygiene

  • idle GC — tabs not touched in --idle-timeout get auto-closed. Browsers with no remaining tabs follow. Manual trigger: call the cleanup_stale tool with idle_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 dirs rmtreed. 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_list to the caller's namespace.

    • reject cross-tenant tab_id / browser_id references 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-server processes 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-in geoip: true on spawn.

    • deterministic fingerprint seed — spawn(fingerprint_seed='abc') → seedable PRNG for rotate_fingerprint reproducibility.

    • 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|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 it

7 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=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.

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 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 — 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 in

Fernet (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 (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.

how the savings happen

Every MCP tool response goes through _compact():

  • drops None only — empty [] / "" / 0 / False KEPT (they're informative)

  • columnar layout for 4+ homogeneous-dict arrays: {"_columnar":true,"keys":[...],"rows":[[...]]} — 44% smaller on real dom_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: true flag (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=True to 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/chrome

platforms

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 Network.setUserAgentOverride, so the minimal payload now re-asserts navigator.userAgentData via defineProperty from the Python-side detected Chrome version. Only injected under cloak.

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)

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)


🩹 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:

  1. Transaction.__call__ — every parser exception becomes future.set_exception(...) so the awaiter gets a real error, never a hang.

  2. Connection._listener — wraps the per-message dispatch so a single bad parse can't kill the listener task; future calls keep working.

  3. Cookie.from_json — tolerant of Chrome 146+ dropping sameParty (matches the pattern already used in CookieParam.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.py

Covers 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.json for Claude Code's plugin marketplace.

  • Submit to Anthropic's official marketplace via claude.ai/settings/plugins/submit.

features

  • Proxy pool rotation — shipped. ProxyPool w/ 5 rotation strategies, rolling health, geo + tag filters, sticky sessions, multi-format loaders (URL, host:port:user:pass, sticky-session gateway, JSON, CSV). CDP Fetch.authRequired handler 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-owns cross-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's route(pattern, async (route, request) => {...}) is callback-based. Add route_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 + loose URL-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 tailscaled per-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.js patterns from h4ckf0r0day/obscura @ 536072b (Apache-2.0)

  • stealth/tracker_domains.txt from obscura (Peter Lowe ad/tracker host file)

  • driver/aria.py + humanizer.py patterns from Huzy85/fantoma @ 86f20eb (MIT)

  • MCP tool surface convention from vibheksoni/stealth-browser-mcp @ def424d (MIT)

Available Tools

87 tools
aria_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_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

    B3.2/5.0

    Scored across 87 tools

    Disambiguation3/5

    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.

    Naming Consistency4/5

    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.

    Tool Count1/5

    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.

    Completeness5/5

    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

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • -
      license
      Not graded
      quality
      Not graded
      maintenance
      Undetectable 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
      -
    • A
      license
      A
      quality
      D
      maintenance
      MCP 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.
      15
      23 npm
      3
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      A stealth-enhanced browser automation MCP server for AI agents to interact with websites while bypassing anti-bot detection mechanisms like Cloudflare and reCAPTCHA.
      9
      MIT