Commit Graph
13 Commits
Author SHA1 Message Date
Andrey Mnatsakanov 58c7fe0543 fix(mcp): fill_form check/uncheck/select use the native setter and trusted events
The check/uncheck/select branches of browser_fill_form were left on the
pre-#530 pattern: a direct `el.checked = true` / `el.selectedIndex = i` plus a
raw, untrusted `change` event. For a React-controlled checkbox the direct
assignment runs the framework's value tracker in lockstep, so onChange never
commits and the box reverts; and any handler gating on event.isTrusted ignores
the untrusted events -- the exact bug class #530 fixed for text inputs two
lines away.

Route check/uncheck through __obscura_setFieldValue(el, 'checked', ...) so the
write goes through the prototype setter and leaves the tracker stale, and
dispatch input+change wrapped in __obscura_markTrusted for all three branches,
matching the text-input branch.

Fixes #550
2026-08-04 11:02:05 +02:00
lisa0314 6fac7f719a fix(mcp): fill React controlled inputs 2026-07-29 20:19:21 +08:00
SGavrl 750ffa62a9 fix(mcp): bound HTTP request read time 2026-07-26 11:05:04 +00:00
Andrey Mnatsakanov ea417bcd11 fix(mcp): detach SSE keep-alive so it cannot wedge the HTTP server
The MCP HTTP server serves connections sequentially on the single
current-thread runtime (the browser session is !Send). The SSE branch
(GET /mcp, Accept: text/event-stream) ran an infinite keep-alive ping loop
inline, so handle_connection never returned and the accept loop never ran
again — one open SSE stream hung every later POST /mcp tool call
indefinitely (functional break + trivial unauthenticated DoS).

The keep-alive carries no browser state and its write half is Send + 'static,
so spawn the ping loop on its own task and return from the handler. POST
handling stays serialized (correct for a single browser session), but a live
SSE stream no longer blocks the accept loop.

