Skip to main content

browser_control/mcp/
tools.rs

1//! MCP tools exposed by the `browser-control mcp` server.
2//!
3//! The tool surface is Playwright-shaped (`browser_*` prefix) plus
4//! browser-control extensions (`browser_get_html`, `browser_fetch`,
5//! `browser_eval`, `browser_select_element`, `browser_cookies`, `browser_storage_*`,
6//! `browser_wait_for_cookie`) and the legacy CDP-shaped `list_targets`
7//! kept for info-dense diagnostics.
8//!
9//! Tools that operate against a single tab accept optional `tab` (named)
10//! and `target` (URL regex) arguments. The two are mutually exclusive;
11//! omitting both routes to the server's in-memory active tab
12//! (`current_tab`).
13
14use anyhow::{anyhow, Result};
15use regex::Regex;
16use serde_json::{json, Value};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use crate::a11y::{self, FindOptions, RefEntry, SnapshotOptions};
21use crate::cli::fetch::script_fetch_timeout_ms;
22use crate::cli::storage::{build_get_expr, build_set_expr, ns_global};
23use crate::cli::wait_for_cookie::cookie_matches;
24use crate::detect::Engine;
25use crate::dom::scripts::{
26    FETCH_JS, GET_CLIP_RECT_JS, GET_DOM_JS, GET_PAGE_TEXT_JS, SELECT_ELEMENT_JS,
27};
28use crate::errors::SessionError;
29use crate::mcp::server::{RegisteredTool, ServerState, ToolHandler, ToolRegistry};
30use crate::session::backend::{ImageFormat, ScreenshotOptions, TabBackend};
31use crate::session::freshness;
32use crate::session::targets::TargetInfo;
33
34/// Per-op timeout for read tools (`browser_get_html`,
35/// `browser_select_element` short path, storage). 10 s is generous for
36/// legitimate DOM work and tight enough that a wedged renderer
37/// fast-fails.
38const MCP_OP_TIMEOUT: Duration = Duration::from_secs(10);
39
40/// Per-op timeout for `browser_fetch`. Slow HTTP fetches over real
41/// networks can take many seconds; 60 s matches the CLI `fetch
42/// --timeout-ms` default.
43const MCP_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
44
45/// Per-op timeout for `browser_select_element`. The overlay waits for a
46/// human click, so the bound has to be much longer than for automated
47/// tools. Five minutes is plenty for an interactive selection without
48/// leaking forever if the page is left abandoned.
49const MCP_SELECT_ELEMENT_TIMEOUT: Duration = Duration::from_secs(300);
50
51/// Probe budget for `browser_tab_select`: how long we give the selected
52/// tab to answer `Runtime.evaluate("1")` / `script.evaluate("1")` before
53/// returning `TabHung`. Matches `session::attach::PICK_PROBE_TIMEOUT`.
54const TAB_SELECT_PROBE: Duration = Duration::from_millis(500);
55
56/// Native wake/probe budget used only after a Playwright sidecar CDP failure.
57const SIDECAR_WAKE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
58
59/// Per-op timeout for `Accessibility.getFullAXTree`. Large pages serialise
60/// tens of thousands of nodes; 20 s keeps that below the 30 s transport
61/// timeout so a wedged renderer still surfaces as recoverable `TabHung`.
62const MCP_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(20);
63
64/// Register the standard tool set onto the given registry.
65pub fn register_all(registry: &ToolRegistry) {
66    // Renamed-from-Playwright tools.
67    registry.register(make_navigate());
68    registry.register(make_eval());
69    registry.register(make_get_html());
70    registry.register(make_get_page_text());
71    registry.register(make_take_screenshot());
72    registry.register(make_fetch());
73    registry.register(make_curl());
74    registry.register(make_select_element());
75    registry.register(make_cookies());
76    registry.register(make_storage_get());
77    registry.register(make_storage_set());
78    registry.register(make_wait_for_cookie());
79    // Passive console/network capture (native CDP or BiDi events; bodies Chromium-only).
80    registry.register(make_console_messages());
81    registry.register(make_network_requests());
82    registry.register(make_network_body());
83    // Diagnostic enumeration (kept).
84    registry.register(make_list_targets());
85    // New tab-management tools.
86    registry.register(make_tab_list());
87    registry.register(make_tab_new());
88    registry.register(make_tab_select());
89    registry.register(make_tab_close());
90    registry.register(make_tab_foreground());
91    // New browser-management tools.
92    registry.register(make_browser_start());
93    registry.register(make_browser_select());
94    registry.register(make_browser_list());
95    registry.register(make_browser_show());
96    // Native accessibility tools (no sidecar) on both engines.
97    registry.register(make_snapshot());
98    registry.register(make_find());
99    // Interaction tools. A `ref` routes through native input on either
100    // engine; a CSS `selector` routes through the Node sidecar and errors
101    // with `EngineUnsupported` when the active browser is BiDi.
102    registry.register(make_click());
103    registry.register(make_type());
104    registry.register(make_hover());
105    registry.register(make_drag());
106    registry.register(make_press_key());
107    registry.register(make_wait_for());
108    registry.register(make_pdf_save());
109}
110
111// ---------------------------------------------------------------------------
112// Helpers.
113// ---------------------------------------------------------------------------
114
115fn text_content(text: impl Into<String>) -> Value {
116    json!({ "content": [ { "type": "text", "text": text.into() } ] })
117}
118
119fn image_content(data: String, mime: &str) -> Value {
120    json!({
121        "content": [ { "type": "image", "data": data, "mimeType": mime } ]
122    })
123}
124
125fn handler<F>(f: F) -> ToolHandler
126where
127    F: Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
128        + Send
129        + Sync
130        + 'static,
131{
132    Arc::new(f)
133}
134
135/// Schema fragment for optional `tab` / `target` args. Inlined into
136/// every per-tab tool's input schema so the agent-facing contract is
137/// consistent.
138fn tab_args_schema() -> Value {
139    json!({
140        "tab": {
141            "type": "string",
142            "description": "Optional named tab; mutually exclusive with `target`."
143        },
144        "target": {
145            "type": "string",
146            "description": "Optional URL regex selecting an existing tab; mutually exclusive with `tab`."
147        }
148    })
149}
150
151/// Canonical builder for a per-tab tool's `properties` object: the shared
152/// `tab` / `target` schema merged with tool-specific `extra` fields. The
153/// merge result is order-independent — `serde_json::Map` serializes keys
154/// sorted — so callers may pass `extra` in any shape.
155fn tab_args_properties(extra: Value) -> Value {
156    let mut obj = extra.as_object().cloned().unwrap_or_default();
157    if let Some(ta) = tab_args_schema().as_object() {
158        for (k, v) in ta {
159            obj.insert(k.clone(), v.clone());
160        }
161    }
162    Value::Object(obj)
163}
164
165/// Canonical extraction of the optional `tab` (named) / `target` (URL
166/// regex) routing args from a tool's `args`. Mirrors the parse in
167/// [`ServerState::resolve_target_for_args`]; used by tools that need to
168/// branch on whether explicit routing was given before resolving.
169fn extract_tab_target(args: &Value) -> (Option<String>, Option<String>) {
170    let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
171    let target = args
172        .get("target")
173        .and_then(|v| v.as_str())
174        .map(String::from);
175    (tab, target)
176}
177
178fn max_age_arg(args: &Value) -> Result<Duration> {
179    match args.get("max_age") {
180        None | Some(Value::Null) => Ok(freshness::DEFAULT_MAX_AGE),
181        Some(Value::String(s)) => freshness::parse_max_age(s),
182        Some(Value::Number(n)) => n
183            .as_u64()
184            .map(Duration::from_secs)
185            .ok_or_else(|| anyhow!("`max_age` number must be non-negative seconds")),
186        Some(_) => Err(anyhow!(
187            "`max_age` must be a duration string, e.g. `10m` or `1h`"
188        )),
189    }
190}
191
192fn timeout_ms_arg(args: &Value, key: &str, default: Duration) -> Result<Duration> {
193    match args.get(key) {
194        None | Some(Value::Null) => Ok(default),
195        Some(Value::Number(n)) => n
196            .as_u64()
197            .map(Duration::from_millis)
198            .ok_or_else(|| anyhow!("`{key}` number must be non-negative milliseconds")),
199        Some(_) => Err(anyhow!(
200            "`{key}` must be a non-negative number of milliseconds"
201        )),
202    }
203}
204
205// ---------------------------------------------------------------------------
206// browser_navigate
207// ---------------------------------------------------------------------------
208
209fn make_navigate() -> RegisteredTool {
210    RegisteredTool {
211        name: "browser_navigate".into(),
212        description: "Navigate the active page to a URL.".into(),
213        input_schema: json!({
214            "type": "object",
215            "properties": tab_args_properties(json!({ "url": { "type": "string" } })),
216            "required": ["url"],
217        }),
218        handler: handler(|state, args| {
219            Box::pin(async move {
220                let url = args
221                    .get("url")
222                    .and_then(|v| v.as_str())
223                    .ok_or_else(|| anyhow!("missing 'url'"))?
224                    .to_string();
225                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
226                // Make sure capture is live before the load starts so the
227                // document request and load-time console output land in
228                // the buffers. Bounded by `TOUCH_WAIT`; usually milliseconds.
229                state.capture.touch_and_wait(&backend, &target_id).await;
230                backend.navigate(&target_id, &url).await?;
231                Ok(text_content(format!("Navigated to {url}")))
232            })
233        }),
234    }
235}
236
237// ---------------------------------------------------------------------------
238// browser_eval
239// ---------------------------------------------------------------------------
240
241fn make_eval() -> RegisteredTool {
242    RegisteredTool {
243        name: "browser_eval".into(),
244        description: "Evaluate a JavaScript expression in the active page.".into(),
245        input_schema: json!({
246            "type": "object",
247            "properties": tab_args_properties(json!({
248                "expression": {
249                    "type": "string",
250                    "description": "JavaScript expression to evaluate."
251                },
252                "await_promise": {
253                    "type": "boolean",
254                    "default": true,
255                    "description": "Treat the expression as a Promise and await it. Ignored on Firefox, which always awaits."
256                },
257                "timeout_ms": {
258                    "type": "number",
259                    "description": "Per-call timeout in milliseconds (default 10000)."
260                },
261                "max_age": {
262                    "type": "string",
263                    "description": "Reload the page first if its document is older than this duration (default 10m)."
264                }
265            })),
266            "required": ["expression"],
267        }),
268        handler: handler(|state, args| {
269            Box::pin(async move {
270                let expression = args
271                    .get("expression")
272                    .and_then(|v| v.as_str())
273                    .ok_or_else(|| anyhow!("missing 'expression'"))?
274                    .to_string();
275                let await_promise = args
276                    .get("await_promise")
277                    .and_then(Value::as_bool)
278                    .unwrap_or(true);
279                let timeout = timeout_ms_arg(&args, "timeout_ms", MCP_OP_TIMEOUT)?;
280                let max_age = max_age_arg(&args)?;
281                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
282                backend.ensure_fresh(&target_id, max_age).await?;
283                let value = backend
284                    .evaluate(&target_id, &expression, await_promise, timeout)
285                    .await?;
286                Ok(text_content(serde_json::to_string_pretty(&value)?))
287            })
288        }),
289    }
290}
291
292// ---------------------------------------------------------------------------
293// browser_get_html
294// ---------------------------------------------------------------------------
295
296fn make_get_html() -> RegisteredTool {
297    RegisteredTool {
298        name: "browser_get_html".into(),
299        description: "Get the rendered DOM as HTML, with shadow roots serialized when supported."
300            .into(),
301        input_schema: json!({
302            "type": "object",
303            "properties": tab_args_properties(json!({
304                "selector": {
305                    "type": "string",
306                    "description": "Optional CSS selector; defaults to the document element."
307                }
308            })),
309        }),
310        handler: handler(|state, args| {
311            Box::pin(async move {
312                let selector_arg = args.get("selector").and_then(|v| v.as_str());
313                let selector_literal = match selector_arg {
314                    Some(s) => serde_json::to_string(s)?,
315                    None => "null".to_string(),
316                };
317                let expr = format!("({GET_DOM_JS})({selector_literal})");
318                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
319                let value = backend
320                    .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
321                    .await?;
322                let html = value.as_str().unwrap_or("").to_string();
323                Ok(text_content(html))
324            })
325        }),
326    }
327}
328
329// ---------------------------------------------------------------------------
330// browser_get_page_text
331// ---------------------------------------------------------------------------
332
333const PAGE_TEXT_DEFAULT_MAX: usize = 20_000;
334
335fn make_get_page_text() -> RegisteredTool {
336    RegisteredTool {
337        name: "browser_get_page_text".into(),
338        description: "Readable text of the page (article-first: main/article content, page \
339                      chrome and hidden elements stripped, headings and list items kept) as \
340                      plain text. The cheapest way to read a page; use browser_snapshot when you \
341                      need structure and refs, browser_get_html for markup. Works on every \
342                      engine including Firefox."
343            .into(),
344        input_schema: json!({
345            "type": "object",
346            "properties": tab_args_properties(json!({
347                "max_chars": {
348                    "type": "integer",
349                    "minimum": 500,
350                    "description": "Truncate at a line boundary before this many characters. Default 20000."
351                },
352                "selector": {
353                    "type": "string",
354                    "description": "Optional CSS selector to extract from instead of the auto-detected main content."
355                }
356            })),
357        }),
358        handler: handler(|state, args| {
359            Box::pin(async move {
360                let max_chars =
361                    count_arg(&args, "max_chars", PAGE_TEXT_DEFAULT_MAX, 500, usize::MAX)?;
362                let selector_literal = match string_arg(&args, "selector")? {
363                    Some(s) => serde_json::to_string(&s)?,
364                    None => "null".to_string(),
365                };
366                let expr = format!("({GET_PAGE_TEXT_JS})({max_chars}, {selector_literal})");
367                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
368                let value = backend
369                    .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
370                    .await?;
371                let raw = value
372                    .as_str()
373                    .ok_or_else(|| anyhow!("page text script returned no result"))?;
374                let parsed: Value = serde_json::from_str(raw)
375                    .map_err(|e| anyhow!("page text script returned invalid JSON: {e}"))?;
376                if let Some(err) = parsed["error"].as_str() {
377                    return Err(anyhow!("{err}"));
378                }
379                let mut out = String::new();
380                if let Some(t) = parsed["title"].as_str().filter(|t| !t.is_empty()) {
381                    out.push_str(t);
382                    out.push('\n');
383                }
384                if let Some(u) = parsed["url"].as_str() {
385                    out.push_str(u);
386                    out.push('\n');
387                }
388                out.push('\n');
389                out.push_str(parsed["text"].as_str().unwrap_or_default());
390                if parsed["truncated"].as_bool().unwrap_or(false) {
391                    out.push_str(&format!(
392                        "\n… [truncated at {} of {} chars; pass max_chars or selector to narrow]",
393                        max_chars,
394                        parsed["total_chars"].as_u64().unwrap_or(0)
395                    ));
396                }
397                Ok(text_content(out))
398            })
399        }),
400    }
401}
402
403// ---------------------------------------------------------------------------
404// browser_take_screenshot
405// ---------------------------------------------------------------------------
406
407/// Parse and validate the screenshot arguments that do not need a
408/// backend, so bad input fails before any I/O.
409fn screenshot_opts(args: &Value) -> Result<(ScreenshotOptions, Option<std::path::PathBuf>)> {
410    let full_page = bool_arg(args, "full_page", false)?;
411    let format = match args.get("format") {
412        None | Some(Value::Null) => ImageFormat::Png,
413        Some(Value::String(s)) if s == "png" => ImageFormat::Png,
414        Some(Value::String(s)) if s == "jpeg" || s == "jpg" => ImageFormat::Jpeg,
415        Some(_) => return Err(anyhow!("`format` must be \"png\" or \"jpeg\"")),
416    };
417    let quality = match args.get("quality") {
418        None | Some(Value::Null) => None,
419        Some(Value::Number(n)) => {
420            let q = n
421                .as_u64()
422                .filter(|q| (1..=100).contains(q))
423                .ok_or_else(|| anyhow!("`quality` must be an integer from 1 to 100"))?;
424            if format != ImageFormat::Jpeg {
425                return Err(anyhow!("`quality` only applies to `format: \"jpeg\"`"));
426            }
427            Some(q as u8)
428        }
429        Some(_) => return Err(anyhow!("`quality` must be an integer from 1 to 100")),
430    };
431    let max_width = match args.get("max_width") {
432        None | Some(Value::Null) => None,
433        Some(Value::Number(n)) => Some(
434            n.as_u64()
435                .filter(|w| *w >= 64)
436                .ok_or_else(|| anyhow!("`max_width` must be an integer of at least 64"))?
437                as u32,
438        ),
439        Some(_) => return Err(anyhow!("`max_width` must be an integer of at least 64")),
440    };
441    let save_to = match string_arg(args, "save_to")? {
442        None => None,
443        Some(p) => {
444            let path = std::path::PathBuf::from(&p);
445            if !path.is_absolute() {
446                return Err(anyhow!("`save_to` must be an absolute path, got `{p}`"));
447            }
448            match path.parent() {
449                Some(dir) if dir.is_dir() => {}
450                _ => return Err(anyhow!("`save_to` parent directory does not exist: `{p}`")),
451            }
452            Some(path)
453        }
454    };
455    if args.get("selector").is_some_and(Value::is_string)
456        && args.get("ref").is_some_and(Value::is_string)
457    {
458        return Err(anyhow!("`selector` and `ref` are mutually exclusive"));
459    }
460    Ok((
461        ScreenshotOptions {
462            full_page,
463            clip: None,
464            format,
465            quality,
466            max_width,
467        },
468        save_to,
469    ))
470}
471
472fn make_take_screenshot() -> RegisteredTool {
473    RegisteredTool {
474        name: "browser_take_screenshot".into(),
475        description: "Capture a screenshot of the page, or of one element via `selector` or \
476                      `ref`. Screenshots are expensive in context: prefer browser_snapshot or \
477                      browser_get_page_text for reading, and when you do need pixels use \
478                      `format: \"jpeg\"` with `max_width` (e.g. 1024), or `save_to` to write the \
479                      file to disk and keep it out of the conversation. Default output is an \
480                      unscaled PNG image."
481            .into(),
482        input_schema: json!({
483            "type": "object",
484            "properties": tab_args_properties(json!({
485                "full_page": { "type": "boolean", "default": false },
486                "selector": { "type": "string", "description": "CSS selector to clip to; mutually exclusive with `ref`." },
487                "ref": { "type": "string", "description": "Element ref from browser_snapshot/browser_find to clip to; mutually exclusive with `selector`." },
488                "format": { "type": "string", "enum": ["png", "jpeg"], "description": "Default png." },
489                "quality": { "type": "integer", "minimum": 1, "maximum": 100, "description": "JPEG quality (default 80). jpeg only." },
490                "max_width": { "type": "integer", "minimum": 64, "description": "Downscale so the image is at most this many pixels wide. Chromium only; ignored on Firefox." },
491                "save_to": { "type": "string", "description": "Absolute file path. When set, the image is written there (0600) and only the path and dimensions are returned." }
492            })),
493        }),
494        handler: handler(|state, args| {
495            Box::pin(async move {
496                let (mut opts, save_to) = screenshot_opts(&args)?;
497                let selector = args.get("selector").and_then(|v| v.as_str());
498                let r = args.get("ref").and_then(|v| v.as_str());
499                if r.is_some() {
500                    state.ensure_native_ready("browser_take_screenshot").await?;
501                }
502                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
503                // A selector or ref clips the capture to that element's box.
504                opts.clip = match (selector, r) {
505                    (Some(sel), _) => {
506                        let sel_literal = serde_json::to_string(sel)?;
507                        let expr = format!("({GET_CLIP_RECT_JS})({sel_literal})");
508                        let rect = backend
509                            .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
510                            .await?;
511                        if rect.is_null() {
512                            return Err(anyhow!("selector matched no visible element: {sel}"));
513                        }
514                        Some(rect)
515                    }
516                    (None, Some(r)) => {
517                        let entry = resolve_ref(&state, &backend, &target_id, r).await?;
518                        Some(
519                            backend
520                                .node_clip_rect(&target_id, entry.backend_node_id, MCP_OP_TIMEOUT)
521                                .await
522                                .map_err(|e| stale_on_node_gone(e, r, &target_id))?,
523                        )
524                    }
525                    (None, None) => None,
526                };
527                let b64 = backend.screenshot(&target_id, &opts).await?;
528                match save_to {
529                    None => Ok(image_content(b64, opts.format.mime())),
530                    Some(path) => {
531                        use base64::Engine as _;
532                        let bytes = base64::engine::general_purpose::STANDARD
533                            .decode(b64.as_bytes())
534                            .map_err(|e| anyhow!("decoding screenshot data: {e}"))?;
535                        crate::cli::output::write_private_file(&path, &bytes)?;
536                        let dims = crate::cli::output::image_dimensions(&bytes)
537                            .map(|(w, h)| format!("{w}x{h}, "))
538                            .unwrap_or_default();
539                        Ok(text_content(format!(
540                            "Saved screenshot to {} ({dims}{}, {} KiB)",
541                            path.display(),
542                            opts.format.mime(),
543                            bytes.len().div_ceil(1024)
544                        )))
545                    }
546                }
547            })
548        }),
549    }
550}
551
552// ---------------------------------------------------------------------------
553// browser_fetch
554// ---------------------------------------------------------------------------
555
556fn make_fetch() -> RegisteredTool {
557    RegisteredTool {
558        name: "browser_fetch".into(),
559        description:
560            "Perform an HTTP request from the page context. Preserves cookies and remains subject to browser CORS/CSP rules. Prefer `browser_curl` for large responses or direct file downloads."
561                .into(),
562        input_schema: json!({
563            "type": "object",
564            "properties": tab_args_properties(json!({
565                "url": { "type": "string" },
566                "method": { "type": "string" },
567                "headers": { "type": "object" },
568                "body": { "type": "string" },
569                "timeout_ms": {
570                    "type": "number",
571                    "description": "Per-call timeout in milliseconds for the in-page fetch. Default 60s."
572                },
573                "max_age": {
574                    "type": "string",
575                    "description": "Reload the page first if its document is older than this duration (default 10m)."
576                }
577            })),
578            "required": ["url"],
579        }),
580        handler: handler(|state, args| {
581            Box::pin(async move {
582                if args.get("url").and_then(|v| v.as_str()).is_none() {
583                    return Err(anyhow!("missing 'url'"));
584                }
585                // Strip routing args before forwarding to the JS shim.
586                let mut for_js = args.clone();
587                if let Some(obj) = for_js.as_object_mut() {
588                    obj.remove("tab");
589                    obj.remove("target");
590                    obj.remove("max_age");
591                    obj.remove("timeout_ms");
592                }
593                let timeout = timeout_ms_arg(&args, "timeout_ms", MCP_FETCH_TIMEOUT)?;
594                if let Some(obj) = for_js.as_object_mut() {
595                    obj.insert(
596                        "timeoutMs".to_string(),
597                        json!(script_fetch_timeout_ms(timeout)),
598                    );
599                }
600                let max_age = max_age_arg(&args)?;
601                let args_json = serde_json::to_string(&for_js)?;
602                let args_literal = serde_json::to_string(&args_json)?;
603                let expr = format!("({FETCH_JS})({args_literal})");
604                // Explicit `tab`/`target` routing is honoured verbatim. With
605                // neither, route to a tab on the URL's origin rather than the
606                // server's `about:blank` active tab — an opaque-origin fetch
607                // silently drops cookies/credentials and trips CORS. Mirrors
608                // `cli::fetch`'s origin-bound default path.
609                let (tab, target) = extract_tab_target(&args);
610                let has_route = tab.is_some() || target.is_some();
611                let (backend, target_id) = if has_route {
612                    state.resolve_target_for_args(&args).await?
613                } else {
614                    let url = args.get("url").and_then(|v| v.as_str()).unwrap();
615                    state.resolve_or_create_for_origin(url).await?
616                };
617                backend.ensure_fresh(&target_id, max_age).await?;
618                let value = backend.evaluate(&target_id, &expr, true, timeout).await?;
619                let raw = value.as_str().unwrap_or("").to_string();
620                let mut parsed: Value = serde_json::from_str(&raw)
621                    .map_err(|e| anyhow!("invalid fetch response JSON: {e}"))?;
622                if parsed.get("ok").and_then(Value::as_bool) == Some(false) {
623                    let mut msg = parsed
624                        .get("error")
625                        .and_then(Value::as_str)
626                        .unwrap_or("fetch failed")
627                        .to_string();
628                    if let Some(name) = parsed.get("errorName").and_then(Value::as_str) {
629                        if !name.is_empty() {
630                            msg.push_str(&format!(" ({name})"));
631                        }
632                    }
633                    return Err(anyhow!(msg));
634                }
635                if let Some(obj) = parsed.as_object_mut() {
636                    obj.remove("ok");
637                }
638                let pretty = serde_json::to_string_pretty(&parsed)?;
639                Ok(text_content(pretty))
640            })
641        }),
642    }
643}
644
645// ---------------------------------------------------------------------------
646// browser_curl
647// ---------------------------------------------------------------------------
648
649fn make_curl() -> RegisteredTool {
650    RegisteredTool {
651        name: "browser_curl".into(),
652        description: format!(
653            "Run the real curl out of page context with cookies and User-Agent copied from the active browser, plus Origin and Referer derived from the selected source tab. Arguments use ordinary curl syntax and are forwarded unchanged. Omit `-o` to return up to {} MiB through MCP; use `-o <path>`/`--output <path>` for unrestricted streaming downloads. Unlike browser_fetch, curl is not subject to browser CORS/CSP and does not reproduce the browser TLS fingerprint.",
654            crate::cli::curl::MCP_RESPONSE_LIMIT / (1024 * 1024)
655        ),
656        input_schema: json!({
657            "type": "object",
658            "properties": tab_args_properties(json!({
659                "args": {
660                    "type": "array",
661                    "items": { "type": "string" },
662                    "minItems": 1,
663                    "description": "Exact curl arguments, including options and URL(s), e.g. [\"-L\", \"--fail-with-body\", \"-o\", \"/tmp/file.zip\", \"https://example.com/file.zip\"]."
664                }
665            })),
666            "required": ["args"],
667        }),
668        handler: handler(|state, args| {
669            Box::pin(async move {
670                let curl_args = args
671                    .get("args")
672                    .and_then(Value::as_array)
673                    .ok_or_else(|| anyhow!("missing or invalid 'args': expected an array of strings"))?
674                    .iter()
675                    .map(|arg| {
676                        arg.as_str()
677                            .map(String::from)
678                            .ok_or_else(|| anyhow!("every curl argument must be a string"))
679                    })
680                    .collect::<Result<Vec<_>>>()?;
681                if curl_args.is_empty() {
682                    return Err(anyhow!(
683                        "'args' must contain curl options and at least one URL"
684                    ));
685                }
686
687                // Cookies are browser-wide. Explicit tab/target routing
688                // selects the document used for navigator.userAgent, Origin,
689                // and Referer. Otherwise prefer the MCP active tab, falling
690                // back to any live tab inside `prepare`.
691                let (tab, target) = extract_tab_target(&args);
692                let has_route = tab.is_some() || target.is_some();
693                let (backend, target_id) = if has_route {
694                    let (backend, target_id) = state.resolve_target_for_args(&args).await?;
695                    (backend, Some(target_id))
696                } else {
697                    let backend = state.ensure_backend().await?;
698                    let target_id = state.active_target_id.lock().await.clone();
699                    (backend, target_id)
700                };
701                let prepared = crate::cli::curl::prepare(&backend, target_id.as_deref()).await?;
702                let output = crate::cli::curl::execute_mcp(&prepared, &curl_args).await?;
703                Ok(crate::cli::curl::mcp_result(output))
704            })
705        }),
706    }
707}
708
709// ---------------------------------------------------------------------------
710// browser_select_element
711// ---------------------------------------------------------------------------
712
713fn make_select_element() -> RegisteredTool {
714    RegisteredTool {
715        name: "browser_select_element".into(),
716        description:
717            "Show an interactive overlay; resolve with the CSS selector for the clicked element."
718                .into(),
719        input_schema: json!({
720            "type": "object",
721            "properties": tab_args_properties(json!({})),
722        }),
723        handler: handler(|state, args| {
724            Box::pin(async move {
725                let expr = SELECT_ELEMENT_JS.to_string();
726                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
727                // select_element shows an interactive overlay that the
728                // human clicks — extend the bound generously so the
729                // human has time to click.
730                let value = backend
731                    .evaluate(&target_id, &expr, true, MCP_SELECT_ELEMENT_TIMEOUT)
732                    .await?;
733                let selector = value.as_str().unwrap_or("").to_string();
734                Ok(text_content(selector))
735            })
736        }),
737    }
738}
739
740// ---------------------------------------------------------------------------
741// list_targets (legacy, CDP-shaped info-dense diagnostic)
742// ---------------------------------------------------------------------------
743
744fn make_list_targets() -> RegisteredTool {
745    RegisteredTool {
746        name: "list_targets".into(),
747        description: "List open page targets, optionally filtered by an unanchored URL regex. \
748                      CDP-shaped diagnostic; agents typically want `browser_tab_list`."
749            .into(),
750        input_schema: json!({
751            "type": "object",
752            "properties": {
753                "filter": {
754                    "type": "string",
755                    "description": "Optional unanchored URL regex."
756                }
757            },
758        }),
759        handler: handler(|state, args| {
760            Box::pin(async move {
761                let filter_re = args
762                    .get("filter")
763                    .and_then(|v| v.as_str())
764                    .map(Regex::new)
765                    .transpose()
766                    .map_err(|e| anyhow!("invalid `filter` regex: {e}"))?;
767                // Route through the server-owned backend rather than opening
768                // a fresh BiDi session (which would fail/race on Firefox).
769                // `live_targets` is the same primitive `browser_tab_list`
770                // uses; re-shape it into the legacy CDP-style `TargetInfo`.
771                let backend = state.ensure_backend().await?;
772                let kind = match state.browser_snapshot().await.engine {
773                    Engine::Cdp => "page",
774                    Engine::Bidi => "context",
775                };
776                let targets: Vec<TargetInfo> = backend
777                    .live_targets()
778                    .await?
779                    .into_iter()
780                    .filter(|t| filter_re.as_ref().map_or(true, |re| re.is_match(&t.url)))
781                    .map(|t| TargetInfo {
782                        id: t.id,
783                        url: t.url,
784                        title: t.title,
785                        kind: kind.to_string(),
786                    })
787                    .collect();
788                Ok(text_content(serde_json::to_string_pretty(&targets)?))
789            })
790        }),
791    }
792}
793
794// ---------------------------------------------------------------------------
795// browser_cookies
796// ---------------------------------------------------------------------------
797
798fn make_cookies() -> RegisteredTool {
799    RegisteredTool {
800        name: "browser_cookies".into(),
801        description: "Fetch cookies from the active browser. Returns full values (MCP is a \
802                      trusted local channel). Optional unanchored regex filters."
803            .into(),
804        input_schema: json!({
805            "type": "object",
806            "properties": {
807                "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
808                "name":   { "type": "string", "description": "Unanchored regex on cookie name." }
809            },
810        }),
811        handler: handler(|state, args| {
812            Box::pin(async move {
813                let domain_re = args
814                    .get("domain")
815                    .and_then(|v| v.as_str())
816                    .map(Regex::new)
817                    .transpose()
818                    .map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
819                let name_re = args
820                    .get("name")
821                    .and_then(|v| v.as_str())
822                    .map(Regex::new)
823                    .transpose()
824                    .map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
825                // Route through the server-owned backend (reuses the open
826                // session) instead of `fetch_cookies`, which opens a fresh
827                // BiDi session and would fail/race on Firefox.
828                let backend = state.ensure_backend().await?;
829                let all = backend.cookies().await?;
830                let filtered: Vec<_> = all
831                    .into_iter()
832                    .filter(|c| {
833                        domain_re.as_ref().map_or(true, |re| re.is_match(&c.domain))
834                            && name_re.as_ref().map_or(true, |re| re.is_match(&c.name))
835                    })
836                    .collect();
837                Ok(text_content(serde_json::to_string_pretty(&filtered)?))
838            })
839        }),
840    }
841}
842
843// ---------------------------------------------------------------------------
844// browser_storage_get / browser_storage_set
845// ---------------------------------------------------------------------------
846
847fn make_storage_get() -> RegisteredTool {
848    RegisteredTool {
849        name: "browser_storage_get".into(),
850        description: "Read a value from localStorage or sessionStorage on the active page.".into(),
851        input_schema: json!({
852            "type": "object",
853            "properties": tab_args_properties(json!({
854                "key": { "type": "string" },
855                "namespace": {
856                    "type": "string",
857                    "enum": ["local", "session"],
858                    "default": "local"
859                },
860                "max_age": {
861                    "type": "string",
862                    "description": "Reload the page first if its document is older than this duration (default 10m)."
863                }
864            })),
865            "required": ["key"],
866        }),
867        handler: handler(|state, args| {
868            Box::pin(async move {
869                let key = args
870                    .get("key")
871                    .and_then(|v| v.as_str())
872                    .ok_or_else(|| anyhow!("missing 'key'"))?
873                    .to_string();
874                let namespace = args
875                    .get("namespace")
876                    .and_then(|v| v.as_str())
877                    .unwrap_or("local");
878                let ns = ns_global(namespace)?;
879                let expr = build_get_expr(ns, &key);
880                let max_age = max_age_arg(&args)?;
881                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
882                backend.ensure_fresh(&target_id, max_age).await?;
883                let value = backend
884                    .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
885                    .await?;
886                // `build_get_expr` wraps the result in JSON.stringify, so the
887                // evaluator returns a JSON string. Unwrap one layer to surface
888                // the raw value (or `null` when the key is absent).
889                let text = match value {
890                    Value::String(s) => s,
891                    Value::Null => "null".to_string(),
892                    other => other.to_string(),
893                };
894                Ok(text_content(text))
895            })
896        }),
897    }
898}
899
900fn make_storage_set() -> RegisteredTool {
901    RegisteredTool {
902        name: "browser_storage_set".into(),
903        description: "Write a value to localStorage or sessionStorage on the active page.".into(),
904        input_schema: json!({
905            "type": "object",
906            "properties": tab_args_properties(json!({
907                "key": { "type": "string" },
908                "value": { "type": "string" },
909                "namespace": {
910                    "type": "string",
911                    "enum": ["local", "session"],
912                    "default": "local"
913                }
914            })),
915            "required": ["key", "value"],
916        }),
917        handler: handler(|state, args| {
918            Box::pin(async move {
919                let key = args
920                    .get("key")
921                    .and_then(|v| v.as_str())
922                    .ok_or_else(|| anyhow!("missing 'key'"))?
923                    .to_string();
924                let value = args
925                    .get("value")
926                    .and_then(|v| v.as_str())
927                    .ok_or_else(|| anyhow!("missing 'value'"))?
928                    .to_string();
929                let namespace = args
930                    .get("namespace")
931                    .and_then(|v| v.as_str())
932                    .unwrap_or("local");
933                let ns = ns_global(namespace)?;
934                let expr = build_set_expr(ns, &key, &value);
935                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
936                let _ = backend
937                    .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
938                    .await?;
939                Ok(text_content("ok"))
940            })
941        }),
942    }
943}
944
945// ---------------------------------------------------------------------------
946// browser_wait_for_cookie
947// ---------------------------------------------------------------------------
948
949fn make_wait_for_cookie() -> RegisteredTool {
950    RegisteredTool {
951        name: "browser_wait_for_cookie".into(),
952        description: "Poll the browser until a cookie matching the regex filters appears, or \
953                      timeout elapses."
954            .into(),
955        input_schema: json!({
956            "type": "object",
957            "properties": {
958                "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
959                "name":   { "type": "string", "description": "Unanchored regex on cookie name." },
960                "timeout_seconds": { "type": "number", "default": 120 },
961                "poll_interval_seconds": { "type": "number", "default": 1 }
962            },
963            "required": ["domain", "name"],
964        }),
965        handler: handler(|state, args| {
966            Box::pin(async move {
967                let domain = args
968                    .get("domain")
969                    .and_then(|v| v.as_str())
970                    .ok_or_else(|| anyhow!("missing 'domain'"))?;
971                let name = args
972                    .get("name")
973                    .and_then(|v| v.as_str())
974                    .ok_or_else(|| anyhow!("missing 'name'"))?;
975                let domain_re =
976                    Regex::new(domain).map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
977                let name_re = Regex::new(name).map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
978                let timeout_s = args
979                    .get("timeout_seconds")
980                    .and_then(|v| v.as_f64())
981                    .unwrap_or(120.0)
982                    .max(0.0);
983                let interval_s = args
984                    .get("poll_interval_seconds")
985                    .and_then(|v| v.as_f64())
986                    .unwrap_or(1.0)
987                    .max(0.001);
988                let deadline = Instant::now() + Duration::from_secs_f64(timeout_s);
989                let interval = Duration::from_secs_f64(interval_s);
990                // Acquire the server-owned backend once; reuse it each poll
991                // rather than opening a fresh BiDi session per iteration
992                // (which would fail/race on Firefox).
993                let backend = state.ensure_backend().await?;
994                loop {
995                    let cookies = backend.cookies().await?;
996                    if let Some(c) = cookies
997                        .into_iter()
998                        .find(|c| cookie_matches(c, &domain_re, &name_re))
999                    {
1000                        return Ok(text_content(c.name));
1001                    }
1002                    let now = Instant::now();
1003                    if now >= deadline {
1004                        return Err(anyhow!("timed out waiting for cookie"));
1005                    }
1006                    let remaining = deadline.saturating_duration_since(now);
1007                    let nap = std::cmp::min(interval, remaining);
1008                    if nap.is_zero() {
1009                        return Err(anyhow!("timed out waiting for cookie"));
1010                    }
1011                    tokio::time::sleep(nap).await;
1012                }
1013            })
1014        }),
1015    }
1016}
1017
1018// ---------------------------------------------------------------------------
1019// browser_tab_list / browser_tab_new / browser_tab_select / browser_tab_close
1020// ---------------------------------------------------------------------------
1021
1022fn make_tab_list() -> RegisteredTool {
1023    RegisteredTool {
1024        name: "browser_tab_list".into(),
1025        description: "List open tabs in the active browser, Playwright-shaped \
1026                      (`[{target_id, url, title, active, foreground}]`). Titles are empty on \
1027                      Firefox; `foreground` reports foreground emulation (browser_tab_foreground)."
1028            .into(),
1029        input_schema: json!({"type": "object", "properties": {}}),
1030        handler: handler(|state, _args| {
1031            Box::pin(async move {
1032                let v = tab_list_value(&state).await?;
1033                Ok(text_content(serde_json::to_string_pretty(&v)?))
1034            })
1035        }),
1036    }
1037}
1038
1039/// Build the `[{target_id, url, title, active}]` value for the current
1040/// browser. Shared between `browser_tab_list` and `browser_select`'s
1041/// response.
1042async fn tab_list_value(state: &ServerState) -> Result<Value> {
1043    let backend = state.ensure_backend().await?;
1044    let targets = backend.live_targets().await?;
1045    let active = state.active_target_id.lock().await.clone();
1046    let foreground: Vec<String> = match state.registered_browser_name().await {
1047        Ok(name) => {
1048            crate::mcp::server::sync_registry_op(move |reg| {
1049                crate::session::foreground::active_targets(reg, &name)
1050            })
1051            .await?
1052        }
1053        Err(_) => Vec::new(),
1054    };
1055    let arr: Vec<Value> = targets
1056        .into_iter()
1057        .map(|t| {
1058            json!({
1059                "target_id": t.id,
1060                "url": t.url,
1061                "title": t.title,
1062                "active": active.as_deref() == Some(t.id.as_str()),
1063                "foreground": foreground.contains(&t.id),
1064            })
1065        })
1066        .collect();
1067    Ok(Value::Array(arr))
1068}
1069
1070fn make_tab_new() -> RegisteredTool {
1071    RegisteredTool {
1072        name: "browser_tab_new".into(),
1073        description: "Create a new tab and make it the active tab. Defaults to about:blank. \
1074                      Pass `name` to create or select a durable named tab addressable as \
1075                      `<browser>/<name>`."
1076            .into(),
1077        input_schema: json!({
1078            "type": "object",
1079            "properties": {
1080                "name": { "type": "string", "description": "Optional named-tab id (a-z, 0-9, '-', '_')." },
1081                "url": { "type": "string", "description": "Optional URL; defaults to about:blank." }
1082            },
1083        }),
1084        handler: handler(|state, args| {
1085            Box::pin(async move {
1086                if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
1087                    let url = args.get("url").and_then(|v| v.as_str());
1088                    let opened = open_or_create_named_tab(&state, name, url).await?;
1089                    return Ok(text_content(serde_json::to_string_pretty(&opened)?));
1090                }
1091                let url = args
1092                    .get("url")
1093                    .and_then(|v| v.as_str())
1094                    .unwrap_or("about:blank")
1095                    .to_string();
1096                let backend = state.ensure_backend().await?;
1097                let tid = backend.create_tab(&url).await?;
1098                *state.active_target_id.lock().await = Some(tid.clone());
1099                state.capture.touch(&backend, &tid);
1100                Ok(text_content(serde_json::to_string_pretty(&json!({
1101                    "target_id": tid,
1102                    "url": url,
1103                    "active": true,
1104                }))?))
1105            })
1106        }),
1107    }
1108}
1109
1110async fn open_or_create_named_tab(
1111    state: &ServerState,
1112    name: &str,
1113    url: Option<&str>,
1114) -> Result<Value> {
1115    crate::cli::env_resolver::validate_tab_name(name)?;
1116    let want_url = url.unwrap_or("about:blank").to_string();
1117    let backend = state.ensure_backend().await?;
1118    let browser_name = state.registered_browser_name().await?;
1119
1120    let existing = {
1121        let bn = browser_name.clone();
1122        let n = name.to_string();
1123        crate::mcp::server::sync_registry_op(move |reg| reg.tab_get(&bn, &n)).await?
1124    };
1125    if let Some(row) = existing {
1126        let live = backend.live_target_ids().await?;
1127        if live.contains(&row.target_id) {
1128            if url.is_some() && row.last_url != want_url {
1129                backend.navigate(&row.target_id, &want_url).await?;
1130                let bn = browser_name.clone();
1131                let n = name.to_string();
1132                let u = want_url.clone();
1133                crate::mcp::server::sync_registry_op(move |reg| reg.tab_set_url(&bn, &n, &u))
1134                    .await?;
1135            } else {
1136                let bn = browser_name.clone();
1137                let n = name.to_string();
1138                crate::mcp::server::sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
1139            }
1140            *state.active_target_id.lock().await = Some(row.target_id.clone());
1141            state.capture.touch(&backend, &row.target_id);
1142            return Ok(json!({
1143                "name": name,
1144                "target_id": row.target_id,
1145                "url": if url.is_some() { want_url } else { row.last_url },
1146                "active": true,
1147                "created": false,
1148            }));
1149        }
1150
1151        let _ = backend.close_tab(&row.target_id).await;
1152        state.capture.forget(&backend, &row.target_id);
1153        let bn = browser_name.clone();
1154        let n = name.to_string();
1155        crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
1156    }
1157
1158    let victim = {
1159        let bn = browser_name.clone();
1160        crate::mcp::server::sync_registry_op(
1161            move |reg| -> Result<Option<crate::registry::TabRow>> {
1162                if reg.tabs_count_daemon_created(&bn)? >= crate::session::tabs::HARD_CAP {
1163                    reg.tabs_lru_daemon_created(&bn)
1164                } else {
1165                    Ok(None)
1166                }
1167            },
1168        )
1169        .await?
1170    };
1171    if let Some(victim) = victim {
1172        let _ = backend.close_tab(&victim.target_id).await;
1173        state.capture.forget(&backend, &victim.target_id);
1174        let bn = victim.browser_name;
1175        let n = victim.name;
1176        crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
1177    }
1178
1179    let target_id = backend.create_tab(&want_url).await?;
1180    let bn = browser_name;
1181    let n = name.to_string();
1182    let tid = target_id.clone();
1183    let u = want_url.clone();
1184    crate::mcp::server::sync_registry_op(move |reg| reg.tab_upsert(&bn, &n, &tid, &u, true))
1185        .await?;
1186    *state.active_target_id.lock().await = Some(target_id.clone());
1187    state.capture.touch(&backend, &target_id);
1188    Ok(json!({
1189        "name": name,
1190        "target_id": target_id,
1191        "url": want_url,
1192        "active": true,
1193        "created": true,
1194    }))
1195}
1196
1197fn make_tab_select() -> RegisteredTool {
1198    RegisteredTool {
1199        name: "browser_tab_select".into(),
1200        description: "Set the active tab. Probe-and-iterate: errors `TabHung` if the selected \
1201                      tab doesn't respond to a 500ms probe (agent should pick another or call \
1202                      `browser_tab_new`)."
1203            .into(),
1204        input_schema: json!({
1205            "type": "object",
1206            "properties": {
1207                "target_id": { "type": "string" }
1208            },
1209            "required": ["target_id"],
1210        }),
1211        handler: handler(|state, args| {
1212            Box::pin(async move {
1213                use crate::errors::SessionError;
1214                let tid = args
1215                    .get("target_id")
1216                    .and_then(|v| v.as_str())
1217                    .ok_or_else(|| anyhow!("missing 'target_id'"))?
1218                    .to_string();
1219                let backend = state.ensure_backend().await?;
1220                let live = backend.live_target_ids().await?;
1221                if !live.contains(&tid) {
1222                    return Err(SessionError::TabNotFound {
1223                        browser: state
1224                            .registered_browser_name()
1225                            .await
1226                            .unwrap_or_else(|_| "<external>".to_string()),
1227                        name: tid,
1228                    }
1229                    .into());
1230                }
1231                // Probe the tab. We don't auto-recreate on hang — the
1232                // agent asked for THIS tab; bubble up `TabHung` so they
1233                // can choose to `browser_tab_new` or pick a different
1234                // tab.
1235                let probed = tokio::time::timeout(
1236                    TAB_SELECT_PROBE,
1237                    backend.evaluate(&tid, "1", false, TAB_SELECT_PROBE),
1238                )
1239                .await;
1240                let ok = matches!(probed, Ok(Ok(_)));
1241                if !ok {
1242                    return Err(SessionError::TabHung {
1243                        target_id: Some(tid),
1244                        url: None,
1245                        timeout_ms: TAB_SELECT_PROBE.as_millis() as u64,
1246                        hint: "selected-tab-hung",
1247                    }
1248                    .into());
1249                }
1250                *state.active_target_id.lock().await = Some(tid.clone());
1251                state.capture.touch(&backend, &tid);
1252                Ok(text_content(serde_json::to_string_pretty(&json!({
1253                    "target_id": tid,
1254                    "active": true,
1255                }))?))
1256            })
1257        }),
1258    }
1259}
1260
1261fn make_tab_foreground() -> RegisteredTool {
1262    RegisteredTool {
1263        name: "browser_tab_foreground".into(),
1264        description: "Make a tab behave as the focused, visible foreground tab on an unlocked \
1265                      display even while the browser window is minimized or the machine's \
1266                      display is locked: `document.visibilityState` reports `visible`, \
1267                      `document.hasFocus()` is true, `requestAnimationFrame` and timers run at \
1268                      full rate, and screenshots show live content. Use it for games, canvas \
1269                      apps, and anything that pauses in the background. A small holder process \
1270                      keeps it on until `enabled: false`, the `timeout` (default 1h) elapses, \
1271                      the tab closes, or the browser exits; the same state is visible to the CLI \
1272                      (`browser-control tab foreground`). `enabled: false, all: true` stops every \
1273                      holder on the browser. Chromium only; requires a registered browser."
1274            .into(),
1275        input_schema: json!({
1276            "type": "object",
1277            "properties": tab_args_properties(json!({
1278                "enabled": { "type": "boolean", "description": "Default true; false turns emulation off." },
1279                "all": { "type": "boolean", "description": "With `enabled: false`: stop foreground emulation on every tab of the browser." },
1280                "timeout": { "type": "string", "description": "How long to hold it, e.g. \"30m\", \"2h\". Default 1h." }
1281            })),
1282        }),
1283        handler: handler(|state, args| {
1284            Box::pin(async move {
1285                use crate::session::foreground;
1286                let enabled = bool_arg(&args, "enabled", true)?;
1287                let all = bool_arg(&args, "all", false)?;
1288                let timeout = match string_arg(&args, "timeout")? {
1289                    Some(t) => freshness::parse_max_age(&t)?,
1290                    None => foreground::DEFAULT_TIMEOUT,
1291                };
1292                if all && enabled {
1293                    return Err(anyhow!("`all` only applies with `enabled: false`"));
1294                }
1295                state
1296                    .ensure_cdp_engine("browser_tab_foreground", FOREGROUND_HINT)
1297                    .await?;
1298                let browser_name = state.registered_browser_name().await.map_err(|e| {
1299                    anyhow!("{e}; foreground emulation records its holder per registered browser")
1300                })?;
1301                if all {
1302                    let bn = browser_name.clone();
1303                    let n = crate::mcp::server::sync_registry_op(move |reg| {
1304                        foreground::stop_all(reg, &bn)
1305                    })
1306                    .await?;
1307                    return Ok(text_content(format!(
1308                        "foreground emulation off for {n} tab(s) on {browser_name}"
1309                    )));
1310                }
1311                let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1312                let bn = browser_name.clone();
1313                let tid = target_id.clone();
1314                if enabled {
1315                    let (pid, created, expires_in) =
1316                        crate::mcp::server::sync_registry_op(move |reg| {
1317                            let (pid, created) = foreground::spawn_holder(reg, &bn, &tid, timeout)?;
1318                            // Report the running holder's expiry, which may
1319                            // predate this call.
1320                            let expires_in = foreground::status(reg, &bn, &tid)?
1321                                .map(|r| {
1322                                    (r.expires_at_epoch_s - crate::registry::now_epoch_s()).max(0)
1323                                        as u64
1324                                })
1325                                .map(std::time::Duration::from_secs)
1326                                .unwrap_or(timeout);
1327                            Ok((pid, created, expires_in))
1328                        })
1329                        .await?;
1330                    Ok(text_content(format!(
1331                        "foreground emulation {} for tab {target_id} (holder pid {pid}, expires in {}): the page reports visible and focused, and requestAnimationFrame/timers run at full rate while the window is minimized or the display is locked.",
1332                        if created { "on" } else { "already on" },
1333                        freshness::format_duration(expires_in)
1334                    )))
1335                } else {
1336                    let was_on = crate::mcp::server::sync_registry_op(move |reg| {
1337                        foreground::stop_holder(reg, &bn, &tid)
1338                    })
1339                    .await?;
1340                    Ok(text_content(format!(
1341                        "foreground emulation {} for tab {target_id}",
1342                        if was_on { "off" } else { "already off" }
1343                    )))
1344                }
1345            })
1346        }),
1347    }
1348}
1349
1350const FOREGROUND_HINT: &str = "foreground emulation uses CDP Emulation.setFocusEmulationEnabled; Firefox has no WebDriver BiDi equivalent, so switch to a Chromium browser via browser_select";
1351
1352fn make_tab_close() -> RegisteredTool {
1353    RegisteredTool {
1354        name: "browser_tab_close".into(),
1355        description: "Close a tab. Defaults to the active tab; clears the active pointer if the \
1356                      closed tab was active."
1357            .into(),
1358        input_schema: json!({
1359            "type": "object",
1360            "properties": {
1361                "target_id": { "type": "string", "description": "Optional; defaults to active tab." }
1362            },
1363        }),
1364        handler: handler(|state, args| {
1365            Box::pin(async move {
1366                let backend = state.ensure_backend().await?;
1367                let explicit = args
1368                    .get("target_id")
1369                    .and_then(|v| v.as_str())
1370                    .map(|s| s.to_string());
1371                let active = state.active_target_id.lock().await.clone();
1372                let tid = match (explicit, &active) {
1373                    (Some(e), _) => e,
1374                    (None, Some(a)) => a.clone(),
1375                    (None, None) => {
1376                        return Err(anyhow!("no `target_id` given and no active tab to close"));
1377                    }
1378                };
1379                let closed = backend.close_tab(&tid).await;
1380                // Capture state and element refs die with the tab, whether
1381                // or not the close RPC succeeded (the target is gone either
1382                // way).
1383                state.capture.forget(&backend, &tid);
1384                state.refs.lock().await.remove(&tid);
1385                closed?;
1386                // If we just closed the active tab, clear the pointer.
1387                let mut ptr = state.active_target_id.lock().await;
1388                if ptr.as_deref() == Some(tid.as_str()) {
1389                    *ptr = None;
1390                }
1391                Ok(text_content(serde_json::to_string_pretty(&json!({
1392                    "closed": tid,
1393                }))?))
1394            })
1395        }),
1396    }
1397}
1398
1399// ---------------------------------------------------------------------------
1400// browser_select / browser_list
1401// ---------------------------------------------------------------------------
1402
1403fn make_browser_start() -> RegisteredTool {
1404    RegisteredTool {
1405        name: "browser_start".into(),
1406        description: "Start or reuse a browser, then make it the active MCP browser. \
1407                      Use this to recover after the active browser exits."
1408            .into(),
1409        input_schema: json!({
1410            "type": "object",
1411            "properties": {
1412                "browser": { "type": "string", "description": "Optional browser kind (chrome, edge, chromium, brave, firefox). Defaults to an already-running installed browser if any, otherwise the first installed Chromium-family browser." },
1413                "headless": { "type": "boolean", "default": false },
1414                "wait_timeout_seconds": { "type": "integer", "default": 30 }
1415            },
1416        }),
1417        handler: handler(|state, args| {
1418            Box::pin(async move {
1419                let browser = args
1420                    .get("browser")
1421                    .and_then(|v| v.as_str())
1422                    .map(|s| s.to_string());
1423                let headless = args
1424                    .get("headless")
1425                    .and_then(|v| v.as_bool())
1426                    .unwrap_or(false);
1427                let wait_timeout = args
1428                    .get("wait_timeout_seconds")
1429                    .and_then(|v| v.as_u64())
1430                    .unwrap_or(30);
1431                let started =
1432                    crate::cli::start::ensure_started(browser, headless, false, wait_timeout)
1433                        .await?;
1434                let resolved = crate::cli::env_resolver::ResolvedBrowser {
1435                    endpoint: started.endpoint.clone(),
1436                    engine: started.engine,
1437                    source: crate::cli::env_resolver::Source::Registered {
1438                        name: started.name.clone(),
1439                    },
1440                };
1441                state.switch_browser(resolved).await?;
1442                let tabs = tab_list_value(&state).await?;
1443                Ok(text_content(serde_json::to_string_pretty(&json!({
1444                    "name": started.name,
1445                    "kind": started.kind.as_str(),
1446                    "engine": match started.engine {
1447                        crate::detect::Engine::Cdp => "cdp",
1448                        crate::detect::Engine::Bidi => "bidi",
1449                    },
1450                    "endpoint": started.endpoint,
1451                    "reused": started.reused,
1452                    "selected": true,
1453                    "tabs": tabs,
1454                }))?))
1455            })
1456        }),
1457    }
1458}
1459
1460fn make_browser_select() -> RegisteredTool {
1461    RegisteredTool {
1462        name: "browser_select".into(),
1463        description: "Switch the active browser by registered name, kind, URL, or CLI target \
1464                      syntax such as `chrome` or `brave/cart`. A kind selector starts or reuses \
1465                      that browser when none is live. The switch is committed before \
1466                      Firefox BiDi lock preparation; if preparation fails, the new browser remains \
1467                      active and the caller decides whether to retry, switch elsewhere, or switch back."
1468            .into(),
1469        input_schema: json!({
1470            "type": "object",
1471            "properties": {
1472                "name": { "type": "string", "description": "Browser selector, optionally `<browser>/<tab>`." }
1473            },
1474            "required": ["name"],
1475        }),
1476        handler: handler(|state, args| {
1477            Box::pin(async move {
1478                let name = args
1479                    .get("name")
1480                    .and_then(|v| v.as_str())
1481                    .ok_or_else(|| anyhow!("missing 'name'"))?
1482                    .to_string();
1483                let target = crate::cli::env_resolver::parse_target(&name)?;
1484                let resolved =
1485                    crate::mcp::server::resolve_browser_send(target.browser.clone()).await?;
1486                let resolved_clone = resolved.clone();
1487                state.switch_browser(resolved).await?;
1488                let selected_tab = if let Some(tab) = target.tab.as_deref() {
1489                    Some(open_or_create_named_tab(&state, tab, None).await?)
1490                } else {
1491                    None
1492                };
1493                let tabs = tab_list_value(&state).await?;
1494                Ok(text_content(serde_json::to_string_pretty(&json!({
1495                    "name": match &resolved_clone.source {
1496                        crate::cli::env_resolver::Source::Registered { name } => name.as_str(),
1497                        crate::cli::env_resolver::Source::External => "<external>",
1498                    },
1499                    "engine": match resolved_clone.engine {
1500                        crate::detect::Engine::Cdp => "cdp",
1501                        crate::detect::Engine::Bidi => "bidi",
1502                    },
1503                    "endpoint": resolved_clone.endpoint,
1504                    "selected_tab": selected_tab,
1505                    "tabs": tabs,
1506                }))?))
1507            })
1508        }),
1509    }
1510}
1511
1512fn make_browser_list() -> RegisteredTool {
1513    RegisteredTool {
1514        name: "browser_list".into(),
1515        description: "List live registered browsers with `[{name, kind, engine, endpoint, alive}]`; dead-process rows are pruned."
1516            .into(),
1517        input_schema: json!({"type": "object", "properties": {}}),
1518        handler: handler(|_state, _args| {
1519            Box::pin(async move {
1520                // `Registry` is `!Send`; do the read on a blocking thread.
1521                let arr = tokio::task::spawn_blocking(|| -> Result<Vec<Value>> {
1522                    let registry = crate::registry::Registry::open()?;
1523                    let rows = registry.list_alive()?;
1524                    Ok(rows
1525                        .into_iter()
1526                        .map(|r| {
1527                            json!({
1528                                "name": r.name,
1529                                "kind": r.kind.as_str(),
1530                                "engine": match r.engine {
1531                                    crate::detect::Engine::Cdp => "cdp",
1532                                    crate::detect::Engine::Bidi => "bidi",
1533                                },
1534                                "endpoint": r.endpoint,
1535                                "alive": true,
1536                            })
1537                        })
1538                        .collect())
1539                })
1540                .await??;
1541                Ok(text_content(serde_json::to_string_pretty(&Value::Array(
1542                    arr,
1543                ))?))
1544            })
1545        }),
1546    }
1547}
1548
1549fn make_browser_show() -> RegisteredTool {
1550    RegisteredTool {
1551        name: "browser_show".into(),
1552        description: "Explicitly reveal the active browser window for login or debugging. \
1553                      Normal automation keeps new tabs in the background."
1554            .into(),
1555        input_schema: json!({"type": "object", "properties": {}}),
1556        handler: handler(|state, _args| {
1557            Box::pin(async move {
1558                let backend = state.ensure_backend().await?;
1559                let target_id = backend.target_for_show().await?;
1560                let resolved = state.browser_snapshot().await;
1561                let source = resolved.source.clone();
1562                // External endpoints have no registered executable to
1563                // activate. Avoid opening the global registry in that case;
1564                // besides being unnecessary I/O, it could race a concurrent
1565                // browser switch or test-time data-directory override.
1566                let os_activated = match source {
1567                    crate::cli::env_resolver::Source::External => false,
1568                    source @ crate::cli::env_resolver::Source::Registered { .. } => {
1569                        tokio::task::spawn_blocking(move || -> Result<bool> {
1570                            let registry = crate::registry::Registry::open()?;
1571                            crate::cli::show::activate_resolved_app(&registry, &source)
1572                        })
1573                        .await??
1574                    }
1575                };
1576                backend.show_tab(&target_id).await?;
1577                Ok(text_content(serde_json::to_string_pretty(&json!({
1578                    "target_id": target_id,
1579                    "os_activated": os_activated,
1580                }))?))
1581            })
1582        }),
1583    }
1584}
1585
1586// ---------------------------------------------------------------------------
1587// Playwright-only interaction tools (routed through the Node sidecar).
1588// ---------------------------------------------------------------------------
1589//
1590// Each tool:
1591//   1. Resolves the target tab via `state.resolve_target_for_args(args)`.
1592//   2. Acquires the sidecar via `state.ensure_sidecar(tool_name)`. On
1593//      BiDi browsers this errors with `EngineUnsupported`.
1594//   3. Forwards to the sidecar with `target_id` + tool-specific params.
1595
1596/// Forward a sidecar call. Resolves the target natively first, ensures the
1597/// sidecar is up, then sends the RPC with `target_id` merged into the params.
1598/// If Playwright fails at the CDP attachment/connection layer, wake and probe
1599/// the tab through browser-control's native backend before returning a typed
1600/// sidecar-specific error. This prevents agents from misreading a sidecar CDP
1601/// timeout as evidence that the page itself is hung.
1602async fn forward_to_sidecar(
1603    state: &ServerState,
1604    tool_name: &str,
1605    args: &Value,
1606    sidecar_method: &str,
1607    mut params: serde_json::Map<String, Value>,
1608) -> Result<Value> {
1609    // Preflight: check engine support before resolving the target, but do not
1610    // spawn the sidecar yet. If Playwright attach fails, we still need a native
1611    // backend + target id for the wake/probe diagnostic.
1612    state.ensure_sidecar_supported(tool_name).await?;
1613    let (backend, target_id) = state.resolve_target_for_args(args).await?;
1614    params.insert("target_id".into(), Value::String(target_id));
1615    let sc = match state.ensure_sidecar(tool_name).await {
1616        Ok(sc) => sc,
1617        Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1618            return sidecar_cdp_failure_after_probe(
1619                state,
1620                &backend,
1621                tool_name,
1622                sidecar_method,
1623                params
1624                    .get("target_id")
1625                    .and_then(|v| v.as_str())
1626                    .unwrap_or_default(),
1627                e,
1628            )
1629            .await;
1630        }
1631        Err(e) => return Err(e),
1632    };
1633    match sc.call(sidecar_method, Value::Object(params.clone())).await {
1634        Ok(v) => Ok(v),
1635        Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1636            sidecar_cdp_failure_after_probe(
1637                state,
1638                &backend,
1639                tool_name,
1640                sidecar_method,
1641                params
1642                    .get("target_id")
1643                    .and_then(|v| v.as_str())
1644                    .unwrap_or_default(),
1645                e,
1646            )
1647            .await
1648        }
1649        Err(e) => Err(e),
1650    }
1651}
1652
1653async fn sidecar_cdp_failure_after_probe(
1654    state: &ServerState,
1655    backend: &TabBackend,
1656    tool_name: &str,
1657    sidecar_method: &str,
1658    target_id: &str,
1659    err: anyhow::Error,
1660) -> Result<Value> {
1661    state.reset_sidecar().await;
1662    let url = wake_and_probe_target(backend, target_id).await?;
1663    Err(SessionError::SidecarConnectionFailed {
1664        tool: tool_name.to_string(),
1665        method: sidecar_method.to_string(),
1666        target_id: target_id.to_string(),
1667        url,
1668        details: format!("{err:#}"),
1669        hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
1670    }
1671    .into())
1672}
1673
1674async fn wake_and_probe_target(backend: &TabBackend, target_id: &str) -> Result<Option<String>> {
1675    match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.show_tab(target_id)).await {
1676        Ok(r) => r?,
1677        Err(_) => {
1678            return Err(SessionError::TabHung {
1679                target_id: Some(target_id.to_string()),
1680                url: None,
1681                timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1682                hint: "sidecar-wake-timeout",
1683            }
1684            .into());
1685        }
1686    }
1687
1688    match tokio::time::timeout(
1689        SIDECAR_WAKE_PROBE_TIMEOUT,
1690        backend.evaluate(target_id, "1", false, SIDECAR_WAKE_PROBE_TIMEOUT),
1691    )
1692    .await
1693    {
1694        Ok(r) => {
1695            let _ = r?;
1696        }
1697        Err(_) => {
1698            return Err(SessionError::TabHung {
1699                target_id: Some(target_id.to_string()),
1700                url: None,
1701                timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1702                hint: "sidecar-probe-timeout",
1703            }
1704            .into());
1705        }
1706    }
1707
1708    match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.live_targets()).await {
1709        Ok(Ok(targets)) => Ok(targets
1710            .into_iter()
1711            .find(|t| t.id == target_id)
1712            .map(|t| t.url)),
1713        _ => Ok(None),
1714    }
1715}
1716
1717fn looks_like_sidecar_cdp_attach_failure(err: &anyhow::Error) -> bool {
1718    let msg = format!("{err:#}").to_ascii_lowercase();
1719    msg.contains("<ws connecting>")
1720        || msg.contains("connectovercdp")
1721        || msg.contains("websocket")
1722        || msg.contains("browser has been closed")
1723        || msg.contains("browser closed")
1724        || msg.contains("browser disconnected")
1725        || msg.contains("target closed")
1726        || msg.contains("cdp session closed")
1727        || msg.contains("econnrefused")
1728        || msg.contains("econnreset")
1729        || msg.contains("socket hang up")
1730        || msg.contains("sidecar stdout closed")
1731        || msg.contains("sidecar writer closed")
1732        || msg.contains("sidecar response channel dropped")
1733}
1734
1735// ---------------------------------------------------------------------------
1736// Console / network capture tools.
1737// ---------------------------------------------------------------------------
1738
1739fn regex_arg(args: &Value, key: &str) -> Result<Option<Regex>> {
1740    match args.get(key) {
1741        None | Some(Value::Null) => Ok(None),
1742        Some(Value::String(s)) if s.is_empty() => Ok(None),
1743        Some(Value::String(s)) => Regex::new(s)
1744            .map(Some)
1745            .map_err(|e| anyhow!("invalid `{key}` regex: {e}")),
1746        Some(_) => Err(anyhow!("`{key}` must be a string")),
1747    }
1748}
1749
1750fn bool_arg(args: &Value, key: &str, default: bool) -> Result<bool> {
1751    match args.get(key) {
1752        None | Some(Value::Null) => Ok(default),
1753        Some(Value::Bool(b)) => Ok(*b),
1754        Some(_) => Err(anyhow!("`{key}` must be a boolean")),
1755    }
1756}
1757
1758fn count_arg(args: &Value, key: &str, default: usize, min: usize, max: usize) -> Result<usize> {
1759    match args.get(key) {
1760        None | Some(Value::Null) => Ok(default),
1761        Some(Value::Number(n)) => {
1762            let n = n
1763                .as_u64()
1764                .ok_or_else(|| anyhow!("`{key}` must be a non-negative integer"))?
1765                as usize;
1766            if n < min {
1767                return Err(anyhow!("`{key}` must be at least {min}"));
1768            }
1769            Ok(n.min(max))
1770        }
1771        Some(_) => Err(anyhow!("`{key}` must be a non-negative integer")),
1772    }
1773}
1774
1775fn string_arg(args: &Value, key: &str) -> Result<Option<String>> {
1776    match args.get(key) {
1777        None | Some(Value::Null) => Ok(None),
1778        Some(Value::String(s)) if s.trim().is_empty() => Ok(None),
1779        Some(Value::String(s)) => Ok(Some(s.clone())),
1780        Some(_) => Err(anyhow!("`{key}` must be a string")),
1781    }
1782}
1783
1784/// `format: "text" | "json"` (default text).
1785fn wants_json(args: &Value) -> Result<bool> {
1786    match args.get("format") {
1787        None | Some(Value::Null) => Ok(false),
1788        Some(Value::String(s)) if s == "text" => Ok(false),
1789        Some(Value::String(s)) if s == "json" => Ok(true),
1790        Some(_) => Err(anyhow!("`format` must be \"text\" or \"json\"")),
1791    }
1792}
1793
1794fn capture_common_schema() -> Value {
1795    json!({
1796        "limit": {
1797            "type": "integer",
1798            "minimum": 0,
1799            "description": "Return the most recent N matching entries. Default 100. `limit: 0` with `clear: true` just clears."
1800        },
1801        "clear": {
1802            "type": "boolean",
1803            "description": "Clear the tab's buffer after reading. Use before an action to isolate its effects."
1804        },
1805        "format": {
1806            "type": "string",
1807            "enum": ["text", "json"],
1808            "description": "Output format. Default text (one line per entry)."
1809        }
1810    })
1811}
1812
1813fn make_console_messages() -> RegisteredTool {
1814    use crate::session::capture::{format_console_text, ConsoleQuery, CONSOLE_CAP};
1815    let mut props = capture_common_schema();
1816    props["pattern"] = json!({
1817        "type": "string",
1818        "description": "Unanchored regex applied to the rendered line (level, source URL, message, page URL). Always pass one on busy pages."
1819    });
1820    props["only_errors"] = json!({
1821        "type": "boolean",
1822        "description": "Only error-level entries (console.error, uncaught exceptions, failed resources). Default false."
1823    });
1824    RegisteredTool {
1825        name: "browser_console_messages".into(),
1826        description: format!(
1827            "Read console messages (console.*, uncaught exceptions, browser log entries such as \
1828             failed resource loads and CSP violations) captured for a tab. Capture starts when \
1829             the MCP server first touches a tab (browser_navigate, browser_tab_select, …) and \
1830             keeps the last {CONSOLE_CAP} entries across navigations until `clear`. Pass \
1831             `pattern` or `only_errors` to keep output small. Native protocol events on \
1832             Chromium (CDP) and Firefox (BiDi); no Node."
1833        ),
1834        input_schema: json!({
1835            "type": "object",
1836            "properties": tab_args_properties(props),
1837        }),
1838        handler: handler(|state, args| {
1839            Box::pin(async move {
1840                let q = ConsoleQuery {
1841                    pattern: regex_arg(&args, "pattern")?,
1842                    only_errors: bool_arg(&args, "only_errors", false)?,
1843                    limit: count_arg(&args, "limit", 100, 0, CONSOLE_CAP)?,
1844                    clear: bool_arg(&args, "clear", false)?,
1845                };
1846                let json_out = wants_json(&args)?;
1847                let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1848                let report = state.capture.read_console(&target_id, &q).await?;
1849                if json_out {
1850                    Ok(text_content(serde_json::to_string_pretty(&report)?))
1851                } else {
1852                    Ok(text_content(format_console_text(&report)))
1853                }
1854            })
1855        }),
1856    }
1857}
1858
1859fn make_network_requests() -> RegisteredTool {
1860    use crate::session::capture::{format_network_text, NetworkQuery, StatusFilter, NETWORK_CAP};
1861    let mut props = capture_common_schema();
1862    props["url_pattern"] = json!({
1863        "type": "string",
1864        "description": "Unanchored regex applied to the request URL."
1865    });
1866    props["method"] = json!({
1867        "type": "string",
1868        "description": "Exact HTTP method (case-insensitive)."
1869    });
1870    props["status"] = json!({
1871        "type": "string",
1872        "description": "Exact code (\"404\"), class (\"2xx\"…\"5xx\"), \"failed\", or \"pending\"."
1873    });
1874    props["resource_type"] = json!({
1875        "type": "string",
1876        "description": "Resource type: Document, XHR, Fetch, Script, Stylesheet, Image, Font, WebSocket, … On Firefox the type is derived from the request destination or MIME type and may be absent."
1877    });
1878    RegisteredTool {
1879        name: "browser_network_requests".into(),
1880        description: format!(
1881            "List network requests captured for a tab: method, URL, status, MIME type, size, \
1882             duration, failure reason, and the request id to pass to browser_network_body. \
1883             Capture starts when the MCP server first touches a tab and keeps the last \
1884             {NETWORK_CAP} requests across navigations until `clear`. Native protocol events \
1885             on Chromium (CDP) and Firefox (BiDi); no Node."
1886        ),
1887        input_schema: json!({
1888            "type": "object",
1889            "properties": tab_args_properties(props),
1890        }),
1891        handler: handler(|state, args| {
1892            Box::pin(async move {
1893                let q = NetworkQuery {
1894                    url_pattern: regex_arg(&args, "url_pattern")?,
1895                    method: string_arg(&args, "method")?,
1896                    status: string_arg(&args, "status")?
1897                        .map(|s| StatusFilter::parse(&s))
1898                        .transpose()?,
1899                    resource_type: string_arg(&args, "resource_type")?,
1900                    limit: count_arg(&args, "limit", 100, 0, NETWORK_CAP)?,
1901                    clear: bool_arg(&args, "clear", false)?,
1902                };
1903                let json_out = wants_json(&args)?;
1904                let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1905                let report = state.capture.read_network(&target_id, &q).await?;
1906                if json_out {
1907                    Ok(text_content(serde_json::to_string_pretty(&report)?))
1908                } else {
1909                    Ok(text_content(format_network_text(&report)))
1910                }
1911            })
1912        }),
1913    }
1914}
1915
1916fn make_network_body() -> RegisteredTool {
1917    use crate::session::capture::{BODY_DEFAULT_MAX, BODY_HARD_MAX};
1918    RegisteredTool {
1919        name: "browser_network_body".into(),
1920        description: "Fetch the response body of a captured request by the request id printed by \
1921                      browser_network_requests. Text bodies come back as text, binary as an \
1922                      embedded base64 resource, followed by a JSON metadata block. Default cap \
1923                      256 KiB, hard max 8 MiB. Bodies are evicted by the browser after \
1924                      navigation, so fetch promptly. Chromium-only: Firefox does not expose \
1925                      captured bodies; use browser_fetch there."
1926            .into(),
1927        input_schema: json!({
1928            "type": "object",
1929            "properties": tab_args_properties(json!({
1930                "request_id": {
1931                    "type": "string",
1932                    "description": "Request id from browser_network_requests (e.g. \"1234.56\")."
1933                },
1934                "max_bytes": {
1935                    "type": "integer",
1936                    "minimum": 1,
1937                    "maximum": BODY_HARD_MAX,
1938                    "description": "Truncate the body after this many bytes. Default 262144."
1939                }
1940            })),
1941            "required": ["request_id"],
1942        }),
1943        handler: handler(|state, args| {
1944            Box::pin(async move {
1945                let request_id = string_arg(&args, "request_id")?
1946                    .ok_or_else(|| anyhow!("missing 'request_id'"))?;
1947                let max_bytes = count_arg(&args, "max_bytes", BODY_DEFAULT_MAX, 1, BODY_HARD_MAX)?;
1948                state
1949                    .ensure_body_capture_supported("browser_network_body")
1950                    .await?;
1951                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
1952                let body = state
1953                    .capture
1954                    .response_body(&backend, &target_id, &request_id, max_bytes, MCP_OP_TIMEOUT)
1955                    .await?;
1956                let mut content = Vec::new();
1957                match std::str::from_utf8(&body.bytes) {
1958                    Ok(text) => content.push(json!({ "type": "text", "text": text })),
1959                    Err(_) => {
1960                        use base64::Engine as _;
1961                        content.push(json!({
1962                            "type": "resource",
1963                            "resource": {
1964                                "uri": format!("browser-control://network/{}", body.request_id),
1965                                "mimeType": body.mime_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
1966                                "blob": base64::engine::general_purpose::STANDARD.encode(&body.bytes),
1967                            }
1968                        }))
1969                    }
1970                }
1971                content.push(json!({
1972                    "type": "text",
1973                    "text": serde_json::to_string_pretty(&json!({
1974                        "request_id": body.request_id,
1975                        "url": body.url,
1976                        "status": body.status,
1977                        "mime_type": body.mime_type,
1978                        "bytes": body.bytes.len(),
1979                        "total_bytes": body.total_bytes,
1980                        "truncated": body.truncated,
1981                    }))?
1982                }));
1983                Ok(json!({ "content": content }))
1984            })
1985        }),
1986    }
1987}
1988
1989// ---------------------------------------------------------------------------
1990// Native accessibility snapshot, find, and ref resolution.
1991// ---------------------------------------------------------------------------
1992
1993/// Parse the rendering options shared by `browser_snapshot`. Pure
1994/// validation so bad args fail before any backend I/O.
1995fn snapshot_opts(args: &Value) -> Result<SnapshotOptions> {
1996    let interactive_only = match args.get("interactive_only") {
1997        None | Some(Value::Null) => false,
1998        Some(Value::Bool(b)) => *b,
1999        Some(_) => return Err(anyhow!("`interactive_only` must be a boolean")),
2000    };
2001    let max_chars = match args.get("max_chars") {
2002        None | Some(Value::Null) => a11y::DEFAULT_MAX_CHARS,
2003        Some(Value::Number(n)) => {
2004            let n = n
2005                .as_u64()
2006                .ok_or_else(|| anyhow!("`max_chars` must be a positive integer"))?;
2007            if n < 1000 {
2008                return Err(anyhow!("`max_chars` must be at least 1000"));
2009            }
2010            n as usize
2011        }
2012        Some(_) => return Err(anyhow!("`max_chars` must be a positive integer")),
2013    };
2014    let depth = match args.get("depth") {
2015        None | Some(Value::Null) => None,
2016        Some(Value::Number(n)) => Some(
2017            n.as_u64()
2018                .ok_or_else(|| anyhow!("`depth` must be a non-negative integer"))?
2019                as usize,
2020        ),
2021        Some(_) => return Err(anyhow!("`depth` must be a non-negative integer")),
2022    };
2023    if let Some(v) = args.get("ref") {
2024        if !v.is_null() && !v.is_string() {
2025            return Err(anyhow!("`ref` must be a string such as \"e12\""));
2026        }
2027    }
2028    Ok(SnapshotOptions {
2029        interactive_only,
2030        max_chars,
2031        root_backend_id: None,
2032        depth,
2033    })
2034}
2035
2036/// Fetch and parse the accessibility tree for `target_id`.
2037async fn fetch_ax_tree(backend: &TabBackend, target_id: &str) -> Result<a11y::AxTree> {
2038    let raw = backend
2039        .accessibility_tree(target_id, None, MCP_SNAPSHOT_TIMEOUT)
2040        .await?;
2041    a11y::parse_full_ax_tree(&raw)
2042}
2043
2044/// Run `f` against the ref table for `target_id`, replacing the table
2045/// when the tree belongs to a different document than the stored refs.
2046async fn with_ref_table<T>(
2047    state: &ServerState,
2048    target_id: &str,
2049    tree: &a11y::AxTree,
2050    f: impl FnOnce(&mut a11y::RefTable) -> Result<T>,
2051) -> Result<T> {
2052    let token = a11y::document_token(tree).unwrap_or(0);
2053    let mut refs = state.refs.lock().await;
2054    let table = refs
2055        .entry(target_id.to_string())
2056        .or_insert_with(|| a11y::RefTable::new(token));
2057    if table.doc_token != token {
2058        *table = a11y::RefTable::new(token);
2059    }
2060    f(table)
2061}
2062
2063/// Resolve an agent-facing ref to its element, verifying the tab is still
2064/// on the document the ref was taken from. A mismatch drops the table and
2065/// reports `StaleRef` so the agent re-snapshots instead of hitting a
2066/// recycled node id.
2067async fn resolve_ref(
2068    state: &ServerState,
2069    backend: &TabBackend,
2070    target_id: &str,
2071    r: &str,
2072) -> Result<RefEntry> {
2073    let unknown = || SessionError::RefUnknown {
2074        element: r.to_string(),
2075        target_id: target_id.to_string(),
2076    };
2077    let (entry, doc_token) = {
2078        let refs = state.refs.lock().await;
2079        let table = refs.get(target_id).ok_or_else(unknown)?;
2080        let entry = table.lookup(r).ok_or_else(unknown)?.clone();
2081        (entry, table.doc_token)
2082    };
2083    let current = backend.document_token(target_id, MCP_OP_TIMEOUT).await?;
2084    if current != doc_token {
2085        state.refs.lock().await.remove(target_id);
2086        return Err(SessionError::StaleRef {
2087            element: r.to_string(),
2088            target_id: target_id.to_string(),
2089            reason: "document changed",
2090        }
2091        .into());
2092    }
2093    Ok(entry)
2094}
2095
2096/// Translate the input layer's `NodeGone` into the agent-facing
2097/// `StaleRef` for `r`.
2098fn stale_on_node_gone(err: anyhow::Error, r: &str, target_id: &str) -> anyhow::Error {
2099    if matches!(
2100        err.downcast_ref::<SessionError>(),
2101        Some(SessionError::NodeGone { .. })
2102    ) {
2103        return SessionError::StaleRef {
2104            element: r.to_string(),
2105            target_id: target_id.to_string(),
2106            reason: "node no longer exists",
2107        }
2108        .into();
2109    }
2110    err
2111}
2112
2113fn quote(s: &str) -> String {
2114    serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
2115}
2116
2117fn describe_ref(entry: &RefEntry) -> String {
2118    if entry.name.is_empty() {
2119        entry.role.clone()
2120    } else {
2121        format!("{} {}", entry.role, quote(&entry.name))
2122    }
2123}
2124
2125/// `# <title> (<url>)` header for snapshot output, from the live target
2126/// list (already fetched by tab routing, so effectively free).
2127async fn page_header(backend: &TabBackend, target_id: &str, title_hint: &str) -> String {
2128    match backend.live_targets().await {
2129        Ok(targets) => targets
2130            .iter()
2131            .find(|t| t.id == target_id)
2132            .map(|t| {
2133                // BiDi's `getTree` carries no titles; the walker reports
2134                // `document.title` on its root node instead.
2135                let title = if t.title.is_empty() {
2136                    title_hint
2137                } else {
2138                    &t.title
2139                };
2140                if title.is_empty() {
2141                    format!("# {}\n", t.url)
2142                } else {
2143                    format!("# {} ({})\n", title, t.url)
2144                }
2145            })
2146            .unwrap_or_default(),
2147        Err(_) => String::new(),
2148    }
2149}
2150
2151fn make_snapshot() -> RegisteredTool {
2152    RegisteredTool {
2153        name: "browser_snapshot".into(),
2154        description: "Accessibility snapshot of the page with stable element refs (`[ref=eN]`) \
2155                      usable by browser_click / browser_type / browser_hover / browser_drag / \
2156                      browser_take_screenshot. Prefer this over screenshots for reading page \
2157                      structure. `interactive_only` keeps only actionable elements and their \
2158                      ancestors (good for forms); `ref` renders one subtree; `depth` limits \
2159                      nesting; `max_chars` caps output (default 50000, cut at a line boundary \
2160                      with a note). Refs stay valid until the page navigates. Native on \
2161                      Chromium (accessibility tree) and Firefox (injected DOM walker: names and \
2162                      roles are approximate, closed shadow roots are not visible); iframe \
2163                      contents are not included."
2164            .into(),
2165        input_schema: json!({
2166            "type": "object",
2167            "properties": tab_args_properties(json!({
2168                "interactive_only": {
2169                    "type": "boolean",
2170                    "description": "Only interactive elements (buttons, links, inputs, …) and their ancestors. Default false."
2171                },
2172                "max_chars": {
2173                    "type": "integer",
2174                    "minimum": 1000,
2175                    "description": "Truncate output at a line boundary before this many characters. Default 50000."
2176                },
2177                "ref": {
2178                    "type": "string",
2179                    "description": "Render only the subtree rooted at this ref from a previous snapshot or find."
2180                },
2181                "depth": {
2182                    "type": "integer",
2183                    "minimum": 0,
2184                    "description": "Levels below the root to include; deeper content collapses to `… (N more)`."
2185                }
2186            })),
2187        }),
2188        handler: handler(|state, args| {
2189            Box::pin(async move {
2190                let mut opts = snapshot_opts(&args)?;
2191                state.ensure_native_ready("browser_snapshot").await?;
2192                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
2193                if let Some(r) = args.get("ref").and_then(Value::as_str) {
2194                    let entry = resolve_ref(&state, &backend, &target_id, r).await?;
2195                    opts.root_backend_id = Some(entry.backend_node_id);
2196                }
2197                let tree = fetch_ax_tree(&backend, &target_id).await?;
2198                let root_title = tree
2199                    .nodes
2200                    .get(&tree.root)
2201                    .map(|n| n.name.clone())
2202                    .unwrap_or_default();
2203                let header = page_header(&backend, &target_id, &root_title).await;
2204                let snap = with_ref_table(&state, &target_id, &tree, |table| {
2205                    a11y::render_snapshot(&tree, table, &opts)
2206                })
2207                .await?;
2208                Ok(text_content(format!("{header}{}", snap.text)))
2209            })
2210        }),
2211    }
2212}
2213
2214fn make_find() -> RegisteredTool {
2215    RegisteredTool {
2216        name: "browser_find".into(),
2217        description:
2218            "Find elements by a short description (\"search box\", \"add to cart button\", \
2219                      \"Sign in\") and return their refs for browser_click / browser_type / etc. \
2220                      Plain text matching against accessible name, value, description, and role; \
2221                      no model call. Cheaper than a full browser_snapshot when you know what you \
2222                      are looking for. Returns up to 20 matches, best first. Native on \
2223                      Chromium and Firefox."
2224                .into(),
2225        input_schema: json!({
2226            "type": "object",
2227            "properties": tab_args_properties(json!({
2228                "query": {
2229                    "type": "string",
2230                    "description": "Words describing the element: visible text, label, placeholder, or role."
2231                },
2232                "interactive_only": {
2233                    "type": "boolean",
2234                    "description": "Only interactive elements. Default true; set false to find headings, images, text regions."
2235                },
2236                "limit": {
2237                    "type": "integer",
2238                    "minimum": 1,
2239                    "maximum": 20,
2240                    "description": "Maximum matches to return. Default 20."
2241                }
2242            })),
2243            "required": ["query"],
2244        }),
2245        handler: handler(|state, args| {
2246            Box::pin(async move {
2247                let query = args
2248                    .get("query")
2249                    .and_then(Value::as_str)
2250                    .map(str::trim)
2251                    .filter(|q| !q.is_empty())
2252                    .ok_or_else(|| anyhow!("missing 'query'"))?
2253                    .to_string();
2254                let interactive_only = match args.get("interactive_only") {
2255                    None | Some(Value::Null) => true,
2256                    Some(Value::Bool(b)) => *b,
2257                    Some(_) => return Err(anyhow!("`interactive_only` must be a boolean")),
2258                };
2259                let limit = match args.get("limit") {
2260                    None | Some(Value::Null) => a11y::DEFAULT_FIND_LIMIT,
2261                    Some(Value::Number(n)) => {
2262                        n.as_u64()
2263                            .filter(|n| *n >= 1)
2264                            .ok_or_else(|| anyhow!("`limit` must be a positive integer"))?
2265                            .min(a11y::DEFAULT_FIND_LIMIT as u64) as usize
2266                    }
2267                    Some(_) => return Err(anyhow!("`limit` must be a positive integer")),
2268                };
2269                state.ensure_native_ready("browser_find").await?;
2270                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
2271                let tree = fetch_ax_tree(&backend, &target_id).await?;
2272                let opts = FindOptions {
2273                    interactive_only,
2274                    limit,
2275                };
2276                let hits = with_ref_table(&state, &target_id, &tree, |table| {
2277                    Ok(a11y::find(&tree, table, &query, &opts))
2278                })
2279                .await?;
2280                if hits.is_empty() {
2281                    return Ok(text_content(format!(
2282                        "no matches for {}; try fewer words, interactive_only: false, or browser_snapshot",
2283                        quote(&query)
2284                    )));
2285                }
2286                let mut out = format!(
2287                    "{} match{} for {}:\n",
2288                    hits.len(),
2289                    if hits.len() == 1 { "" } else { "es" },
2290                    quote(&query)
2291                );
2292                for m in &hits {
2293                    out.push_str(&m.r#ref);
2294                    out.push(' ');
2295                    out.push_str(&m.role);
2296                    if !m.name.is_empty() {
2297                        out.push(' ');
2298                        out.push_str(&quote(&m.name));
2299                    }
2300                    if let Some(v) = &m.value {
2301                        out.push_str(&format!(" [value={}]", quote(v)));
2302                    }
2303                    if let Some(ctx) = &m.context {
2304                        out.push_str(&format!(" — in {ctx}"));
2305                    }
2306                    out.push('\n');
2307                }
2308                Ok(text_content(out))
2309            })
2310        }),
2311    }
2312}
2313
2314/// Which native CDP action a `ref` routes to.
2315#[derive(Clone, Copy, Debug)]
2316enum NativeAction {
2317    Click,
2318    Type,
2319    Hover,
2320    Drag,
2321}
2322
2323/// How an interaction tool call is routed.
2324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2325enum Route {
2326    Native,
2327    Sidecar,
2328}
2329
2330/// Decide the route from the (selector, ref) argument pairs. Validation
2331/// only — fires before any backend access. Every pair must carry exactly
2332/// one side, and all pairs must agree.
2333fn route_mode(args: &Value, ref_pairs: &[(&str, &str)]) -> Result<Route> {
2334    if ref_pairs.is_empty() {
2335        return Ok(Route::Sidecar);
2336    }
2337    let mut native = 0;
2338    let mut sidecar = 0;
2339    for (sel, r) in ref_pairs {
2340        let has_sel = args.get(*sel).is_some_and(Value::is_string);
2341        let has_ref = args.get(*r).is_some_and(Value::is_string);
2342        match (has_sel, has_ref) {
2343            (true, true) => {
2344                return Err(anyhow!("`{sel}` and `{r}` are mutually exclusive; pass one of them"))
2345            }
2346            (false, false) => {
2347                return Err(anyhow!(
2348                    "exactly one of `{sel}` (CSS selector) or `{r}` (ref from browser_snapshot/browser_find) is required"
2349                ))
2350            }
2351            (true, false) => sidecar += 1,
2352            (false, true) => native += 1,
2353        }
2354    }
2355    if native > 0 && sidecar > 0 {
2356        return Err(anyhow!(
2357            "use refs for every element or selectors for every element, not a mix"
2358        ));
2359    }
2360    Ok(if native > 0 {
2361        Route::Native
2362    } else {
2363        Route::Sidecar
2364    })
2365}
2366
2367/// Execute an interaction tool through native CDP input.
2368async fn run_native(
2369    state: &ServerState,
2370    tool_name: &str,
2371    action: NativeAction,
2372    args: &Value,
2373) -> Result<Value> {
2374    state.ensure_native_ready(tool_name).await?;
2375    let (backend, target_id) = state.resolve_target_for_args(args).await?;
2376    let timeout = timeout_ms_arg(args, "timeout_ms", MCP_OP_TIMEOUT)?;
2377    let ref_arg = |key: &str| -> Result<String> {
2378        args.get(key)
2379            .and_then(Value::as_str)
2380            .map(String::from)
2381            .ok_or_else(|| anyhow!("missing '{key}'"))
2382    };
2383    match action {
2384        NativeAction::Click => {
2385            let r = ref_arg("ref")?;
2386            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2387            backend
2388                .click_node(&target_id, entry.backend_node_id, timeout)
2389                .await
2390                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2391            Ok(text_content(format!(
2392                "clicked {r} ({})",
2393                describe_ref(&entry)
2394            )))
2395        }
2396        NativeAction::Hover => {
2397            let r = ref_arg("ref")?;
2398            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2399            backend
2400                .hover_node(&target_id, entry.backend_node_id, timeout)
2401                .await
2402                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2403            Ok(text_content(format!(
2404                "hovered {r} ({})",
2405                describe_ref(&entry)
2406            )))
2407        }
2408        NativeAction::Type => {
2409            let r = ref_arg("ref")?;
2410            let text = args
2411                .get("text")
2412                .and_then(Value::as_str)
2413                .ok_or_else(|| anyhow!("missing 'text'"))?
2414                .to_string();
2415            let press_sequentially = args
2416                .get("press_sequentially")
2417                .and_then(Value::as_bool)
2418                .unwrap_or(false);
2419            let submit = args.get("submit").and_then(Value::as_bool).unwrap_or(false);
2420            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2421            backend
2422                .type_into_node(
2423                    &target_id,
2424                    entry.backend_node_id,
2425                    &text,
2426                    press_sequentially,
2427                    submit,
2428                    timeout,
2429                )
2430                .await
2431                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2432            Ok(text_content(format!(
2433                "typed into {r} ({}){}",
2434                describe_ref(&entry),
2435                if submit { " and pressed Enter" } else { "" }
2436            )))
2437        }
2438        NativeAction::Drag => {
2439            let a = ref_arg("source_ref")?;
2440            let b = ref_arg("target_ref")?;
2441            let from = resolve_ref(state, &backend, &target_id, &a).await?;
2442            let to = resolve_ref(state, &backend, &target_id, &b).await?;
2443            backend
2444                .drag_nodes(
2445                    &target_id,
2446                    from.backend_node_id,
2447                    to.backend_node_id,
2448                    timeout,
2449                )
2450                .await
2451                .map_err(|e| stale_on_node_gone(e, &a, &target_id))?;
2452            Ok(text_content(format!(
2453                "dragged {a} ({}) to {b} ({})",
2454                describe_ref(&from),
2455                describe_ref(&to)
2456            )))
2457        }
2458    }
2459}
2460
2461// ---------------------------------------------------------------------------
2462// Table-driven sidecar interaction tools.
2463//
2464// click / type / hover / drag / press_key / wait_for all share one shape:
2465// build a param map from a fixed set of args, forward to the sidecar, return a
2466// fixed success string. Previously each tool declared its params *twice* — once
2467// in the input schema (`tab_args_properties`) and once in the handler (`copy_arg` per
2468// param) — with no compiler link, so a schema param missing a matching
2469// `copy_arg` was silently dropped before reaching the sidecar.
2470//
2471// `SidecarTool` is the single source of truth: each param's name + schema +
2472// required-ness is declared once in `params`, and BOTH the input schema and the
2473// param-forwarding are derived from it, so a param can't be in the schema but
2474// missing from the wire (or vice versa).
2475// ---------------------------------------------------------------------------
2476
2477/// One sidecar-forwarded parameter, declared once. Drives both the JSON schema
2478/// (`schema`, `required`) and the runtime forwarding (`name`).
2479struct SidecarParam {
2480    name: &'static str,
2481    schema: Value,
2482    required: bool,
2483}
2484
2485/// Declarative spec for an interaction tool. Both the input schema and
2486/// the param-forwarding are derived from the single `params` slice.
2487///
2488/// Tools with `ref_pairs` accept either a CSS selector (forwarded to the
2489/// Playwright sidecar) or an element ref (handled natively over CDP by
2490/// `run_native`). `route_mode` validates the pairing before any I/O.
2491struct SidecarTool {
2492    name: &'static str,
2493    description: &'static str,
2494    /// The sidecar RPC method (e.g. `"click"`).
2495    method: &'static str,
2496    params: Vec<SidecarParam>,
2497    /// Fixed success message returned as text content.
2498    success: &'static str,
2499    /// Native action taken when the call carries refs instead of selectors.
2500    native: Option<NativeAction>,
2501    /// `(selector_param, ref_param)` pairs; empty for sidecar-only tools.
2502    ref_pairs: &'static [(&'static str, &'static str)],
2503}
2504
2505const REF_PARAM_DESC: &str = "Element ref from browser_snapshot or browser_find (e.g. \"e12\"); \
2506                              handled natively on Chromium and Firefox, no Node needed. Mutually exclusive with \
2507                              the CSS selector; exactly one is required.";
2508
2509impl SidecarTool {
2510    fn build(self) -> RegisteredTool {
2511        let SidecarTool {
2512            name,
2513            description,
2514            method,
2515            params,
2516            success,
2517            native,
2518            ref_pairs,
2519        } = self;
2520
2521        // Schema: shared tab/target args plus this tool's params, with the
2522        // `required` list derived from the same table.
2523        let extra = Value::Object(
2524            params
2525                .iter()
2526                .map(|p| (p.name.to_string(), p.schema.clone()))
2527                .collect(),
2528        );
2529        let required: Vec<&str> = params
2530            .iter()
2531            .filter(|p| p.required)
2532            .map(|p| p.name)
2533            .collect();
2534        let mut input_schema = json!({
2535            "type": "object",
2536            "properties": tab_args_properties(extra),
2537        });
2538        if !required.is_empty() {
2539            input_schema["required"] = json!(required);
2540        }
2541
2542        // Forwarding: copy exactly the params declared above — no second list
2543        // to drift out of sync. Ref params never reach the sidecar.
2544        let param_names: Vec<&'static str> = params
2545            .iter()
2546            .map(|p| p.name)
2547            .filter(|n| !ref_pairs.iter().any(|(_, r)| r == n))
2548            .collect();
2549        RegisteredTool {
2550            name: name.into(),
2551            description: description.into(),
2552            input_schema,
2553            handler: handler(move |state, args| {
2554                let param_names = param_names.clone();
2555                Box::pin(async move {
2556                    match (route_mode(&args, ref_pairs)?, native) {
2557                        (Route::Native, Some(action)) => {
2558                            run_native(&state, name, action, &args).await
2559                        }
2560                        (Route::Native, None) => Err(anyhow!("{name} has no native path")),
2561                        (Route::Sidecar, _) => {
2562                            let mut params = serde_json::Map::new();
2563                            for key in &param_names {
2564                                copy_arg(&args, key, &mut params);
2565                            }
2566                            forward_to_sidecar(&state, name, &args, method, params).await?;
2567                            Ok(text_content(success))
2568                        }
2569                    }
2570                })
2571            }),
2572        }
2573    }
2574}
2575
2576fn make_click() -> RegisteredTool {
2577    SidecarTool {
2578        name: "browser_click",
2579        description: "Click an element by `ref` (from browser_snapshot/browser_find; native, \
2580                      Chromium and Firefox) or by CSS `selector` (Playwright sidecar, Chromium).",
2581        method: "click",
2582        params: vec![
2583            SidecarParam {
2584                name: "selector",
2585                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2586                required: false,
2587            },
2588            SidecarParam {
2589                name: "ref",
2590                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2591                required: false,
2592            },
2593            SidecarParam {
2594                name: "timeout_ms",
2595                schema: json!({"type": "integer"}),
2596                required: false,
2597            },
2598        ],
2599        success: "clicked",
2600        native: Some(NativeAction::Click),
2601        ref_pairs: &[("selector", "ref")],
2602    }
2603    .build()
2604}
2605
2606fn make_type() -> RegisteredTool {
2607    SidecarTool {
2608        name: "browser_type",
2609        description: "Replace the content of an input with `text`, addressed by `ref` (native, \
2610                      Chromium and Firefox) or CSS `selector` (Playwright sidecar, Chromium). \
2611                      `press_sequentially=true` sends one character at a time; `submit=true` \
2612                      presses Enter afterwards.",
2613        method: "type",
2614        params: vec![
2615            SidecarParam {
2616                name: "selector",
2617                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2618                required: false,
2619            },
2620            SidecarParam {
2621                name: "ref",
2622                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2623                required: false,
2624            },
2625            SidecarParam {
2626                name: "text",
2627                schema: json!({"type": "string"}),
2628                required: true,
2629            },
2630            SidecarParam {
2631                name: "press_sequentially",
2632                schema: json!({"type": "boolean", "description": "Send the text one character at a time. On Firefox this dispatches real key events; on Chromium it inserts one character per event without keydown/keyup."}),
2633                required: false,
2634            },
2635            SidecarParam {
2636                name: "submit",
2637                schema: json!({"type": "boolean", "description": "Press Enter after typing."}),
2638                required: false,
2639            },
2640            SidecarParam {
2641                name: "timeout_ms",
2642                schema: json!({"type": "integer"}),
2643                required: false,
2644            },
2645        ],
2646        success: "typed",
2647        native: Some(NativeAction::Type),
2648        ref_pairs: &[("selector", "ref")],
2649    }
2650    .build()
2651}
2652
2653fn make_hover() -> RegisteredTool {
2654    SidecarTool {
2655        name: "browser_hover",
2656        description: "Hover an element by `ref` (native, Chromium and Firefox) or CSS `selector` \
2657                      (Playwright sidecar, Chromium).",
2658        method: "hover",
2659        params: vec![
2660            SidecarParam {
2661                name: "selector",
2662                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2663                required: false,
2664            },
2665            SidecarParam {
2666                name: "ref",
2667                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2668                required: false,
2669            },
2670            SidecarParam {
2671                name: "timeout_ms",
2672                schema: json!({"type": "integer"}),
2673                required: false,
2674            },
2675        ],
2676        success: "hovered",
2677        native: Some(NativeAction::Hover),
2678        ref_pairs: &[("selector", "ref")],
2679    }
2680    .build()
2681}
2682
2683fn make_drag() -> RegisteredTool {
2684    SidecarTool {
2685        name: "browser_drag",
2686        description: "Drag one element onto another, by refs (`source_ref`/`target_ref`, native \
2687                      pointer events on Chromium and Firefox) or CSS selectors \
2688                      (`source_selector`/`target_selector`, Playwright sidecar, Chromium).",
2689        method: "drag",
2690        params: vec![
2691            SidecarParam {
2692                name: "source_selector",
2693                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `source_ref`."}),
2694                required: false,
2695            },
2696            SidecarParam {
2697                name: "target_selector",
2698                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `target_ref`."}),
2699                required: false,
2700            },
2701            SidecarParam {
2702                name: "source_ref",
2703                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2704                required: false,
2705            },
2706            SidecarParam {
2707                name: "target_ref",
2708                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2709                required: false,
2710            },
2711        ],
2712        success: "dragged",
2713        native: Some(NativeAction::Drag),
2714        ref_pairs: &[
2715            ("source_selector", "source_ref"),
2716            ("target_selector", "target_ref"),
2717        ],
2718    }
2719    .build()
2720}
2721
2722fn make_press_key() -> RegisteredTool {
2723    SidecarTool {
2724        name: "browser_press_key",
2725        description: "Press a keyboard key (Playwright key name, e.g. 'Enter', 'Control+A'). \
2726                      Chromium-only.",
2727        method: "press_key",
2728        params: vec![SidecarParam {
2729            name: "key",
2730            schema: json!({"type": "string"}),
2731            required: true,
2732        }],
2733        success: "pressed",
2734        native: None,
2735        ref_pairs: &[],
2736    }
2737    .build()
2738}
2739
2740fn make_wait_for() -> RegisteredTool {
2741    SidecarTool {
2742        name: "browser_wait_for",
2743        description: "Wait for a condition: a selector reaching `state`, a URL matching \
2744                      `url_regex`, or the page reaching `load_state` (`load` / \
2745                      `domcontentloaded` / `networkidle`). Chromium-only.",
2746        method: "wait_for",
2747        params: vec![
2748            SidecarParam { name: "selector", schema: json!({"type": "string"}), required: false },
2749            SidecarParam { name: "state", schema: json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), required: false },
2750            SidecarParam { name: "url_regex", schema: json!({"type": "string"}), required: false },
2751            SidecarParam { name: "load_state", schema: json!({"type": "string", "enum": ["load", "domcontentloaded", "networkidle"]}), required: false },
2752            SidecarParam { name: "timeout_ms", schema: json!({"type": "integer"}), required: false },
2753        ],
2754        success: "ok",
2755        native: None,
2756        ref_pairs: &[],
2757    }
2758    .build()
2759}
2760
2761fn make_pdf_save() -> RegisteredTool {
2762    RegisteredTool {
2763        name: "browser_pdf_save".into(),
2764        description: "Render the active page to PDF (base64 in `pdf_base64`). Chromium-only."
2765            .into(),
2766        input_schema: json!({
2767            "type": "object",
2768            "properties": tab_args_schema(),
2769        }),
2770        handler: handler(|state, args| {
2771            Box::pin(async move {
2772                let v = forward_to_sidecar(
2773                    &state,
2774                    "browser_pdf_save",
2775                    &args,
2776                    "pdf",
2777                    serde_json::Map::new(),
2778                )
2779                .await?;
2780                let b64 = v
2781                    .get("pdf_base64")
2782                    .and_then(|s| s.as_str())
2783                    .unwrap_or_default();
2784                Ok(json!({
2785                    "content": [{
2786                        "type": "resource",
2787                        "resource": { "mimeType": "application/pdf", "blob": b64 }
2788                    }]
2789                }))
2790            })
2791        }),
2792    }
2793}
2794
2795/// Helper: copy a key from `args` into `dst` if present.
2796fn copy_arg(args: &Value, key: &str, dst: &mut serde_json::Map<String, Value>) {
2797    if let Some(v) = args.get(key) {
2798        dst.insert(key.into(), v.clone());
2799    }
2800}
2801
2802#[cfg(test)]
2803mod tests {
2804    use super::*;
2805    use futures_util::{SinkExt, StreamExt};
2806    use tokio::sync::Mutex;
2807    use tokio_tungstenite::tungstenite::Message;
2808
2809    /// All tools the registry exposes after `register_all`. Mirrors the
2810    /// registration order in `register_all`.
2811    const EXPECTED_TOOLS: &[&str] = &[
2812        "browser_navigate",
2813        "browser_eval",
2814        "browser_get_html",
2815        "browser_get_page_text",
2816        "browser_take_screenshot",
2817        "browser_fetch",
2818        "browser_curl",
2819        "browser_select_element",
2820        "browser_cookies",
2821        "browser_storage_get",
2822        "browser_storage_set",
2823        "browser_wait_for_cookie",
2824        "browser_console_messages",
2825        "browser_network_requests",
2826        "browser_network_body",
2827        "list_targets",
2828        "browser_tab_list",
2829        "browser_tab_new",
2830        "browser_tab_select",
2831        "browser_tab_close",
2832        "browser_tab_foreground",
2833        "browser_start",
2834        "browser_select",
2835        "browser_list",
2836        "browser_show",
2837        "browser_snapshot",
2838        "browser_find",
2839        "browser_click",
2840        "browser_type",
2841        "browser_hover",
2842        "browser_drag",
2843        "browser_press_key",
2844        "browser_wait_for",
2845        "browser_pdf_save",
2846    ];
2847
2848    fn schema_for(name: &str) -> Value {
2849        let registry = ToolRegistry::new();
2850        register_all(&registry);
2851        registry
2852            .list()
2853            .into_iter()
2854            .find(|t| t["name"] == name)
2855            .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
2856            .clone()
2857    }
2858
2859    fn tool_description(name: &str) -> String {
2860        let registry = ToolRegistry::new();
2861        register_all(&registry);
2862        registry
2863            .list()
2864            .into_iter()
2865            .find(|t| t["name"] == name)
2866            .unwrap_or_else(|| panic!("tool {name} not registered"))["description"]
2867            .as_str()
2868            .unwrap_or("")
2869            .to_string()
2870    }
2871
2872    struct ScreenshotMock {
2873        endpoint: String,
2874        capture_params: Arc<Mutex<Vec<Value>>>,
2875    }
2876
2877    async fn spawn_screenshot_mock(selector_rect: Value) -> ScreenshotMock {
2878        spawn_screenshot_mock_with_data(selector_rect, "PNGDATA".into()).await
2879    }
2880
2881    /// Minimal PNG header (signature + IHDR) for a 1280x720 image; enough
2882    /// for `image_dimensions`, not a decodable file.
2883    fn fake_png_1280x720() -> Vec<u8> {
2884        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2885        png.extend_from_slice(&[0, 0, 0, 13]);
2886        png.extend_from_slice(b"IHDR");
2887        png.extend_from_slice(&1280u32.to_be_bytes());
2888        png.extend_from_slice(&720u32.to_be_bytes());
2889        png
2890    }
2891
2892    /// `selector_rect` is returned by every `Runtime.evaluate` after the
2893    /// first (the routing probe), so it doubles as the `devicePixelRatio`
2894    /// answer for `max_width` tests. `data` is the capture payload.
2895    async fn spawn_screenshot_mock_with_data(selector_rect: Value, data: String) -> ScreenshotMock {
2896        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2897        let addr = listener.local_addr().unwrap();
2898        let capture_params = Arc::new(Mutex::new(Vec::new()));
2899        tokio::spawn({
2900            let capture_params = capture_params.clone();
2901            async move {
2902                let (stream, _) = listener.accept().await.unwrap();
2903                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2904                let mut next_session = 0u32;
2905                let mut eval_count = 0u32;
2906                while let Some(Ok(Message::Text(t))) = ws.next().await {
2907                    let req: Value = serde_json::from_str(&t).unwrap();
2908                    let id = req["id"].as_u64().unwrap();
2909                    let method = req["method"].as_str().unwrap_or("");
2910                    let result = match method {
2911                        "Target.getTargets" => json!({
2912                            "targetInfos": [{
2913                                "targetId": "T1",
2914                                "type": "page",
2915                                "url": "https://example.com/",
2916                                "title": "Example",
2917                            }]
2918                        }),
2919                        "Target.attachToTarget" => {
2920                            next_session += 1;
2921                            json!({"sessionId": format!("S{next_session}")})
2922                        }
2923                        "Target.detachFromTarget" => json!({}),
2924                        "Inspector.enable" => json!({}),
2925                        "Runtime.evaluate" => {
2926                            eval_count += 1;
2927                            if eval_count == 1 {
2928                                json!({"result": {"value": 1}})
2929                            } else {
2930                                json!({"result": {"value": selector_rect.clone()}})
2931                            }
2932                        }
2933                        "Page.captureScreenshot" => {
2934                            capture_params.lock().await.push(req["params"].clone());
2935                            json!({"data": data})
2936                        }
2937                        "Page.getLayoutMetrics" => json!({
2938                            "cssLayoutViewport": {"pageX": 0, "pageY": 100, "clientWidth": 1000, "clientHeight": 500},
2939                            "cssContentSize": {"width": 1000, "height": 3000},
2940                        }),
2941                        _ => json!({}),
2942                    };
2943                    let resp = json!({"id": id, "result": result});
2944                    ws.send(Message::Text(resp.to_string())).await.unwrap();
2945                }
2946            }
2947        });
2948        ScreenshotMock {
2949            endpoint: format!("ws://{addr}"),
2950            capture_params,
2951        }
2952    }
2953
2954    #[test]
2955    fn register_all_includes_expected_set() {
2956        let registry = ToolRegistry::new();
2957        register_all(&registry);
2958        let list = registry.list();
2959        let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
2960        for expected in EXPECTED_TOOLS {
2961            assert!(
2962                names.contains(expected),
2963                "missing tool {expected} in {names:?}"
2964            );
2965        }
2966        assert_eq!(
2967            list.len(),
2968            EXPECTED_TOOLS.len(),
2969            "extra tools present: {names:?}"
2970        );
2971    }
2972
2973    #[test]
2974    fn every_tool_has_object_input_schema() {
2975        let registry = ToolRegistry::new();
2976        register_all(&registry);
2977        for t in registry.list() {
2978            let schema = &t["inputSchema"];
2979            assert!(schema.is_object(), "schema not object: {schema}");
2980            assert_eq!(
2981                schema["type"], "object",
2982                "schema type != object for {}: {schema}",
2983                t["name"]
2984            );
2985        }
2986    }
2987
2988    #[test]
2989    fn list_targets_schema_has_optional_filter() {
2990        let schema = schema_for("list_targets");
2991        assert_eq!(schema["properties"]["filter"]["type"], "string");
2992        assert!(
2993            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
2994        );
2995    }
2996
2997    #[test]
2998    fn browser_cookies_schema_has_optional_filters() {
2999        let schema = schema_for("browser_cookies");
3000        assert_eq!(schema["properties"]["domain"]["type"], "string");
3001        assert_eq!(schema["properties"]["name"]["type"], "string");
3002        assert!(
3003            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3004        );
3005    }
3006
3007    #[test]
3008    fn browser_eval_requires_expression_and_supports_routing() {
3009        let schema = schema_for("browser_eval");
3010        let required = schema["required"].as_array().expect("required array");
3011        assert!(required.iter().any(|v| v == "expression"));
3012        assert_eq!(schema["properties"]["expression"]["type"], "string");
3013        assert_eq!(schema["properties"]["await_promise"]["type"], "boolean");
3014        assert_eq!(schema["properties"]["timeout_ms"]["type"], "number");
3015        assert_eq!(schema["properties"]["tab"]["type"], "string");
3016        assert_eq!(schema["properties"]["target"]["type"], "string");
3017    }
3018
3019    #[test]
3020    fn browser_storage_get_requires_key() {
3021        let schema = schema_for("browser_storage_get");
3022        let required = schema["required"].as_array().expect("required array");
3023        assert!(required.iter().any(|v| v == "key"));
3024        assert_eq!(schema["properties"]["key"]["type"], "string");
3025        assert_eq!(schema["properties"]["namespace"]["type"], "string");
3026    }
3027
3028    #[test]
3029    fn browser_storage_set_requires_key_and_value() {
3030        let schema = schema_for("browser_storage_set");
3031        let required: Vec<&str> = schema["required"]
3032            .as_array()
3033            .unwrap()
3034            .iter()
3035            .map(|v| v.as_str().unwrap())
3036            .collect();
3037        assert!(required.contains(&"key"));
3038        assert!(required.contains(&"value"));
3039        assert_eq!(schema["properties"]["value"]["type"], "string");
3040    }
3041
3042    #[test]
3043    fn browser_wait_for_cookie_requires_domain_and_name() {
3044        let schema = schema_for("browser_wait_for_cookie");
3045        let required: Vec<&str> = schema["required"]
3046            .as_array()
3047            .unwrap()
3048            .iter()
3049            .map(|v| v.as_str().unwrap())
3050            .collect();
3051        assert!(required.contains(&"domain"));
3052        assert!(required.contains(&"name"));
3053        assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
3054        assert_eq!(
3055            schema["properties"]["poll_interval_seconds"]["type"],
3056            "number"
3057        );
3058    }
3059
3060    #[test]
3061    fn browser_navigate_schema_has_tab_and_target() {
3062        // Per-tab tools expose optional `tab`/`target` for routing.
3063        let schema = schema_for("browser_navigate");
3064        assert_eq!(schema["properties"]["tab"]["type"], "string");
3065        assert_eq!(schema["properties"]["target"]["type"], "string");
3066        let required: Vec<&str> = schema["required"]
3067            .as_array()
3068            .unwrap()
3069            .iter()
3070            .map(|v| v.as_str().unwrap())
3071            .collect();
3072        assert!(required.contains(&"url"));
3073        assert!(!required.contains(&"tab"));
3074        assert!(!required.contains(&"target"));
3075    }
3076
3077    #[test]
3078    fn browser_tab_select_requires_target_id() {
3079        let schema = schema_for("browser_tab_select");
3080        let required: Vec<&str> = schema["required"]
3081            .as_array()
3082            .unwrap()
3083            .iter()
3084            .map(|v| v.as_str().unwrap())
3085            .collect();
3086        assert!(required.contains(&"target_id"));
3087    }
3088
3089    #[test]
3090    fn browser_tab_close_target_id_is_optional() {
3091        // Default = close active tab; no required args.
3092        let schema = schema_for("browser_tab_close");
3093        assert!(
3094            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3095        );
3096        assert_eq!(schema["properties"]["target_id"]["type"], "string");
3097    }
3098
3099    #[test]
3100    fn browser_select_requires_name() {
3101        let schema = schema_for("browser_select");
3102        let required: Vec<&str> = schema["required"]
3103            .as_array()
3104            .unwrap()
3105            .iter()
3106            .map(|v| v.as_str().unwrap())
3107            .collect();
3108        assert!(required.contains(&"name"));
3109    }
3110
3111    #[test]
3112    fn browser_select_description_documents_failed_lock_contract() {
3113        let desc = tool_description("browser_select");
3114        assert!(desc.contains("committed before"));
3115        assert!(desc.contains("new browser remains active"));
3116        assert!(desc.contains("switch back"));
3117    }
3118
3119    #[test]
3120    fn browser_list_has_no_args() {
3121        let schema = schema_for("browser_list");
3122        assert_eq!(schema["properties"], json!({}));
3123    }
3124
3125    #[test]
3126    fn browser_cookies_schema_has_no_tab_arg() {
3127        // Cookies are browser-wide; no per-tab routing.
3128        let schema = schema_for("browser_cookies");
3129        assert!(schema["properties"].get("tab").is_none());
3130        assert!(schema["properties"].get("target").is_none());
3131    }
3132
3133    /// Sidecar-routed tools expose `tab`/`target` for the same routing
3134    /// surface as the other per-tab tools.
3135    #[test]
3136    fn sidecar_tools_expose_tab_and_target() {
3137        for name in &[
3138            "browser_snapshot",
3139            "browser_click",
3140            "browser_type",
3141            "browser_hover",
3142            "browser_drag",
3143            "browser_press_key",
3144            "browser_wait_for",
3145            "browser_pdf_save",
3146        ] {
3147            let schema = schema_for(name);
3148            assert_eq!(
3149                schema["properties"]["tab"]["type"], "string",
3150                "{name} missing tab arg"
3151            );
3152            assert_eq!(
3153                schema["properties"]["target"]["type"], "string",
3154                "{name} missing target arg"
3155            );
3156        }
3157    }
3158
3159    /// `selector` is no longer statically required on the interaction
3160    /// tools (a `ref` is the alternative; `route_mode` enforces exactly
3161    /// one at call time). `text` stays required on `browser_type`;
3162    /// `browser_snapshot` / `browser_pdf_save` have no required args.
3163    #[test]
3164    fn sidecar_tools_required_args() {
3165        let click = schema_for("browser_click");
3166        assert!(
3167            click.get("required").is_none() || click["required"].as_array().unwrap().is_empty()
3168        );
3169        assert_eq!(click["properties"]["selector"]["type"], "string");
3170        assert_eq!(click["properties"]["ref"]["type"], "string");
3171
3172        let t = schema_for("browser_type");
3173        let req: Vec<&str> = t["required"]
3174            .as_array()
3175            .unwrap()
3176            .iter()
3177            .map(|v| v.as_str().unwrap())
3178            .collect();
3179        assert_eq!(req, vec!["text"]);
3180        assert_eq!(t["properties"]["ref"]["type"], "string");
3181        assert_eq!(t["properties"]["submit"]["type"], "boolean");
3182
3183        let hover = schema_for("browser_hover");
3184        assert_eq!(hover["properties"]["ref"]["type"], "string");
3185        let drag = schema_for("browser_drag");
3186        assert_eq!(drag["properties"]["source_ref"]["type"], "string");
3187        assert_eq!(drag["properties"]["target_ref"]["type"], "string");
3188        assert!(drag.get("required").is_none());
3189
3190        // No required args on these.
3191        let snap = schema_for("browser_snapshot");
3192        assert!(snap.get("required").is_none() || snap["required"].as_array().unwrap().is_empty());
3193        for key in ["interactive_only", "max_chars", "ref", "depth"] {
3194            assert!(
3195                snap["properties"][key].is_object(),
3196                "snapshot missing {key}"
3197            );
3198        }
3199        let pdf = schema_for("browser_pdf_save");
3200        assert!(pdf.get("required").is_none() || pdf["required"].as_array().unwrap().is_empty());
3201
3202        let find = schema_for("browser_find");
3203        assert_eq!(find["required"], json!(["query"]));
3204        assert_eq!(find["properties"]["tab"]["type"], "string");
3205    }
3206
3207    #[test]
3208    fn route_mode_validates_selector_ref_pairs() {
3209        let pairs = &[("selector", "ref")];
3210        assert_eq!(
3211            route_mode(&json!({"ref": "e1"}), pairs).unwrap(),
3212            Route::Native
3213        );
3214        assert_eq!(
3215            route_mode(&json!({"selector": "#x"}), pairs).unwrap(),
3216            Route::Sidecar
3217        );
3218        let err = route_mode(&json!({}), pairs).unwrap_err().to_string();
3219        assert!(err.contains("exactly one of `selector`"), "{err}");
3220        let err = route_mode(&json!({"selector": "#x", "ref": "e1"}), pairs)
3221            .unwrap_err()
3222            .to_string();
3223        assert!(err.contains("mutually exclusive"), "{err}");
3224
3225        let drag = &[
3226            ("source_selector", "source_ref"),
3227            ("target_selector", "target_ref"),
3228        ];
3229        let err = route_mode(&json!({"source_ref": "e1", "target_selector": "#y"}), drag)
3230            .unwrap_err()
3231            .to_string();
3232        assert!(err.contains("not a mix"), "{err}");
3233        assert_eq!(
3234            route_mode(&json!({"source_ref": "e1", "target_ref": "e2"}), drag).unwrap(),
3235            Route::Native
3236        );
3237        // Sidecar-only tools never route natively.
3238        assert_eq!(route_mode(&json!({}), &[]).unwrap(), Route::Sidecar);
3239    }
3240
3241    #[tokio::test]
3242    async fn click_without_selector_or_ref_errors_before_backend() {
3243        let h = handler_for("browser_click");
3244        let err = h(unreached_state(), json!({}))
3245            .await
3246            .expect_err("must error");
3247        assert!(err.to_string().contains("exactly one of"), "got: {err:#}");
3248        let h = handler_for("browser_drag");
3249        let err = h(
3250            unreached_state(),
3251            json!({"source_ref": "e1", "target_selector": "#a"}),
3252        )
3253        .await
3254        .expect_err("must error");
3255        assert!(err.to_string().contains("not a mix"), "got: {err:#}");
3256    }
3257
3258    #[tokio::test]
3259    async fn snapshot_rejects_bad_options_before_backend() {
3260        let h = handler_for("browser_snapshot");
3261        let err = h(unreached_state(), json!({"max_chars": 10}))
3262            .await
3263            .expect_err("must error");
3264        assert!(err.to_string().contains("at least 1000"), "got: {err:#}");
3265        let h = handler_for("browser_find");
3266        let err = h(unreached_state(), json!({"query": "  "}))
3267            .await
3268            .expect_err("must error");
3269        assert!(err.to_string().contains("missing 'query'"), "got: {err:#}");
3270    }
3271
3272    /// BiDi-framed mock for the native tools on Firefox: session handshake,
3273    /// one context, marker-dispatched `script.callFunction`, a mutable
3274    /// document token, and recorded `input.performActions`.
3275    struct BidiA11yMock {
3276        endpoint: String,
3277        doc_token: Arc<std::sync::atomic::AtomicU64>,
3278        requests: Arc<Mutex<Vec<Value>>>,
3279    }
3280
3281    async fn spawn_bidi_a11y_mock() -> BidiA11yMock {
3282        use std::sync::atomic::{AtomicU64, Ordering};
3283        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3284        let addr = listener.local_addr().unwrap();
3285        let doc_token = Arc::new(AtomicU64::new(4294967296));
3286        let requests = Arc::new(Mutex::new(Vec::new()));
3287        tokio::spawn({
3288            let doc_token = doc_token.clone();
3289            let requests = requests.clone();
3290            async move {
3291                let (stream, _) = listener.accept().await.unwrap();
3292                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3293                while let Some(Ok(Message::Text(t))) = ws.next().await {
3294                    let req: Value = serde_json::from_str(&t).unwrap();
3295                    requests.lock().await.push(req.clone());
3296                    let id = req["id"].as_u64().unwrap();
3297                    let method = req["method"].as_str().unwrap_or("");
3298                    let decl = req["params"]["functionDeclaration"].as_str().unwrap_or("");
3299                    let expr = req["params"]["expression"].as_str().unwrap_or("");
3300                    let string = |s: String| json!({"type": "success", "result": {"type": "string", "value": s}, "realm": "R1"});
3301                    let result = match method {
3302                        "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3303                        "browsingContext.getTree" => json!({"contexts": [
3304                            {"context": "CTX1", "url": "https://example.com/", "children": []}
3305                        ]}),
3306                        "browsingContext.captureScreenshot" => json!({"data": "PNGDATA"}),
3307                        "script.callFunction" if decl.contains("bc:snapshot") => string(
3308                            json!({"nodes": [
3309                                {"nodeId": "root", "backendDOMNodeId": 4294967296u64,
3310                                 "role": {"value": "RootWebArea"}, "name": {"value": "Example"}, "childIds": ["n1", "n2"]},
3311                                {"nodeId": "n1", "parentId": "root", "backendDOMNodeId": 1,
3312                                 "role": {"value": "button"}, "name": {"value": "Submit"}, "childIds": [],
3313                                 "properties": [{"name": "focusable", "value": {"value": true}}]},
3314                                {"nodeId": "n2", "parentId": "root", "backendDOMNodeId": 2,
3315                                 "role": {"value": "link"}, "name": {"value": "Docs"}, "childIds": [],
3316                                 "properties": [{"name": "focusable", "value": {"value": true}}]}
3317                            ], "truncated": false})
3318                            .to_string(),
3319                        ),
3320                        "script.callFunction" if decl.contains("bc:center") => {
3321                            string("{\"x\":30,\"y\":20}".into())
3322                        }
3323                        "script.callFunction" if decl.contains("bc:clip") => {
3324                            string("{\"x\":10,\"y\":30,\"width\":100,\"height\":20}".into())
3325                        }
3326                        "script.callFunction" if decl.contains("bc:type") => {
3327                            string("{\"kind\":\"field\",\"method\":\"execCommand\"}".into())
3328                        }
3329                        "script.evaluate" if expr.contains("__bcDocToken") => {
3330                            string(doc_token.load(Ordering::SeqCst).to_string())
3331                        }
3332                        "script.evaluate" => {
3333                            json!({"type": "success", "result": {"type": "number", "value": 1}, "realm": "R1"})
3334                        }
3335                        _ => json!({}),
3336                    };
3337                    ws.send(Message::Text(
3338                        json!({"type": "success", "id": id, "result": result}).to_string(),
3339                    ))
3340                    .await
3341                    .unwrap();
3342                }
3343            }
3344        });
3345        BidiA11yMock {
3346            endpoint: format!("ws://{addr}"),
3347            doc_token,
3348            requests,
3349        }
3350    }
3351
3352    fn bidi_state(endpoint: &str) -> ServerState {
3353        ServerState::new(ResolvedBrowser {
3354            engine: Engine::Bidi,
3355            endpoint: endpoint.to_string(),
3356            source: Source::External,
3357        })
3358    }
3359
3360    #[tokio::test]
3361    async fn bidi_snapshot_then_click_by_ref_performs_actions() {
3362        use std::sync::atomic::Ordering;
3363        let mock = spawn_bidi_a11y_mock().await;
3364        let state = bidi_state(&mock.endpoint);
3365        let route = json!({"target": "example\\.com"});
3366
3367        let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3368            .await
3369            .unwrap();
3370        assert_eq!(
3371            snap["content"][0]["text"],
3372            "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3373        );
3374
3375        let mut args = route.clone();
3376        args["ref"] = json!("e1");
3377        let out = handler_for("browser_click")(state.clone(), args.clone())
3378            .await
3379            .unwrap();
3380        assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3381        {
3382            let reqs = mock.requests.lock().await;
3383            let perform = reqs
3384                .iter()
3385                .find(|r| r["method"] == "input.performActions")
3386                .expect("performActions");
3387            assert_eq!(perform["params"]["context"], "CTX1");
3388            let acts = &perform["params"]["actions"][0]["actions"];
3389            assert_eq!(acts[0]["type"], "pointerMove");
3390            assert_eq!(acts[0]["x"], 30);
3391            assert_eq!(acts[1]["type"], "pointerDown");
3392            assert_eq!(acts[2]["type"], "pointerUp");
3393            assert!(reqs.iter().any(|r| r["method"] == "script.callFunction"
3394                && r["params"]["arguments"][0]["value"] == 1));
3395        }
3396        assert!(state.sidecar.lock().await.is_none());
3397
3398        let mut find_args = route.clone();
3399        find_args["query"] = json!("docs");
3400        let out = handler_for("browser_find")(state.clone(), find_args)
3401            .await
3402            .unwrap();
3403        assert_eq!(
3404            out["content"][0]["text"],
3405            "1 match for \"docs\":\ne2 link \"Docs\"\n"
3406        );
3407
3408        mock.doc_token.store(4294967297, Ordering::SeqCst);
3409        let err = handler_for("browser_click")(state.clone(), args.clone())
3410            .await
3411            .unwrap_err();
3412        assert!(matches!(
3413            err.downcast_ref::<SessionError>(),
3414            Some(SessionError::StaleRef {
3415                reason: "document changed",
3416                ..
3417            })
3418        ));
3419        let err = handler_for("browser_click")(state.clone(), args)
3420            .await
3421            .unwrap_err();
3422        assert!(matches!(
3423            err.downcast_ref::<SessionError>(),
3424            Some(SessionError::RefUnknown { .. })
3425        ));
3426    }
3427
3428    #[tokio::test]
3429    async fn bidi_type_by_ref_fills_and_submits() {
3430        let mock = spawn_bidi_a11y_mock().await;
3431        let state = bidi_state(&mock.endpoint);
3432        let route = json!({"target": "example\\.com"});
3433        handler_for("browser_snapshot")(state.clone(), route.clone())
3434            .await
3435            .unwrap();
3436        let mut args = route.clone();
3437        args["ref"] = json!("e1");
3438        args["text"] = json!("hello");
3439        args["submit"] = json!(true);
3440        let out = handler_for("browser_type")(state.clone(), args)
3441            .await
3442            .unwrap();
3443        assert_eq!(
3444            out["content"][0]["text"],
3445            "typed into e1 (button \"Submit\") and pressed Enter"
3446        );
3447        let reqs = mock.requests.lock().await;
3448        let typed = reqs
3449            .iter()
3450            .find(|r| {
3451                r["params"]["functionDeclaration"]
3452                    .as_str()
3453                    .is_some_and(|d| d.contains("bc:type"))
3454            })
3455            .expect("type helper");
3456        assert_eq!(typed["params"]["arguments"][1]["value"], "hello");
3457        assert_eq!(typed["params"]["arguments"][2]["value"], "fill");
3458        let keys = reqs
3459            .iter()
3460            .find(|r| r["method"] == "input.performActions")
3461            .expect("enter");
3462        assert_eq!(keys["params"]["actions"][0]["type"], "key");
3463        assert_eq!(
3464            keys["params"]["actions"][0]["actions"][0]["value"],
3465            "\u{e007}"
3466        );
3467    }
3468
3469    #[tokio::test]
3470    async fn bidi_screenshot_by_ref_and_full_page_clip_to_document() {
3471        let mock = spawn_bidi_a11y_mock().await;
3472        let state = bidi_state(&mock.endpoint);
3473        let route = json!({"target": "example\\.com"});
3474        handler_for("browser_snapshot")(state.clone(), route.clone())
3475            .await
3476            .unwrap();
3477        let mut args = route.clone();
3478        args["ref"] = json!("e2");
3479        let out = handler_for("browser_take_screenshot")(state.clone(), args)
3480            .await
3481            .unwrap();
3482        assert_eq!(out["content"][0]["type"], "image");
3483        let reqs = mock.requests.lock().await;
3484        let cap = reqs
3485            .iter()
3486            .find(|r| r["method"] == "browsingContext.captureScreenshot")
3487            .expect("capture");
3488        assert_eq!(cap["params"]["origin"], "document");
3489        assert_eq!(cap["params"]["clip"]["type"], "box");
3490        assert_eq!(cap["params"]["clip"]["x"], 10);
3491        assert_eq!(cap["params"]["clip"]["width"], 100);
3492    }
3493
3494    /// CDP mock serving an accessibility tree, document identity, and
3495    /// element geometry; records every request.
3496    struct A11yMock {
3497        endpoint: String,
3498        doc_token: Arc<std::sync::atomic::AtomicU64>,
3499        requests: Arc<Mutex<Vec<Value>>>,
3500    }
3501
3502    async fn spawn_a11y_mock() -> A11yMock {
3503        use std::sync::atomic::{AtomicU64, Ordering};
3504        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3505        let addr = listener.local_addr().unwrap();
3506        let doc_token = Arc::new(AtomicU64::new(101));
3507        let requests = Arc::new(Mutex::new(Vec::new()));
3508        tokio::spawn({
3509            let doc_token = doc_token.clone();
3510            let requests = requests.clone();
3511            async move {
3512                let (stream, _) = listener.accept().await.unwrap();
3513                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3514                let mut next_session = 0u32;
3515                while let Some(Ok(Message::Text(t))) = ws.next().await {
3516                    let req: Value = serde_json::from_str(&t).unwrap();
3517                    requests.lock().await.push(req.clone());
3518                    let id = req["id"].as_u64().unwrap();
3519                    let method = req["method"].as_str().unwrap_or("");
3520                    let result = match method {
3521                        "Target.getTargets" => json!({"targetInfos": [{
3522                            "targetId": "T1", "type": "page",
3523                            "url": "https://example.com/", "title": "Example",
3524                        }]}),
3525                        "Target.attachToTarget" => {
3526                            next_session += 1;
3527                            json!({"sessionId": format!("S{next_session}")})
3528                        }
3529                        "Runtime.evaluate" => json!({"result": {"value": 1}}),
3530                        "Accessibility.getFullAXTree" => json!({"nodes": [
3531                            {"nodeId": "1", "backendDOMNodeId": 101,
3532                             "role": {"value": "RootWebArea"}, "name": {"value": "Example"},
3533                             "childIds": ["2", "3"]},
3534                            {"nodeId": "2", "parentId": "1", "backendDOMNodeId": 106,
3535                             "role": {"value": "button"}, "name": {"value": "Submit"},
3536                             "properties": [{"name": "focusable", "value": {"value": true}}],
3537                             "childIds": []},
3538                            {"nodeId": "3", "parentId": "1", "backendDOMNodeId": 108,
3539                             "role": {"value": "link"}, "name": {"value": "Docs"},
3540                             "properties": [{"name": "focusable", "value": {"value": true}}],
3541                             "childIds": []},
3542                        ]}),
3543                        "DOM.getDocument" => {
3544                            json!({"root": {"backendNodeId": doc_token.load(Ordering::SeqCst)}})
3545                        }
3546                        "DOM.getContentQuads" => {
3547                            json!({"quads": [[10, 10, 50, 10, 50, 30, 10, 30]]})
3548                        }
3549                        "DOM.resolveNode" => json!({"object": {"objectId": "obj-1"}}),
3550                        "DOM.getBoxModel" => {
3551                            json!({"model": {"border": [10, 30, 110, 30, 110, 50, 10, 50]}})
3552                        }
3553                        "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
3554                        "Page.getLayoutMetrics" => json!({"cssLayoutViewport": {
3555                            "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600
3556                        }}),
3557                        _ => json!({}),
3558                    };
3559                    let resp = json!({"id": id, "result": result});
3560                    ws.send(Message::Text(resp.to_string())).await.unwrap();
3561                }
3562            }
3563        });
3564        A11yMock {
3565            endpoint: format!("ws://{addr}"),
3566            doc_token,
3567            requests,
3568        }
3569    }
3570
3571    #[tokio::test]
3572    async fn snapshot_then_click_by_ref_dispatches_native_input() {
3573        use std::sync::atomic::Ordering;
3574        let mock = spawn_a11y_mock().await;
3575        let state = ServerState::new(ResolvedBrowser {
3576            engine: Engine::Cdp,
3577            endpoint: mock.endpoint.clone(),
3578            source: Source::External,
3579        });
3580        let route = json!({"target": "example\\.com"});
3581
3582        let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3583            .await
3584            .unwrap();
3585        let text = snap["content"][0]["text"].as_str().unwrap();
3586        assert_eq!(
3587            text,
3588            "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3589        );
3590
3591        // Refs are stable across a second snapshot of the same document.
3592        let again = handler_for("browser_snapshot")(state.clone(), route.clone())
3593            .await
3594            .unwrap();
3595        assert_eq!(again["content"][0]["text"], snap["content"][0]["text"]);
3596
3597        let mut args = route.clone();
3598        args["ref"] = json!("e1");
3599        let out = handler_for("browser_click")(state.clone(), args.clone())
3600            .await
3601            .unwrap();
3602        assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3603        {
3604            let reqs = mock.requests.lock().await;
3605            let mouse: Vec<&Value> = reqs
3606                .iter()
3607                .filter(|r| r["method"] == "Input.dispatchMouseEvent")
3608                .collect();
3609            assert_eq!(mouse.len(), 3);
3610            assert_eq!(mouse[1]["params"]["type"], "mousePressed");
3611            assert_eq!(mouse[1]["params"]["x"], 30.0);
3612            assert_eq!(mouse[1]["params"]["y"], 20.0);
3613            assert!(reqs
3614                .iter()
3615                .any(|r| r["method"] == "DOM.scrollIntoViewIfNeeded"
3616                    && r["params"]["backendNodeId"] == 106));
3617        }
3618
3619        // find hands out the same refs.
3620        let mut find_args = route.clone();
3621        find_args["query"] = json!("docs");
3622        let out = handler_for("browser_find")(state.clone(), find_args)
3623            .await
3624            .unwrap();
3625        assert_eq!(
3626            out["content"][0]["text"],
3627            "1 match for \"docs\":\ne2 link \"Docs\"\n"
3628        );
3629
3630        // Unknown ref.
3631        let mut bad = route.clone();
3632        bad["ref"] = json!("e9");
3633        let err = handler_for("browser_click")(state.clone(), bad)
3634            .await
3635            .unwrap_err();
3636        assert!(matches!(
3637            err.downcast_ref::<SessionError>(),
3638            Some(SessionError::RefUnknown { .. })
3639        ));
3640
3641        // The page navigates: document token changes, refs become stale.
3642        mock.doc_token.store(202, Ordering::SeqCst);
3643        let err = handler_for("browser_click")(state.clone(), args.clone())
3644            .await
3645            .unwrap_err();
3646        match err.downcast_ref::<SessionError>() {
3647            Some(SessionError::StaleRef { reason, .. }) => assert_eq!(*reason, "document changed"),
3648            other => panic!("expected StaleRef, got {other:?}"),
3649        }
3650        assert!(err.to_string().contains("browser_snapshot"));
3651        // The stale table was dropped, so the same ref is now unknown.
3652        let err = handler_for("browser_click")(state.clone(), args)
3653            .await
3654            .unwrap_err();
3655        assert!(matches!(
3656            err.downcast_ref::<SessionError>(),
3657            Some(SessionError::RefUnknown { .. })
3658        ));
3659    }
3660
3661    #[tokio::test]
3662    async fn type_by_ref_inserts_text_and_submits() {
3663        let mock = spawn_a11y_mock().await;
3664        let state = ServerState::new(ResolvedBrowser {
3665            engine: Engine::Cdp,
3666            endpoint: mock.endpoint.clone(),
3667            source: Source::External,
3668        });
3669        let route = json!({"target": "example\\.com"});
3670        handler_for("browser_snapshot")(state.clone(), route.clone())
3671            .await
3672            .unwrap();
3673        let mut args = route.clone();
3674        args["ref"] = json!("e1");
3675        args["text"] = json!("hello");
3676        args["submit"] = json!(true);
3677        let out = handler_for("browser_type")(state.clone(), args)
3678            .await
3679            .unwrap();
3680        assert_eq!(
3681            out["content"][0]["text"],
3682            "typed into e1 (button \"Submit\") and pressed Enter"
3683        );
3684        let reqs = mock.requests.lock().await;
3685        let insert = reqs
3686            .iter()
3687            .find(|r| r["method"] == "Input.insertText")
3688            .expect("insertText");
3689        assert_eq!(insert["params"]["text"], "hello");
3690        assert!(reqs
3691            .iter()
3692            .any(|r| r["method"] == "Input.dispatchKeyEvent" && r["params"]["key"] == "Enter"));
3693        // Nothing was forwarded to a sidecar: no Node process, no `connect`.
3694        assert!(state.sidecar.lock().await.is_none());
3695    }
3696
3697    /// CDP mock for the capture tools: hands out sessions, and after
3698    /// `Network.enable` on a session pushes one console error and one
3699    /// finished request on that session.
3700    async fn spawn_capture_mock() -> String {
3701        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3702        let addr = listener.local_addr().unwrap();
3703        tokio::spawn(async move {
3704            let (stream, _) = listener.accept().await.unwrap();
3705            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3706            let mut next_session = 0u32;
3707            while let Some(Ok(Message::Text(t))) = ws.next().await {
3708                let req: Value = serde_json::from_str(&t).unwrap();
3709                let id = req["id"].as_u64().unwrap();
3710                let method = req["method"].as_str().unwrap_or("").to_string();
3711                let sid = req["sessionId"].as_str().unwrap_or("").to_string();
3712                let result = match method.as_str() {
3713                    "Target.getTargets" => json!({"targetInfos": [{
3714                        "targetId": "T1", "type": "page",
3715                        "url": "https://app.test/", "title": "App",
3716                    }]}),
3717                    "Target.attachToTarget" => {
3718                        next_session += 1;
3719                        json!({"sessionId": format!("S{next_session}")})
3720                    }
3721                    "Runtime.evaluate" => json!({"result": {"value": 1}}),
3722                    "Page.getNavigationHistory" => json!({
3723                        "currentIndex": 0, "entries": [{"url": "https://app.test/"}]
3724                    }),
3725                    "Network.getResponseBody" => {
3726                        json!({"body": "{\"ok\":true}", "base64Encoded": false})
3727                    }
3728                    _ => json!({}),
3729                };
3730                ws.send(Message::Text(
3731                    json!({"id": id, "result": result}).to_string(),
3732                ))
3733                .await
3734                .unwrap();
3735                if method == "Network.enable" {
3736                    for ev in [
3737                        json!({"method": "Runtime.consoleAPICalled", "sessionId": sid,
3738                               "params": {"type": "error", "timestamp": 1756816496120.0,
3739                                          "args": [{"type": "string", "value": "boom"}],
3740                                          "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3741                        json!({"method": "Network.requestWillBeSent", "sessionId": sid,
3742                               "params": {"requestId": "9.1", "timestamp": 10.0, "wallTime": 1756816496.0, "type": "XHR",
3743                                          "request": {"method": "GET", "url": "https://app.test/api/me"}}}),
3744                        json!({"method": "Network.responseReceived", "sessionId": sid,
3745                               "params": {"requestId": "9.1", "type": "XHR",
3746                                          "response": {"status": 401, "mimeType": "application/json"}}}),
3747                        json!({"method": "Network.loadingFinished", "sessionId": sid,
3748                               "params": {"requestId": "9.1", "timestamp": 10.084, "encodedDataLength": 11}}),
3749                    ] {
3750                        ws.send(Message::Text(ev.to_string())).await.unwrap();
3751                    }
3752                }
3753            }
3754        });
3755        format!("ws://{addr}")
3756    }
3757
3758    #[tokio::test]
3759    async fn capture_tools_read_buffered_console_and_network_after_navigate() {
3760        let endpoint = spawn_capture_mock().await;
3761        let state = ServerState::new(ResolvedBrowser {
3762            engine: Engine::Cdp,
3763            endpoint,
3764            source: Source::External,
3765        });
3766        let route = json!({"target": "app\\.test"});
3767        let mut nav = route.clone();
3768        nav["url"] = json!("https://app.test/x");
3769        handler_for("browser_navigate")(state.clone(), nav)
3770            .await
3771            .unwrap();
3772
3773        // Events are pushed by the mock right after Network.enable; give the
3774        // router a moment (test-side bounded polling only).
3775        let mut text = String::new();
3776        for _ in 0..100 {
3777            let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3778                .await
3779                .unwrap();
3780            text = out["content"][0]["text"].as_str().unwrap().to_string();
3781            if text.contains("boom") {
3782                break;
3783            }
3784            tokio::time::sleep(Duration::from_millis(10)).await;
3785        }
3786        assert!(
3787            text.starts_with("console tab=T1 page=https://app.test/  showing 1 of 1 matched (1 buffered, 0 evicted, 0 events lost)\n-- page: https://app.test/ --\n[error] 2025-09-02T12:34:56.120Z https://app.test/a.js:4:1  boom"),
3788            "{text}"
3789        );
3790
3791        // Pattern filtering and clear.
3792        let mut q = route.clone();
3793        q["pattern"] = json!("nomatch");
3794        let out = handler_for("browser_console_messages")(state.clone(), q)
3795            .await
3796            .unwrap();
3797        assert!(out["content"][0]["text"]
3798            .as_str()
3799            .unwrap()
3800            .contains("showing 0 of 0 matched (1 buffered"));
3801        let mut q = route.clone();
3802        q["pattern"] = json!("[");
3803        let err = handler_for("browser_console_messages")(state.clone(), q)
3804            .await
3805            .unwrap_err();
3806        assert!(err.to_string().contains("invalid `pattern` regex"));
3807        let mut q = route.clone();
3808        q["limit"] = json!(0);
3809        q["clear"] = json!(true);
3810        handler_for("browser_console_messages")(state.clone(), q)
3811            .await
3812            .unwrap();
3813        let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3814            .await
3815            .unwrap();
3816        assert!(out["content"][0]["text"]
3817            .as_str()
3818            .unwrap()
3819            .contains("(0 buffered"));
3820
3821        // Network listing with filters.
3822        let mut q = route.clone();
3823        q["status"] = json!("4xx");
3824        q["url_pattern"] = json!("/api/");
3825        let out = handler_for("browser_network_requests")(state.clone(), q)
3826            .await
3827            .unwrap();
3828        let text = out["content"][0]["text"].as_str().unwrap();
3829        assert!(
3830            text.contains(
3831                "9.1  GET    https://app.test/api/me  → 401 application/json 11B 84ms [XHR]"
3832            ),
3833            "{text}"
3834        );
3835        let mut q = route.clone();
3836        q["status"] = json!("2xx");
3837        let out = handler_for("browser_network_requests")(state.clone(), q)
3838            .await
3839            .unwrap();
3840        assert!(out["content"][0]["text"]
3841            .as_str()
3842            .unwrap()
3843            .contains("showing 0 of 0 matched (1 buffered"));
3844        let mut q = route.clone();
3845        q["format"] = json!("json");
3846        let out = handler_for("browser_network_requests")(state.clone(), q)
3847            .await
3848            .unwrap();
3849        let parsed: Value =
3850            serde_json::from_str(out["content"][0]["text"].as_str().unwrap()).unwrap();
3851        assert_eq!(parsed["entries"][0]["request_id"], "9.1");
3852        assert_eq!(parsed["entries"][0]["state"], "finished");
3853
3854        // Body fetch.
3855        let mut q = route.clone();
3856        q["request_id"] = json!("9.1");
3857        let out = handler_for("browser_network_body")(state.clone(), q)
3858            .await
3859            .unwrap();
3860        assert_eq!(out["content"][0]["text"], "{\"ok\":true}");
3861        let meta: Value =
3862            serde_json::from_str(out["content"][1]["text"].as_str().unwrap()).unwrap();
3863        assert_eq!(meta["status"], 401);
3864        assert_eq!(meta["truncated"], false);
3865
3866        // Closing the tab forgets its capture. (The mock keeps listing T1,
3867        // so a later tool call would re-touch it; assert on the hub directly.)
3868        assert_eq!(state.capture.captured_tabs(), vec!["T1".to_string()]);
3869        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
3870            .await
3871            .unwrap();
3872        assert!(state.capture.captured_tabs().is_empty());
3873    }
3874
3875    #[tokio::test]
3876    async fn capture_tools_validate_before_backend_and_gate_on_engine() {
3877        let h = handler_for("browser_network_requests");
3878        let err = h(unreached_state(), json!({"status": "lots"}))
3879            .await
3880            .expect_err("must error");
3881        assert!(err.to_string().contains("`status` must be"), "{err:#}");
3882        let h = handler_for("browser_network_body");
3883        let err = h(unreached_state(), json!({}))
3884            .await
3885            .expect_err("must error");
3886        assert!(err.to_string().contains("missing 'request_id'"), "{err:#}");
3887
3888        // Only bodies are gated on the engine; the listing tools reach the
3889        // backend on Firefox (and fail here only because nothing listens).
3890        let state = ServerState::new(ResolvedBrowser {
3891            engine: Engine::Bidi,
3892            endpoint: "ws://127.0.0.1:0".into(),
3893            source: Source::External,
3894        });
3895        let err = handler_for("browser_network_body")(state.clone(), json!({"request_id": "1"}))
3896            .await
3897            .expect_err("BiDi must error");
3898        match err.downcast_ref::<SessionError>() {
3899            Some(SessionError::EngineUnsupported { tool, hint, .. }) => {
3900                assert_eq!(tool, "browser_network_body");
3901                assert!(hint.contains("browser_fetch") && hint.contains("browser_select"));
3902            }
3903            other => panic!("expected EngineUnsupported, got {other:?}"),
3904        }
3905        for tool in ["browser_console_messages", "browser_network_requests"] {
3906            let err = handler_for(tool)(state.clone(), json!({}))
3907                .await
3908                .expect_err("unreachable endpoint must error");
3909            assert!(
3910                !matches!(
3911                    err.downcast_ref::<SessionError>(),
3912                    Some(SessionError::EngineUnsupported { .. })
3913                ),
3914                "{tool} must not be engine-gated on BiDi: {err:#}"
3915            );
3916        }
3917    }
3918
3919    /// BiDi-framed CDP-free mock for the capture tools on Firefox: answers
3920    /// the session handshake, tree, navigate and subscribe, then pushes one
3921    /// console error and one finished request on context `C1`.
3922    async fn spawn_bidi_capture_mock() -> String {
3923        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3924        let addr = listener.local_addr().unwrap();
3925        tokio::spawn(async move {
3926            let (stream, _) = listener.accept().await.unwrap();
3927            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3928            while let Some(Ok(Message::Text(t))) = ws.next().await {
3929                let req: Value = serde_json::from_str(&t).unwrap();
3930                let id = req["id"].as_u64().unwrap();
3931                let method = req["method"].as_str().unwrap_or("").to_string();
3932                let result = match method.as_str() {
3933                    "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3934                    "browsingContext.getTree" => json!({"contexts": [
3935                        {"context": "C1", "url": "https://app.test/", "children": []}
3936                    ]}),
3937                    "browsingContext.navigate" => {
3938                        json!({"navigation": "N1", "url": "https://app.test/x"})
3939                    }
3940                    "script.evaluate" => {
3941                        json!({"type": "success", "result": {"type": "number", "value": 1}})
3942                    }
3943                    _ => json!({}),
3944                };
3945                ws.send(Message::Text(
3946                    json!({"type": "success", "id": id, "result": result}).to_string(),
3947                ))
3948                .await
3949                .unwrap();
3950                let first_event = req["params"]["events"][0].as_str().unwrap_or("");
3951                if method == "session.subscribe" && first_event.starts_with("network.") {
3952                    for ev in [
3953                        json!({"type": "event", "method": "log.entryAdded", "params": {
3954                            "type": "console", "level": "error", "method": "error", "text": "boom",
3955                            "timestamp": 1756816496120.0, "source": {"context": "C1"},
3956                            "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3957                        json!({"type": "event", "method": "network.beforeRequestSent", "params": {
3958                            "context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1756816496000.0,
3959                            "initiator": {"type": "other"},
3960                            "request": {"request": "9.1", "url": "https://app.test/api/me", "method": "GET", "bodySize": 0, "initiatorType": "xmlhttprequest"}}}),
3961                        json!({"type": "event", "method": "network.responseCompleted", "params": {
3962                            "context": "C1", "timestamp": 1756816496084.0, "redirectCount": 0,
3963                            "request": {"request": "9.1"},
3964                            "response": {"status": 401, "mimeType": "application/json", "bytesReceived": 11}}}),
3965                    ] {
3966                        ws.send(Message::Text(ev.to_string())).await.unwrap();
3967                    }
3968                }
3969            }
3970        });
3971        format!("ws://{addr}")
3972    }
3973
3974    #[tokio::test]
3975    async fn capture_tools_work_on_bidi_and_body_is_cdp_only() {
3976        let endpoint = spawn_bidi_capture_mock().await;
3977        let state = ServerState::new(ResolvedBrowser {
3978            engine: Engine::Bidi,
3979            endpoint,
3980            source: Source::External,
3981        });
3982        let route = json!({"target": "app\\.test"});
3983        let mut nav = route.clone();
3984        nav["url"] = json!("https://app.test/x");
3985        handler_for("browser_navigate")(state.clone(), nav)
3986            .await
3987            .unwrap();
3988        let mut text = String::new();
3989        for _ in 0..100 {
3990            let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3991                .await
3992                .unwrap();
3993            text = out["content"][0]["text"].as_str().unwrap().to_string();
3994            if text.contains("boom") {
3995                break;
3996            }
3997            tokio::time::sleep(Duration::from_millis(10)).await;
3998        }
3999        assert!(
4000            text.starts_with("console tab=C1 page=https://app.test/  showing 1 of 1 matched (1 buffered, 0 evicted, 0 events lost)\n-- page: https://app.test/ --\n[error] 2025-09-02T12:34:56.120Z https://app.test/a.js:4:1  boom"),
4001            "{text}"
4002        );
4003        let mut q = route.clone();
4004        q["status"] = json!("4xx");
4005        let out = handler_for("browser_network_requests")(state.clone(), q)
4006            .await
4007            .unwrap();
4008        let net = out["content"][0]["text"].as_str().unwrap();
4009        assert!(
4010            net.contains(
4011                "9.1  GET    https://app.test/api/me  → 401 application/json 11B 84ms [XHR]"
4012            ),
4013            "{net}"
4014        );
4015        let mut q = route.clone();
4016        q["request_id"] = json!("9.1");
4017        let err = handler_for("browser_network_body")(state.clone(), q)
4018            .await
4019            .unwrap_err();
4020        match err.downcast_ref::<SessionError>() {
4021            Some(SessionError::EngineUnsupported { hint, .. }) => {
4022                assert!(hint.contains("browser_fetch"))
4023            }
4024            other => panic!("expected EngineUnsupported, got {other:?}"),
4025        }
4026        assert_eq!(state.capture.captured_tabs(), vec!["C1".to_string()]);
4027        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "C1"}))
4028            .await
4029            .unwrap();
4030        assert!(state.capture.captured_tabs().is_empty());
4031    }
4032
4033    #[tokio::test]
4034    async fn tab_foreground_requires_registered_chromium_browser() {
4035        let state = ServerState::new(ResolvedBrowser {
4036            engine: Engine::Bidi,
4037            endpoint: "ws://127.0.0.1:0".into(),
4038            source: Source::External,
4039        });
4040        let err = handler_for("browser_tab_foreground")(state, json!({}))
4041            .await
4042            .expect_err("BiDi must error");
4043        match err.downcast_ref::<SessionError>() {
4044            Some(SessionError::EngineUnsupported { hint, .. }) => {
4045                assert!(hint.contains("setFocusEmulationEnabled"))
4046            }
4047            other => panic!("expected EngineUnsupported, got {other:?}"),
4048        }
4049        let mock = spawn_a11y_mock().await;
4050        let state = ServerState::new(ResolvedBrowser {
4051            engine: Engine::Cdp,
4052            endpoint: mock.endpoint.clone(),
4053            source: Source::External,
4054        });
4055        let err = handler_for("browser_tab_foreground")(state.clone(), json!({}))
4056            .await
4057            .expect_err("external endpoint must error");
4058        assert!(err.to_string().contains("registered browser"), "{err:#}");
4059        let err = handler_for("browser_tab_foreground")(
4060            state.clone(),
4061            json!({"enabled": true, "all": true}),
4062        )
4063        .await
4064        .expect_err("all requires enabled false");
4065        assert!(err.to_string().contains("`all` only applies"), "{err:#}");
4066        let list = handler_for("browser_tab_list")(state, json!({}))
4067            .await
4068            .unwrap();
4069        let rows: Value =
4070            serde_json::from_str(list["content"][0]["text"].as_str().unwrap()).unwrap();
4071        assert_eq!(rows[0]["foreground"], false);
4072    }
4073
4074    #[tokio::test]
4075    async fn tab_close_drops_refs() {
4076        let mock = spawn_a11y_mock().await;
4077        let state = ServerState::new(ResolvedBrowser {
4078            engine: Engine::Cdp,
4079            endpoint: mock.endpoint.clone(),
4080            source: Source::External,
4081        });
4082        handler_for("browser_snapshot")(state.clone(), json!({"target": "example\\.com"}))
4083            .await
4084            .unwrap();
4085        assert!(state.refs.lock().await.contains_key("T1"));
4086        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
4087            .await
4088            .unwrap();
4089        assert!(!state.refs.lock().await.contains_key("T1"));
4090    }
4091
4092    /// Sidecar tool against a BiDi browser must error with
4093    /// `EngineUnsupported` BEFORE attempting to spawn the sidecar — so
4094    /// even systems without Node/Bun get a clean message.
4095    #[tokio::test]
4096    async fn sidecar_tool_on_bidi_returns_engine_unsupported() {
4097        use crate::cli::env_resolver::{ResolvedBrowser, Source};
4098        use crate::detect::Engine;
4099        use crate::errors::SessionError;
4100
4101        // ServerState bound to a BiDi browser. Endpoint never gets hit
4102        // because the engine check short-circuits.
4103        let resolved = ResolvedBrowser {
4104            engine: Engine::Bidi,
4105            endpoint: "ws://127.0.0.1:0".into(),
4106            source: Source::External,
4107        };
4108        let state = ServerState::new(resolved);
4109
4110        let err = match state.ensure_sidecar("browser_snapshot").await {
4111            Ok(_) => panic!("BiDi must error"),
4112            Err(e) => e,
4113        };
4114        let typed = err.downcast_ref::<SessionError>().expect("typed error");
4115        match typed {
4116            SessionError::EngineUnsupported { tool, hint, .. } => {
4117                assert_eq!(tool, "browser_snapshot");
4118                assert!(!hint.contains(concat!("browser_", "evaluate")));
4119                assert!(hint.contains("browser_get_html"));
4120                assert!(hint.contains("browser_select"));
4121            }
4122            other => panic!("expected EngineUnsupported, got {other:?}"),
4123        }
4124    }
4125
4126    #[test]
4127    fn sidecar_cdp_attach_failure_classifier_matches_connect_layer_errors() {
4128        let err = anyhow::anyhow!(
4129            "browserType.connectOverCDP: Timeout 5000ms exceeded while <ws connecting> to ws://127.0.0.1:64767/devtools/browser/x"
4130        );
4131        assert!(looks_like_sidecar_cdp_attach_failure(&err));
4132
4133        let err = anyhow::anyhow!("page.waitForLoadState: Timeout 30000ms exceeded");
4134        assert!(
4135            !looks_like_sidecar_cdp_attach_failure(&err),
4136            "normal page wait timeouts must not be reclassified as sidecar attach failures"
4137        );
4138    }
4139
4140    #[test]
4141    fn sidecar_connection_failed_message_discourages_page_hang_inference() {
4142        let err = SessionError::SidecarConnectionFailed {
4143            tool: "browser_snapshot".into(),
4144            method: "snapshot".into(),
4145            target_id: "T1".into(),
4146            url: Some("http://localhost:5173/404".into()),
4147            details: "browserType.connectOverCDP: Timeout 5000ms exceeded".into(),
4148            hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
4149        };
4150        let msg = err.to_string();
4151        assert!(msg.contains("Playwright sidecar connection failed"));
4152        assert!(msg.contains("not evidence that the page is hung"));
4153        assert!(msg.contains("browser_get_html"));
4154    }
4155
4156    #[tokio::test]
4157    async fn screenshot_selector_sends_cdp_clip() {
4158        let mock = spawn_screenshot_mock(json!({
4159            "x": 12.5,
4160            "y": 34.0,
4161            "width": 56.0,
4162            "height": 78.0,
4163        }))
4164        .await;
4165        let state = ServerState::new(ResolvedBrowser {
4166            engine: Engine::Cdp,
4167            endpoint: mock.endpoint,
4168            source: Source::External,
4169        });
4170        let h = handler_for("browser_take_screenshot");
4171        let out = h(
4172            state,
4173            json!({
4174                "target": "example\\.com",
4175                "selector": "#main",
4176            }),
4177        )
4178        .await
4179        .unwrap();
4180        assert_eq!(out["content"][0]["type"], "image");
4181        assert_eq!(out["content"][0]["data"], "PNGDATA");
4182
4183        let captures = mock.capture_params.lock().await;
4184        assert_eq!(captures.len(), 1);
4185        assert_eq!(captures[0]["format"], "png");
4186        assert_eq!(captures[0]["captureBeyondViewport"], true);
4187        assert_eq!(captures[0]["clip"]["x"], json!(12.5));
4188        assert_eq!(captures[0]["clip"]["y"], json!(34.0));
4189        assert_eq!(captures[0]["clip"]["width"], json!(56.0));
4190        assert_eq!(captures[0]["clip"]["height"], json!(78.0));
4191        assert_eq!(captures[0]["clip"]["scale"], json!(1));
4192    }
4193
4194    #[tokio::test]
4195    async fn screenshot_jpeg_quality_and_max_width_scale_clip() {
4196        // Every post-probe evaluate returns 2.0 → devicePixelRatio = 2.
4197        let mock = spawn_screenshot_mock(json!(2.0)).await;
4198        let state = ServerState::new(ResolvedBrowser {
4199            engine: Engine::Cdp,
4200            endpoint: mock.endpoint,
4201            source: Source::External,
4202        });
4203        let out = handler_for("browser_take_screenshot")(
4204            state.clone(),
4205            json!({
4206                "target": "example\\.com",
4207                "format": "jpeg",
4208                "quality": 60,
4209                "max_width": 500,
4210            }),
4211        )
4212        .await
4213        .unwrap();
4214        assert_eq!(out["content"][0]["mimeType"], "image/jpeg");
4215        let captures = mock.capture_params.lock().await;
4216        assert_eq!(captures.len(), 1);
4217        assert_eq!(captures[0]["format"], "jpeg");
4218        assert_eq!(captures[0]["quality"], 60);
4219        // Viewport is 1000 CSS px wide at DPR 2 → 2000 device px; 500 → 0.25.
4220        assert_eq!(captures[0]["captureBeyondViewport"], true);
4221        assert_eq!(captures[0]["clip"]["x"], json!(0.0));
4222        assert_eq!(captures[0]["clip"]["y"], json!(100.0));
4223        assert_eq!(captures[0]["clip"]["width"], json!(1000.0));
4224        assert_eq!(captures[0]["clip"]["height"], json!(500.0));
4225        assert_eq!(captures[0]["clip"]["scale"], json!(0.25));
4226    }
4227
4228    #[tokio::test]
4229    async fn screenshot_max_width_larger_than_page_keeps_default_params() {
4230        let mock = spawn_screenshot_mock(json!(1.0)).await;
4231        let state = ServerState::new(ResolvedBrowser {
4232            engine: Engine::Cdp,
4233            endpoint: mock.endpoint,
4234            source: Source::External,
4235        });
4236        handler_for("browser_take_screenshot")(
4237            state,
4238            json!({"target": "example\\.com", "max_width": 4000, "full_page": true}),
4239        )
4240        .await
4241        .unwrap();
4242        let captures = mock.capture_params.lock().await;
4243        assert_eq!(captures[0]["format"], "png");
4244        assert!(captures[0].get("clip").is_none());
4245        assert!(captures[0].get("quality").is_none());
4246        assert_eq!(captures[0]["captureBeyondViewport"], true);
4247    }
4248
4249    #[tokio::test]
4250    async fn screenshot_save_to_writes_private_file_and_reports_dimensions() {
4251        use base64::Engine as _;
4252        let data = base64::engine::general_purpose::STANDARD.encode(fake_png_1280x720());
4253        let mock = spawn_screenshot_mock_with_data(Value::Null, data).await;
4254        let state = ServerState::new(ResolvedBrowser {
4255            engine: Engine::Cdp,
4256            endpoint: mock.endpoint,
4257            source: Source::External,
4258        });
4259        let dir = tempfile::TempDir::new().unwrap();
4260        let path = dir.path().join("shot.png");
4261        let out = handler_for("browser_take_screenshot")(
4262            state,
4263            json!({"target": "example\\.com", "save_to": path.to_str().unwrap()}),
4264        )
4265        .await
4266        .unwrap();
4267        assert_eq!(out["content"][0]["type"], "text");
4268        let text = out["content"][0]["text"].as_str().unwrap();
4269        assert!(
4270            text.starts_with(&format!(
4271                "Saved screenshot to {} (1280x720, image/png, 1 KiB)",
4272                path.display()
4273            )),
4274            "{text}"
4275        );
4276        let bytes = std::fs::read(&path).unwrap();
4277        assert_eq!(bytes, fake_png_1280x720());
4278        #[cfg(unix)]
4279        {
4280            use std::os::unix::fs::PermissionsExt;
4281            assert_eq!(
4282                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4283                0o600
4284            );
4285        }
4286    }
4287
4288    #[tokio::test]
4289    async fn screenshot_option_validation_fires_before_backend() {
4290        let h = handler_for("browser_take_screenshot");
4291        for (args, needle) in [
4292            (json!({"quality": 50}), "only applies to"),
4293            (json!({"format": "gif"}), "`format` must be"),
4294            (json!({"max_width": 10}), "at least 64"),
4295            (json!({"save_to": "relative.png"}), "absolute path"),
4296            (
4297                // Absolute on every platform (`/x` is relative on Windows).
4298                json!({"save_to": std::env::temp_dir().join("definitely/missing/dir/x.png")}),
4299                "parent directory",
4300            ),
4301            (json!({"selector": "#a", "ref": "e1"}), "mutually exclusive"),
4302        ] {
4303            let err = h(unreached_state(), args.clone())
4304                .await
4305                .expect_err("must error");
4306            assert!(err.to_string().contains(needle), "{args}: {err:#}");
4307        }
4308    }
4309
4310    #[tokio::test]
4311    async fn screenshot_by_ref_clips_to_node_box() {
4312        let mock = spawn_a11y_mock().await;
4313        let state = ServerState::new(ResolvedBrowser {
4314            engine: Engine::Cdp,
4315            endpoint: mock.endpoint.clone(),
4316            source: Source::External,
4317        });
4318        let route = json!({"target": "example\\.com"});
4319        handler_for("browser_snapshot")(state.clone(), route.clone())
4320            .await
4321            .unwrap();
4322        let mut args = route.clone();
4323        args["ref"] = json!("e1");
4324        let out = handler_for("browser_take_screenshot")(state.clone(), args)
4325            .await
4326            .unwrap();
4327        assert_eq!(out["content"][0]["type"], "image");
4328        let reqs = mock.requests.lock().await;
4329        let cap = reqs
4330            .iter()
4331            .find(|r| r["method"] == "Page.captureScreenshot")
4332            .expect("capture");
4333        assert_eq!(cap["params"]["clip"]["x"], json!(10.0));
4334        assert_eq!(cap["params"]["clip"]["y"], json!(30.0));
4335        assert_eq!(cap["params"]["clip"]["width"], json!(100.0));
4336        assert_eq!(cap["params"]["clip"]["height"], json!(20.0));
4337        assert!(reqs
4338            .iter()
4339            .any(|r| r["method"] == "DOM.getBoxModel" && r["params"]["backendNodeId"] == 106));
4340    }
4341
4342    #[tokio::test]
4343    async fn get_page_text_formats_result_and_truncation() {
4344        let payload = json!({
4345            "title": "Docs",
4346            "url": "https://example.com/docs",
4347            "source": "main",
4348            "text": "# Welcome\nHello world",
4349            "truncated": true,
4350            "total_chars": 12345,
4351        })
4352        .to_string();
4353        let mock = spawn_screenshot_mock(Value::String(payload)).await;
4354        let state = ServerState::new(ResolvedBrowser {
4355            engine: Engine::Cdp,
4356            endpoint: mock.endpoint,
4357            source: Source::External,
4358        });
4359        let out = handler_for("browser_get_page_text")(
4360            state,
4361            json!({"target": "example\\.com", "max_chars": 1000}),
4362        )
4363        .await
4364        .unwrap();
4365        assert_eq!(
4366            out["content"][0]["text"],
4367            "Docs\nhttps://example.com/docs\n\n# Welcome\nHello world\n… [truncated at 1000 of 12345 chars; pass max_chars or selector to narrow]"
4368        );
4369
4370        let mock = spawn_screenshot_mock(Value::String(
4371            json!({"error": "selector matched no element: #x"}).to_string(),
4372        ))
4373        .await;
4374        let state = ServerState::new(ResolvedBrowser {
4375            engine: Engine::Cdp,
4376            endpoint: mock.endpoint,
4377            source: Source::External,
4378        });
4379        let err = handler_for("browser_get_page_text")(
4380            state,
4381            json!({"target": "example\\.com", "selector": "#x"}),
4382        )
4383        .await
4384        .unwrap_err();
4385        assert!(err.to_string().contains("selector matched no element"));
4386
4387        let err = handler_for("browser_get_page_text")(unreached_state(), json!({"max_chars": 10}))
4388            .await
4389            .unwrap_err();
4390        assert!(err.to_string().contains("at least 500"));
4391    }
4392
4393    #[tokio::test]
4394    async fn screenshot_selector_null_rect_errors_clearly() {
4395        let mock = spawn_screenshot_mock(Value::Null).await;
4396        let state = ServerState::new(ResolvedBrowser {
4397            engine: Engine::Cdp,
4398            endpoint: mock.endpoint,
4399            source: Source::External,
4400        });
4401        let h = handler_for("browser_take_screenshot");
4402        let err = h(
4403            state,
4404            json!({
4405                "target": "example\\.com",
4406                "selector": "#missing",
4407            }),
4408        )
4409        .await
4410        .expect_err("null selector rect must error");
4411        assert!(
4412            err.to_string()
4413                .contains("selector matched no visible element: #missing"),
4414            "got: {err:#}"
4415        );
4416        assert!(mock.capture_params.lock().await.is_empty());
4417    }
4418
4419    // -- Behavioral handler arg-validation -----------------------------------
4420    //
4421    // These invoke the real handler closures (not just the static schema)
4422    // against a `ServerState` whose endpoint is never reached, because the
4423    // arg-validation / mutual-exclusion checks fire *before* any backend
4424    // connection. No browser required.
4425
4426    use crate::cli::env_resolver::{ResolvedBrowser, Source};
4427    use crate::detect::Engine;
4428
4429    /// Fetch a registered tool's handler by name.
4430    fn handler_for(name: &str) -> ToolHandler {
4431        let registry = ToolRegistry::new();
4432        register_all(&registry);
4433        registry
4434            .handler(name)
4435            .unwrap_or_else(|| panic!("tool {name} not registered"))
4436    }
4437
4438    /// A `ServerState` bound to an endpoint that is never reached (the
4439    /// handler errors during validation first). Marked CDP so we don't
4440    /// trip the BiDi-lock path.
4441    fn unreached_state() -> ServerState {
4442        ServerState::new(ResolvedBrowser {
4443            engine: Engine::Cdp,
4444            // Port 0 never accepts; any attempt to open a backend would
4445            // fail, but these tests assert the *validation* error fires
4446            // first.
4447            endpoint: "ws://127.0.0.1:0".into(),
4448            source: Source::External,
4449        })
4450    }
4451
4452    #[tokio::test]
4453    async fn navigate_missing_url_errors_before_backend() {
4454        let h = handler_for("browser_navigate");
4455        let err = h(unreached_state(), json!({}))
4456            .await
4457            .expect_err("missing url must error");
4458        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4459    }
4460
4461    #[tokio::test]
4462    async fn fetch_missing_url_errors_before_backend() {
4463        let h = handler_for("browser_fetch");
4464        let err = h(unreached_state(), json!({"method": "GET"}))
4465            .await
4466            .expect_err("missing url must error");
4467        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4468    }
4469
4470    #[tokio::test]
4471    async fn curl_missing_args_errors_before_backend() {
4472        let h = handler_for("browser_curl");
4473        let err = h(unreached_state(), json!({}))
4474            .await
4475            .expect_err("missing args must error");
4476        assert!(err.to_string().contains("'args'"), "got: {err:#}");
4477    }
4478
4479    #[tokio::test]
4480    async fn curl_rejects_non_string_args_before_backend() {
4481        let h = handler_for("browser_curl");
4482        let err = h(unreached_state(), json!({"args": ["-L", 7]}))
4483            .await
4484            .expect_err("non-string args must error");
4485        assert!(
4486            err.to_string()
4487                .contains("every curl argument must be a string"),
4488            "got: {err:#}"
4489        );
4490    }
4491
4492    #[tokio::test]
4493    async fn storage_set_missing_value_errors_before_backend() {
4494        let h = handler_for("browser_storage_set");
4495        let err = h(unreached_state(), json!({"key": "k"}))
4496            .await
4497            .expect_err("missing value must error");
4498        assert!(err.to_string().contains("missing 'value'"), "got: {err:#}");
4499    }
4500
4501    #[tokio::test]
4502    async fn storage_get_missing_key_errors_before_backend() {
4503        let h = handler_for("browser_storage_get");
4504        let err = h(unreached_state(), json!({}))
4505            .await
4506            .expect_err("missing key must error");
4507        assert!(err.to_string().contains("missing 'key'"), "got: {err:#}");
4508    }
4509
4510    /// `tab` and `target` are mutually exclusive; the reject fires in
4511    /// `resolve_target_for_args` before any backend connection.
4512    #[tokio::test]
4513    async fn navigate_tab_and_target_mutually_exclusive() {
4514        let h = handler_for("browser_navigate");
4515        let err = h(
4516            unreached_state(),
4517            json!({"url": "https://e.test/", "tab": "a", "target": "b"}),
4518        )
4519        .await
4520        .expect_err("tab+target must error");
4521        assert!(
4522            err.to_string().contains("mutually exclusive"),
4523            "got: {err:#}"
4524        );
4525    }
4526}