Fixes #489
2026-07-26 11:05:57 +02:00
SGavrl ee86dbf996 Scope MCP CORS to the allowlist and cache it at startup 2026-06-10 19:25:44 +00:00
charlesmamane26-sketch 6c9d5c55ad Security: cap MCP HTTP request bodies and add optional Origin allowlist (#280)
Reject request bodies above 16 MiB with 413 before allocating, stopping an
unauthenticated Content-Length OOM (vec![0u8; len]). Add an optional,
default-off OBSCURA_MCP_ALLOWED_ORIGINS allowlist; unset keeps the permissive
default so issue #175 hosted dashboards are unaffected.
2026-06-10 19:10:18 +00:00
SGavrl fb2841b7ab fix: CJK browser_search panic and multi-tab tab_close crash (#257, #258)
* Fix UTF-8 char boundary panic in browser_search

The search snippet window sliced body[..start] / body[end..] at byte
offsets derived from char counts and needle.len(), which can land inside
a multi-byte (CJK) character and panic, killing the MCP stdio transport.
Snap start/end to char boundaries before slicing and advance past
whitespace by the actual char width. Closes #257.

* Add setLineDash and friends to the canvas 2D mock

Charting libraries (Highcharts, ECharts) call setLineDash/getLineDash/
ellipse every animation frame. The missing setLineDash threw 'is not a
function' from a timer each tick, spamming the console. Adds the
line-dash methods plus ellipse/roundRect/createConicGradient/
getContextAttributes stubs. Addresses the error spam in #258; the
multi-tab isolate-teardown crash is a separate deeper issue.

* Fix multi-tab tab_close V8 isolate abort

rusty_v8 enters each isolate on creation and requires them dropped in
reverse order of creation. The MCP gave each tab its own isolate and
dropped them in arbitrary (user-chosen) order on tab_close, so closing a
non-newest tab aborted the process (Check failed: heap->isolate() ==
Isolate::TryGetCurrent()). Pages with active timers (Highcharts/ECharts)
made it easy to hit.

Enforce the CDP server's single-live-isolate discipline in the MCP: at
most one tab's isolate is ever live. page_mut calls a new activate() that
suspends every other tab (drops its isolate, keeps its DOM) and resumes
the active one. close_tab drops the closed tab's isolate via suspend_js
while it is still the only entered isolate, and tool_close suspends all
tabs before clearing. Confined to obscura-mcp; the engine, CDP server and
obstacle course are untouched, and peak memory drops to one isolate
instead of N. Closes #258.
2026-06-07 11:44:49 +02:00
Ben Younes 6edf111753 feat(mcp): add --host flag for MCP HTTP transport bind address (#254)
Add a --host CLI flag to 'obscura mcp --http' so the server can bind to
addresses other than 127.0.0.1 (e.g. 0.0.0.0 for Docker Compose sidecar
deployments). The default remains 127.0.0.1 for security.

Closes #253
2026-06-06 18:17:46 +02:00
Sander Spilleman 9ae36764da feat(stealth): many stealth improvements 2026-06-03 08:21:14 +00:00
SGavrl fd2421940b Perf/cdp fetch cache and cookie alloc (#216)
* perf: cache HTTP clients + selector parsing + exponential wait backoff

Three low-risk wins discovered while reading the hot paths.

1. op_fetch_url cached its reqwest::Client. Previously every JS-side
   fetch(), XHR, and dynamic script load built a fresh client, which
   re-loaded TLS roots and started with an empty connection pool. On
   an asset-heavy page with 30+ subresources this added ~150ms of
   wasted setup with zero connection reuse. The cache is a process-
   wide map keyed by proxy URL (empty string for no-proxy), so the
   first fetch on each proxy pays the build cost once and every
   subsequent fetch reuses the same pool.
   Also bumped pool_idle_timeout to 5 minutes and tcp_keepalive to
   60s so connections stay warm for SPA-heavy workloads that hit
   the same origin dozens of times across a navigation.

2. DomTree::parse_selector now caches parsed selectors in a thread-
   local HashMap (cap 256). querySelector / querySelectorAll re-
   parsed the selector string every call before; batch DOM access
   patterns (an agent scraping a table row-by-row, a framework
   polling for elements) paid the cssparser cost N times.

3. browser_wait_for / browser_wait_for_text now exponentially back
   off (5 -> 10 -> 20 -> ... -> 200 ms). The old fixed 200ms tick
   meant a selector that appeared 30ms after the call still took
   the next full 200ms tick to be detected. Worst case is still
   200ms per check, but the median wait drops sharply for fast
   appearances.

28/28 integration tests pass. Asset-heavy bench (rust-lang/rust,
docs.stripe.com, etc) doesn't regress.

* perf: module loader client cache, fetch concurrency cap, snapshot hide list, cookie alloc

Round two of perf wins after a deep code audit.

1. ES module loader (module_loader.rs) was building a fresh
   reqwest::Client on every dynamic import. Modern SPAs (Next, Vite)
   load 20-50 chunks via dynamic import; each one used to spin up an
   empty connection pool with fresh TLS init. Now reuses the
   process-wide FETCH_CLIENT_CACHE that op_fetch_url already uses
   (the cached_request_client helper). Saves ~30-100ms on chunk-
   heavy first navigations.

2. Script and CSS fetches in execute_scripts used futures::join_all
   with unbounded concurrency. A 100-script page would open 100 TCP
   sockets at once, exhausting the connection pool and the OS
   ephemeral port range. Capped at 16 in-flight (well above the per-
   host pool ceiling real Chrome uses).

3. bootstrap.js __obscura_init() ran Object.keys(globalThis).filter()
   on every page setup to find internals to hide. The set of keys
   is static at snapshot-build time, so we now precompute it once
   (top-level statement just below the function) and __obscura_init
   just iterates the precomputed array. Was 5-40ms per page on heavy
   SPAs (1000+ globals); now ~0.2ms per page.

4. CookieJar::domain_matches allocated 2 lowercase Strings and a
   format! per call. The lookup runs per fetch per cookie domain;
   on a heavily-cookied site with 100 cookies across 30 domains
   that was thousands of allocations per page. Now zero-alloc with
   eq_ignore_ascii_case and char-boundary checks.
2026-06-01 00:00:22 +02:00
SGavrl 171dba23e7 Feat/mcp agent skill tier1 (#215)
* feat(mcp): 10 new agent tools + element refs (12 -> 22 tools)

Brings obscura-mcp from a Playwright-style core (navigate/click/fill/
type/snapshot/wait_for/evaluate/select/press_key/network_requests/
console_messages/close) up to a full agent surface.

New tools:
- browser_markdown: render the page as Markdown (reuses the
  HTML_TO_MARKDOWN_JS converter behind `fetch --dump markdown`).
  Token-dense alternative to browser_snapshot.
- browser_links: every <a href> with anchor text, JSON-per-line.
  Optional internal_only and limit args.
- browser_interactive_elements: list every clickable / typeable
  element with a stable ref (e1, e2, ...) and a one-line label.
- browser_back / browser_forward / browser_reload: history nav,
  uses the Page history stack from the engine-tier1 commits.
- browser_get_cookies / browser_set_cookie / browser_clear_cookies:
  full cookie jar ops so an agent can skip a login flow when it
  already has a session token.
- browser_wait_for_text: wait for a substring to appear anywhere
  in the visible page text. Complements browser_wait_for which
  only checks for selectors.

Element refs (the playwright-mcp pattern, agent UX win):
- BrowserState now keeps an interactive_refs HashMap built on each
  browser_snapshot / browser_interactive_elements call.
- Each interactive element gets a data-obscura-ref="eN" attribute
  written into the DOM so refs survive across calls.
- browser_click / browser_fill / browser_type accept `ref` (preferred)
  or `selector` (fallback). Removes the agent's 'guess a CSS
  selector then retry' loop.
- Refs are cleared on every navigation / reload / back / forward / close.

Output size limits:
- browser_snapshot, browser_markdown accept max_chars (default 4000).
- browser_links, browser_interactive_elements accept limit (default 100).
- Truncated responses say '...(truncated, N more chars)' so the agent
  can ask for more if needed.

Verified end-to-end via /tmp/mcp-test.sh: tools/list returns 22 tools,
ref-based click on a real element works, markdown converts H1+P+UL
correctly, cookies round-trip. 28/28 integration tests pass.

* feat(mcp): 10 more agent tools, multi-tab, structured extract (22 -> 32 tools)

Tier 2: closes the gaps in agentic flows the Tier 1 surface still
needed multi-call workarounds for (login forms, paginated lists,
multi-step research across multiple destinations).

Multi-tab:
- BrowserState now holds a BTreeMap of tabs (Page per tab) with an
  active_tab cursor. page_mut() auto-creates a default tab so every
  legacy single-tab tool keeps working.
- browser_tab_new: open and optionally navigate.
- browser_tab_list: list all open tabs with active marker.
- browser_tab_switch: change which tab subsequent tools operate on.
- browser_tab_close: close (default: active tab), promotes another.

Form ergonomics:
- browser_detect_forms: returns each <form> with action, method, and
  every field's tag, type, name, current value, label (from <label for>
  or aria-label or placeholder), and option list for selects. Agents
  call this once to see the whole form shape instead of guessing
  selectors field by field.
- browser_fill_form: fill N inputs in one call. Each entry chooses
  type='text' (default value-set), 'check'/'uncheck', or 'select'
  (matches by value OR visible text). Optional submit_ref clicks
  after filling.

Structured extraction:
- browser_extract: pass {field: 'selector'} and get a JSON object back.
  Selector suffix '@attr' reads attribute (e.g. 'a@href'). Field
  suffix '[]' returns an array (queries all matches). One call returns
  the whole structured object instead of an evaluate per field.
- browser_count: existence / pagination probe.
- browser_get_attribute: read href / src / value / data-* etc.

Page control:
- browser_scroll: 'top'|'bottom'|'up'|'down'|'left'|'right' with
  optional amount, or scroll an element into view. Dispatches a real
  'scroll' event on window AND document so infinite-scroll handlers
  fire even though our viewport isn't a real layout.

Other:
- Bumped #![recursion_limit = "512"] because the tools/list JSON
  literal is now too big for the default macro recursion limit.
- browser_count: coerce f64 -> u64 since V8 numbers come back as floats.

Verified end-to-end: 32 tools registered, multi-tab open/list/switch/
close all work, detect_forms returns proper JSON, fill_form fills
text/check/select in one call, extract returns the structured object
with arrays and attribute reads. 28/28 integration tests pass.

* feat(mcp): search + storage state export/restore (32 -> 35 tools)

Three additions that close the long-tail agentic flows:

- browser_search: substring search in visible page text, returns each
  match with N chars of surrounding context (default 80). Lets an
  agent confirm content exists before scraping and locate a section
  without pulling the full page into its window. case_sensitive +
  limit + context_chars args.

- browser_storage_state: export the full auth surface (cookies +
  localStorage + sessionStorage for the current origin) as one JSON
  object. Agents stash this between runs to skip a login flow.

- browser_set_storage_state: apply a previously-exported state. Sets
  cookies via the cookie jar; writes localStorage / sessionStorage
  via evaluate on the current page (so a navigate to the right origin
  must come first).

Tool count: 35. 28/28 integration tests pass.
2026-05-31 23:21:07 +02:00
SGavrl d4d749d850 fix(mcp): allow browser MCP clients through CORS preflight (#179)
The OPTIONS preflight hardcoded Access-Control-Allow-Headers to just
Content-Type. Any browser MCP client sending mcp-protocol-version (per
the MCP spec) or Authorization / X-API-Key (typical for hosted
deployments) failed the preflight and the browser blocked the actual
request.

Add those three to the allow-list and a 24h Max-Age so browsers don't
re-preflight every call. Adds an integration test that drives the
server with a real preflight request.

Closes #175.
2026-05-24 20:14:01 +00:00
Jordi Llonch bce055fd2a MCP support (#65)
* add mcp support

* add MCP docs to README

* wire up mcp subcommand in cli

* fix(stealth): propagate stealth feature to obscura-mcp
2026-05-13 21:38:10 +00:00