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    PressKey,
2322}
2323
2324/// How an interaction tool call is routed.
2325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2326enum Route {
2327    Native,
2328    Sidecar,
2329}
2330
2331/// Decide the route from the (selector, ref) argument pairs. Validation
2332/// only — fires before any backend access. Every pair must carry exactly
2333/// one side, and all pairs must agree.
2334fn route_mode(args: &Value, ref_pairs: &[(&str, &str)], has_native: bool) -> Result<Route> {
2335    if ref_pairs.is_empty() {
2336        // No element to address. Such a tool is native when it has a native
2337        // path at all (`press_key` targets whatever has focus), and sidecar
2338        // otherwise (`wait_for`, `pdf_save`).
2339        return Ok(if has_native {
2340            Route::Native
2341        } else {
2342            Route::Sidecar
2343        });
2344    }
2345    let mut native = 0;
2346    let mut sidecar = 0;
2347    for (sel, r) in ref_pairs {
2348        let has_sel = args.get(*sel).is_some_and(Value::is_string);
2349        let has_ref = args.get(*r).is_some_and(Value::is_string);
2350        match (has_sel, has_ref) {
2351            (true, true) => {
2352                return Err(anyhow!("`{sel}` and `{r}` are mutually exclusive; pass one of them"))
2353            }
2354            (false, false) => {
2355                return Err(anyhow!(
2356                    "exactly one of `{sel}` (CSS selector) or `{r}` (ref from browser_snapshot/browser_find) is required"
2357                ))
2358            }
2359            (true, false) => sidecar += 1,
2360            (false, true) => native += 1,
2361        }
2362    }
2363    if native > 0 && sidecar > 0 {
2364        return Err(anyhow!(
2365            "use refs for every element or selectors for every element, not a mix"
2366        ));
2367    }
2368    Ok(if native > 0 {
2369        Route::Native
2370    } else {
2371        Route::Sidecar
2372    })
2373}
2374
2375/// Execute an interaction tool through native CDP input.
2376async fn run_native(
2377    state: &ServerState,
2378    tool_name: &str,
2379    action: NativeAction,
2380    args: &Value,
2381) -> Result<Value> {
2382    state.ensure_native_ready(tool_name).await?;
2383    let (backend, target_id) = state.resolve_target_for_args(args).await?;
2384    let timeout = timeout_ms_arg(args, "timeout_ms", MCP_OP_TIMEOUT)?;
2385    let ref_arg = |key: &str| -> Result<String> {
2386        args.get(key)
2387            .and_then(Value::as_str)
2388            .map(String::from)
2389            .ok_or_else(|| anyhow!("missing '{key}'"))
2390    };
2391    match action {
2392        NativeAction::PressKey => {
2393            let spec = args
2394                .get("key")
2395                .and_then(Value::as_str)
2396                .ok_or_else(|| anyhow!("missing 'key'"))?;
2397            let chord = crate::session::keys::parse_chord(spec)?;
2398            backend
2399                .press_key_on_tab(&target_id, &chord, timeout)
2400                .await?;
2401            Ok(text_content(format!("pressed {spec}")))
2402        }
2403        NativeAction::Click => {
2404            let r = ref_arg("ref")?;
2405            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2406            backend
2407                .click_node(&target_id, entry.backend_node_id, timeout)
2408                .await
2409                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2410            Ok(text_content(format!(
2411                "clicked {r} ({})",
2412                describe_ref(&entry)
2413            )))
2414        }
2415        NativeAction::Hover => {
2416            let r = ref_arg("ref")?;
2417            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2418            backend
2419                .hover_node(&target_id, entry.backend_node_id, timeout)
2420                .await
2421                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2422            Ok(text_content(format!(
2423                "hovered {r} ({})",
2424                describe_ref(&entry)
2425            )))
2426        }
2427        NativeAction::Type => {
2428            let r = ref_arg("ref")?;
2429            let text = args
2430                .get("text")
2431                .and_then(Value::as_str)
2432                .ok_or_else(|| anyhow!("missing 'text'"))?
2433                .to_string();
2434            let press_sequentially = args
2435                .get("press_sequentially")
2436                .and_then(Value::as_bool)
2437                .unwrap_or(false);
2438            let submit = args.get("submit").and_then(Value::as_bool).unwrap_or(false);
2439            let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2440            backend
2441                .type_into_node(
2442                    &target_id,
2443                    entry.backend_node_id,
2444                    &text,
2445                    press_sequentially,
2446                    submit,
2447                    timeout,
2448                )
2449                .await
2450                .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2451            Ok(text_content(format!(
2452                "typed into {r} ({}){}",
2453                describe_ref(&entry),
2454                if submit { " and pressed Enter" } else { "" }
2455            )))
2456        }
2457        NativeAction::Drag => {
2458            let a = ref_arg("source_ref")?;
2459            let b = ref_arg("target_ref")?;
2460            let from = resolve_ref(state, &backend, &target_id, &a).await?;
2461            let to = resolve_ref(state, &backend, &target_id, &b).await?;
2462            backend
2463                .drag_nodes(
2464                    &target_id,
2465                    from.backend_node_id,
2466                    to.backend_node_id,
2467                    timeout,
2468                )
2469                .await
2470                .map_err(|e| stale_on_node_gone(e, &a, &target_id))?;
2471            Ok(text_content(format!(
2472                "dragged {a} ({}) to {b} ({})",
2473                describe_ref(&from),
2474                describe_ref(&to)
2475            )))
2476        }
2477    }
2478}
2479
2480// ---------------------------------------------------------------------------
2481// Table-driven sidecar interaction tools.
2482//
2483// click / type / hover / drag / press_key / wait_for all share one shape:
2484// build a param map from a fixed set of args, forward to the sidecar, return a
2485// fixed success string. Previously each tool declared its params *twice* — once
2486// in the input schema (`tab_args_properties`) and once in the handler (`copy_arg` per
2487// param) — with no compiler link, so a schema param missing a matching
2488// `copy_arg` was silently dropped before reaching the sidecar.
2489//
2490// `SidecarTool` is the single source of truth: each param's name + schema +
2491// required-ness is declared once in `params`, and BOTH the input schema and the
2492// param-forwarding are derived from it, so a param can't be in the schema but
2493// missing from the wire (or vice versa).
2494// ---------------------------------------------------------------------------
2495
2496/// One sidecar-forwarded parameter, declared once. Drives both the JSON schema
2497/// (`schema`, `required`) and the runtime forwarding (`name`).
2498struct SidecarParam {
2499    name: &'static str,
2500    schema: Value,
2501    required: bool,
2502}
2503
2504/// Declarative spec for an interaction tool. Both the input schema and
2505/// the param-forwarding are derived from the single `params` slice.
2506///
2507/// Tools with `ref_pairs` accept either a CSS selector (forwarded to the
2508/// Playwright sidecar) or an element ref (handled natively over CDP by
2509/// `run_native`). `route_mode` validates the pairing before any I/O.
2510struct SidecarTool {
2511    name: &'static str,
2512    description: &'static str,
2513    /// The sidecar RPC method (e.g. `"click"`).
2514    method: &'static str,
2515    params: Vec<SidecarParam>,
2516    /// Fixed success message returned as text content.
2517    success: &'static str,
2518    /// Native action taken when the call carries refs instead of selectors.
2519    native: Option<NativeAction>,
2520    /// `(selector_param, ref_param)` pairs; empty for sidecar-only tools.
2521    ref_pairs: &'static [(&'static str, &'static str)],
2522}
2523
2524const REF_PARAM_DESC: &str = "Element ref from browser_snapshot or browser_find (e.g. \"e12\"); \
2525                              handled natively on Chromium and Firefox, no Node needed. Mutually exclusive with \
2526                              the CSS selector; exactly one is required.";
2527
2528impl SidecarTool {
2529    fn build(self) -> RegisteredTool {
2530        let SidecarTool {
2531            name,
2532            description,
2533            method,
2534            params,
2535            success,
2536            native,
2537            ref_pairs,
2538        } = self;
2539
2540        // Schema: shared tab/target args plus this tool's params, with the
2541        // `required` list derived from the same table.
2542        let extra = Value::Object(
2543            params
2544                .iter()
2545                .map(|p| (p.name.to_string(), p.schema.clone()))
2546                .collect(),
2547        );
2548        let required: Vec<&str> = params
2549            .iter()
2550            .filter(|p| p.required)
2551            .map(|p| p.name)
2552            .collect();
2553        let mut input_schema = json!({
2554            "type": "object",
2555            "properties": tab_args_properties(extra),
2556        });
2557        if !required.is_empty() {
2558            input_schema["required"] = json!(required);
2559        }
2560
2561        // Forwarding: copy exactly the params declared above — no second list
2562        // to drift out of sync. Ref params never reach the sidecar.
2563        let param_names: Vec<&'static str> = params
2564            .iter()
2565            .map(|p| p.name)
2566            .filter(|n| !ref_pairs.iter().any(|(_, r)| r == n))
2567            .collect();
2568        RegisteredTool {
2569            name: name.into(),
2570            description: description.into(),
2571            input_schema,
2572            handler: handler(move |state, args| {
2573                let param_names = param_names.clone();
2574                Box::pin(async move {
2575                    match (route_mode(&args, ref_pairs, native.is_some())?, native) {
2576                        (Route::Native, Some(action)) => {
2577                            run_native(&state, name, action, &args).await
2578                        }
2579                        (Route::Native, None) => Err(anyhow!("{name} has no native path")),
2580                        (Route::Sidecar, _) => {
2581                            let mut params = serde_json::Map::new();
2582                            for key in &param_names {
2583                                copy_arg(&args, key, &mut params);
2584                            }
2585                            forward_to_sidecar(&state, name, &args, method, params).await?;
2586                            Ok(text_content(success))
2587                        }
2588                    }
2589                })
2590            }),
2591        }
2592    }
2593}
2594
2595fn make_click() -> RegisteredTool {
2596    SidecarTool {
2597        name: "browser_click",
2598        description: "Click an element by `ref` (from browser_snapshot/browser_find; native, \
2599                      Chromium and Firefox) or by CSS `selector` (Playwright sidecar, Chromium).",
2600        method: "click",
2601        params: vec![
2602            SidecarParam {
2603                name: "selector",
2604                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2605                required: false,
2606            },
2607            SidecarParam {
2608                name: "ref",
2609                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2610                required: false,
2611            },
2612            SidecarParam {
2613                name: "timeout_ms",
2614                schema: json!({"type": "integer"}),
2615                required: false,
2616            },
2617        ],
2618        success: "clicked",
2619        native: Some(NativeAction::Click),
2620        ref_pairs: &[("selector", "ref")],
2621    }
2622    .build()
2623}
2624
2625fn make_type() -> RegisteredTool {
2626    SidecarTool {
2627        name: "browser_type",
2628        description: "Replace the content of an input with `text`, addressed by `ref` (native, \
2629                      Chromium and Firefox) or CSS `selector` (Playwright sidecar, Chromium). \
2630                      `press_sequentially=true` sends one character at a time; `submit=true` \
2631                      presses Enter afterwards.",
2632        method: "type",
2633        params: vec![
2634            SidecarParam {
2635                name: "selector",
2636                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2637                required: false,
2638            },
2639            SidecarParam {
2640                name: "ref",
2641                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2642                required: false,
2643            },
2644            SidecarParam {
2645                name: "text",
2646                schema: json!({"type": "string"}),
2647                required: true,
2648            },
2649            SidecarParam {
2650                name: "press_sequentially",
2651                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."}),
2652                required: false,
2653            },
2654            SidecarParam {
2655                name: "submit",
2656                schema: json!({"type": "boolean", "description": "Press Enter after typing."}),
2657                required: false,
2658            },
2659            SidecarParam {
2660                name: "timeout_ms",
2661                schema: json!({"type": "integer"}),
2662                required: false,
2663            },
2664        ],
2665        success: "typed",
2666        native: Some(NativeAction::Type),
2667        ref_pairs: &[("selector", "ref")],
2668    }
2669    .build()
2670}
2671
2672fn make_hover() -> RegisteredTool {
2673    SidecarTool {
2674        name: "browser_hover",
2675        description: "Hover an element by `ref` (native, Chromium and Firefox) or CSS `selector` \
2676                      (Playwright sidecar, Chromium).",
2677        method: "hover",
2678        params: vec![
2679            SidecarParam {
2680                name: "selector",
2681                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2682                required: false,
2683            },
2684            SidecarParam {
2685                name: "ref",
2686                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2687                required: false,
2688            },
2689            SidecarParam {
2690                name: "timeout_ms",
2691                schema: json!({"type": "integer"}),
2692                required: false,
2693            },
2694        ],
2695        success: "hovered",
2696        native: Some(NativeAction::Hover),
2697        ref_pairs: &[("selector", "ref")],
2698    }
2699    .build()
2700}
2701
2702fn make_drag() -> RegisteredTool {
2703    SidecarTool {
2704        name: "browser_drag",
2705        description: "Drag one element onto another, by refs (`source_ref`/`target_ref`, native \
2706                      pointer events on Chromium and Firefox) or CSS selectors \
2707                      (`source_selector`/`target_selector`, Playwright sidecar, Chromium).",
2708        method: "drag",
2709        params: vec![
2710            SidecarParam {
2711                name: "source_selector",
2712                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `source_ref`."}),
2713                required: false,
2714            },
2715            SidecarParam {
2716                name: "target_selector",
2717                schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `target_ref`."}),
2718                required: false,
2719            },
2720            SidecarParam {
2721                name: "source_ref",
2722                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2723                required: false,
2724            },
2725            SidecarParam {
2726                name: "target_ref",
2727                schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2728                required: false,
2729            },
2730        ],
2731        success: "dragged",
2732        native: Some(NativeAction::Drag),
2733        ref_pairs: &[
2734            ("source_selector", "source_ref"),
2735            ("target_selector", "target_ref"),
2736        ],
2737    }
2738    .build()
2739}
2740
2741fn make_press_key() -> RegisteredTool {
2742    SidecarTool {
2743        name: "browser_press_key",
2744        description: "Press a keyboard key on the focused element, e.g. 'Enter', 'Tab', \
2745                      'Escape', 'ArrowDown', 'Control+A'. Modifiers are Control/Ctrl, Shift, \
2746                      Alt/Option and Meta/Cmd, joined with '+'. Native on Chromium and Firefox, \
2747                      no Node needed.",
2748        method: "press_key",
2749        params: vec![SidecarParam {
2750            name: "key",
2751            schema: json!({"type": "string"}),
2752            required: true,
2753        }],
2754        success: "pressed",
2755        native: Some(NativeAction::PressKey),
2756        ref_pairs: &[],
2757    }
2758    .build()
2759}
2760
2761fn make_wait_for() -> RegisteredTool {
2762    SidecarTool {
2763        name: "browser_wait_for",
2764        description: "Wait for a condition: a selector reaching `state`, a URL matching \
2765                      `url_regex`, or the page reaching `load_state` (`load` / \
2766                      `domcontentloaded` / `networkidle`). Chromium-only.",
2767        method: "wait_for",
2768        params: vec![
2769            SidecarParam { name: "selector", schema: json!({"type": "string"}), required: false },
2770            SidecarParam { name: "state", schema: json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), required: false },
2771            SidecarParam { name: "url_regex", schema: json!({"type": "string"}), required: false },
2772            SidecarParam { name: "load_state", schema: json!({"type": "string", "enum": ["load", "domcontentloaded", "networkidle"]}), required: false },
2773            SidecarParam { name: "timeout_ms", schema: json!({"type": "integer"}), required: false },
2774        ],
2775        success: "ok",
2776        native: None,
2777        ref_pairs: &[],
2778    }
2779    .build()
2780}
2781
2782fn make_pdf_save() -> RegisteredTool {
2783    RegisteredTool {
2784        name: "browser_pdf_save".into(),
2785        description: "Render the active page to PDF (base64 in `pdf_base64`). Chromium-only."
2786            .into(),
2787        input_schema: json!({
2788            "type": "object",
2789            "properties": tab_args_schema(),
2790        }),
2791        handler: handler(|state, args| {
2792            Box::pin(async move {
2793                let v = forward_to_sidecar(
2794                    &state,
2795                    "browser_pdf_save",
2796                    &args,
2797                    "pdf",
2798                    serde_json::Map::new(),
2799                )
2800                .await?;
2801                let b64 = v
2802                    .get("pdf_base64")
2803                    .and_then(|s| s.as_str())
2804                    .unwrap_or_default();
2805                Ok(json!({
2806                    "content": [{
2807                        "type": "resource",
2808                        "resource": { "mimeType": "application/pdf", "blob": b64 }
2809                    }]
2810                }))
2811            })
2812        }),
2813    }
2814}
2815
2816/// Helper: copy a key from `args` into `dst` if present.
2817fn copy_arg(args: &Value, key: &str, dst: &mut serde_json::Map<String, Value>) {
2818    if let Some(v) = args.get(key) {
2819        dst.insert(key.into(), v.clone());
2820    }
2821}
2822
2823#[cfg(test)]
2824mod tests {
2825    use super::*;
2826    use futures_util::{SinkExt, StreamExt};
2827    use tokio::sync::Mutex;
2828    use tokio_tungstenite::tungstenite::Message;
2829
2830    /// All tools the registry exposes after `register_all`. Mirrors the
2831    /// registration order in `register_all`.
2832    const EXPECTED_TOOLS: &[&str] = &[
2833        "browser_navigate",
2834        "browser_eval",
2835        "browser_get_html",
2836        "browser_get_page_text",
2837        "browser_take_screenshot",
2838        "browser_fetch",
2839        "browser_curl",
2840        "browser_select_element",
2841        "browser_cookies",
2842        "browser_storage_get",
2843        "browser_storage_set",
2844        "browser_wait_for_cookie",
2845        "browser_console_messages",
2846        "browser_network_requests",
2847        "browser_network_body",
2848        "list_targets",
2849        "browser_tab_list",
2850        "browser_tab_new",
2851        "browser_tab_select",
2852        "browser_tab_close",
2853        "browser_tab_foreground",
2854        "browser_start",
2855        "browser_select",
2856        "browser_list",
2857        "browser_show",
2858        "browser_snapshot",
2859        "browser_find",
2860        "browser_click",
2861        "browser_type",
2862        "browser_hover",
2863        "browser_drag",
2864        "browser_press_key",
2865        "browser_wait_for",
2866        "browser_pdf_save",
2867    ];
2868
2869    fn schema_for(name: &str) -> Value {
2870        let registry = ToolRegistry::new();
2871        register_all(&registry);
2872        registry
2873            .list()
2874            .into_iter()
2875            .find(|t| t["name"] == name)
2876            .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
2877            .clone()
2878    }
2879
2880    fn tool_description(name: &str) -> String {
2881        let registry = ToolRegistry::new();
2882        register_all(&registry);
2883        registry
2884            .list()
2885            .into_iter()
2886            .find(|t| t["name"] == name)
2887            .unwrap_or_else(|| panic!("tool {name} not registered"))["description"]
2888            .as_str()
2889            .unwrap_or("")
2890            .to_string()
2891    }
2892
2893    struct ScreenshotMock {
2894        endpoint: String,
2895        capture_params: Arc<Mutex<Vec<Value>>>,
2896    }
2897
2898    async fn spawn_screenshot_mock(selector_rect: Value) -> ScreenshotMock {
2899        spawn_screenshot_mock_with_data(selector_rect, "PNGDATA".into()).await
2900    }
2901
2902    /// Minimal PNG header (signature + IHDR) for a 1280x720 image; enough
2903    /// for `image_dimensions`, not a decodable file.
2904    fn fake_png_1280x720() -> Vec<u8> {
2905        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2906        png.extend_from_slice(&[0, 0, 0, 13]);
2907        png.extend_from_slice(b"IHDR");
2908        png.extend_from_slice(&1280u32.to_be_bytes());
2909        png.extend_from_slice(&720u32.to_be_bytes());
2910        png
2911    }
2912
2913    /// `selector_rect` is returned by every `Runtime.evaluate` after the
2914    /// first (the routing probe), so it doubles as the `devicePixelRatio`
2915    /// answer for `max_width` tests. `data` is the capture payload.
2916    async fn spawn_screenshot_mock_with_data(selector_rect: Value, data: String) -> ScreenshotMock {
2917        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2918        let addr = listener.local_addr().unwrap();
2919        let capture_params = Arc::new(Mutex::new(Vec::new()));
2920        tokio::spawn({
2921            let capture_params = capture_params.clone();
2922            async move {
2923                let (stream, _) = listener.accept().await.unwrap();
2924                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2925                let mut next_session = 0u32;
2926                let mut eval_count = 0u32;
2927                while let Some(Ok(Message::Text(t))) = ws.next().await {
2928                    let req: Value = serde_json::from_str(&t).unwrap();
2929                    let id = req["id"].as_u64().unwrap();
2930                    let method = req["method"].as_str().unwrap_or("");
2931                    let result = match method {
2932                        "Target.getTargets" => json!({
2933                            "targetInfos": [{
2934                                "targetId": "T1",
2935                                "type": "page",
2936                                "url": "https://example.com/",
2937                                "title": "Example",
2938                            }]
2939                        }),
2940                        "Target.attachToTarget" => {
2941                            next_session += 1;
2942                            json!({"sessionId": format!("S{next_session}")})
2943                        }
2944                        "Target.detachFromTarget" => json!({}),
2945                        "Inspector.enable" => json!({}),
2946                        "Runtime.evaluate" => {
2947                            eval_count += 1;
2948                            if eval_count == 1 {
2949                                json!({"result": {"value": 1}})
2950                            } else {
2951                                json!({"result": {"value": selector_rect.clone()}})
2952                            }
2953                        }
2954                        "Page.captureScreenshot" => {
2955                            capture_params.lock().await.push(req["params"].clone());
2956                            json!({"data": data})
2957                        }
2958                        "Page.getLayoutMetrics" => json!({
2959                            "cssLayoutViewport": {"pageX": 0, "pageY": 100, "clientWidth": 1000, "clientHeight": 500},
2960                            "cssContentSize": {"width": 1000, "height": 3000},
2961                        }),
2962                        _ => json!({}),
2963                    };
2964                    let resp = json!({"id": id, "result": result});
2965                    ws.send(Message::Text(resp.to_string())).await.unwrap();
2966                }
2967            }
2968        });
2969        ScreenshotMock {
2970            endpoint: format!("ws://{addr}"),
2971            capture_params,
2972        }
2973    }
2974
2975    #[test]
2976    fn register_all_includes_expected_set() {
2977        let registry = ToolRegistry::new();
2978        register_all(&registry);
2979        let list = registry.list();
2980        let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
2981        for expected in EXPECTED_TOOLS {
2982            assert!(
2983                names.contains(expected),
2984                "missing tool {expected} in {names:?}"
2985            );
2986        }
2987        assert_eq!(
2988            list.len(),
2989            EXPECTED_TOOLS.len(),
2990            "extra tools present: {names:?}"
2991        );
2992    }
2993
2994    #[test]
2995    fn every_tool_has_object_input_schema() {
2996        let registry = ToolRegistry::new();
2997        register_all(&registry);
2998        for t in registry.list() {
2999            let schema = &t["inputSchema"];
3000            assert!(schema.is_object(), "schema not object: {schema}");
3001            assert_eq!(
3002                schema["type"], "object",
3003                "schema type != object for {}: {schema}",
3004                t["name"]
3005            );
3006        }
3007    }
3008
3009    #[test]
3010    fn list_targets_schema_has_optional_filter() {
3011        let schema = schema_for("list_targets");
3012        assert_eq!(schema["properties"]["filter"]["type"], "string");
3013        assert!(
3014            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3015        );
3016    }
3017
3018    #[test]
3019    fn browser_cookies_schema_has_optional_filters() {
3020        let schema = schema_for("browser_cookies");
3021        assert_eq!(schema["properties"]["domain"]["type"], "string");
3022        assert_eq!(schema["properties"]["name"]["type"], "string");
3023        assert!(
3024            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3025        );
3026    }
3027
3028    #[test]
3029    fn browser_eval_requires_expression_and_supports_routing() {
3030        let schema = schema_for("browser_eval");
3031        let required = schema["required"].as_array().expect("required array");
3032        assert!(required.iter().any(|v| v == "expression"));
3033        assert_eq!(schema["properties"]["expression"]["type"], "string");
3034        assert_eq!(schema["properties"]["await_promise"]["type"], "boolean");
3035        assert_eq!(schema["properties"]["timeout_ms"]["type"], "number");
3036        assert_eq!(schema["properties"]["tab"]["type"], "string");
3037        assert_eq!(schema["properties"]["target"]["type"], "string");
3038    }
3039
3040    #[test]
3041    fn browser_storage_get_requires_key() {
3042        let schema = schema_for("browser_storage_get");
3043        let required = schema["required"].as_array().expect("required array");
3044        assert!(required.iter().any(|v| v == "key"));
3045        assert_eq!(schema["properties"]["key"]["type"], "string");
3046        assert_eq!(schema["properties"]["namespace"]["type"], "string");
3047    }
3048
3049    #[test]
3050    fn browser_storage_set_requires_key_and_value() {
3051        let schema = schema_for("browser_storage_set");
3052        let required: Vec<&str> = schema["required"]
3053            .as_array()
3054            .unwrap()
3055            .iter()
3056            .map(|v| v.as_str().unwrap())
3057            .collect();
3058        assert!(required.contains(&"key"));
3059        assert!(required.contains(&"value"));
3060        assert_eq!(schema["properties"]["value"]["type"], "string");
3061    }
3062
3063    #[test]
3064    fn browser_wait_for_cookie_requires_domain_and_name() {
3065        let schema = schema_for("browser_wait_for_cookie");
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(&"domain"));
3073        assert!(required.contains(&"name"));
3074        assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
3075        assert_eq!(
3076            schema["properties"]["poll_interval_seconds"]["type"],
3077            "number"
3078        );
3079    }
3080
3081    #[test]
3082    fn browser_navigate_schema_has_tab_and_target() {
3083        // Per-tab tools expose optional `tab`/`target` for routing.
3084        let schema = schema_for("browser_navigate");
3085        assert_eq!(schema["properties"]["tab"]["type"], "string");
3086        assert_eq!(schema["properties"]["target"]["type"], "string");
3087        let required: Vec<&str> = schema["required"]
3088            .as_array()
3089            .unwrap()
3090            .iter()
3091            .map(|v| v.as_str().unwrap())
3092            .collect();
3093        assert!(required.contains(&"url"));
3094        assert!(!required.contains(&"tab"));
3095        assert!(!required.contains(&"target"));
3096    }
3097
3098    #[test]
3099    fn browser_tab_select_requires_target_id() {
3100        let schema = schema_for("browser_tab_select");
3101        let required: Vec<&str> = schema["required"]
3102            .as_array()
3103            .unwrap()
3104            .iter()
3105            .map(|v| v.as_str().unwrap())
3106            .collect();
3107        assert!(required.contains(&"target_id"));
3108    }
3109
3110    #[test]
3111    fn browser_tab_close_target_id_is_optional() {
3112        // Default = close active tab; no required args.
3113        let schema = schema_for("browser_tab_close");
3114        assert!(
3115            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3116        );
3117        assert_eq!(schema["properties"]["target_id"]["type"], "string");
3118    }
3119
3120    #[test]
3121    fn browser_select_requires_name() {
3122        let schema = schema_for("browser_select");
3123        let required: Vec<&str> = schema["required"]
3124            .as_array()
3125            .unwrap()
3126            .iter()
3127            .map(|v| v.as_str().unwrap())
3128            .collect();
3129        assert!(required.contains(&"name"));
3130    }
3131
3132    #[test]
3133    fn browser_select_description_documents_failed_lock_contract() {
3134        let desc = tool_description("browser_select");
3135        assert!(desc.contains("committed before"));
3136        assert!(desc.contains("new browser remains active"));
3137        assert!(desc.contains("switch back"));
3138    }
3139
3140    #[test]
3141    fn browser_list_has_no_args() {
3142        let schema = schema_for("browser_list");
3143        assert_eq!(schema["properties"], json!({}));
3144    }
3145
3146    #[test]
3147    fn browser_cookies_schema_has_no_tab_arg() {
3148        // Cookies are browser-wide; no per-tab routing.
3149        let schema = schema_for("browser_cookies");
3150        assert!(schema["properties"].get("tab").is_none());
3151        assert!(schema["properties"].get("target").is_none());
3152    }
3153
3154    /// Sidecar-routed tools expose `tab`/`target` for the same routing
3155    /// surface as the other per-tab tools.
3156    #[test]
3157    fn sidecar_tools_expose_tab_and_target() {
3158        for name in &[
3159            "browser_snapshot",
3160            "browser_click",
3161            "browser_type",
3162            "browser_hover",
3163            "browser_drag",
3164            "browser_press_key",
3165            "browser_wait_for",
3166            "browser_pdf_save",
3167        ] {
3168            let schema = schema_for(name);
3169            assert_eq!(
3170                schema["properties"]["tab"]["type"], "string",
3171                "{name} missing tab arg"
3172            );
3173            assert_eq!(
3174                schema["properties"]["target"]["type"], "string",
3175                "{name} missing target arg"
3176            );
3177        }
3178    }
3179
3180    /// `selector` is no longer statically required on the interaction
3181    /// tools (a `ref` is the alternative; `route_mode` enforces exactly
3182    /// one at call time). `text` stays required on `browser_type`;
3183    /// `browser_snapshot` / `browser_pdf_save` have no required args.
3184    #[test]
3185    fn sidecar_tools_required_args() {
3186        let click = schema_for("browser_click");
3187        assert!(
3188            click.get("required").is_none() || click["required"].as_array().unwrap().is_empty()
3189        );
3190        assert_eq!(click["properties"]["selector"]["type"], "string");
3191        assert_eq!(click["properties"]["ref"]["type"], "string");
3192
3193        let t = schema_for("browser_type");
3194        let req: Vec<&str> = t["required"]
3195            .as_array()
3196            .unwrap()
3197            .iter()
3198            .map(|v| v.as_str().unwrap())
3199            .collect();
3200        assert_eq!(req, vec!["text"]);
3201        assert_eq!(t["properties"]["ref"]["type"], "string");
3202        assert_eq!(t["properties"]["submit"]["type"], "boolean");
3203
3204        let hover = schema_for("browser_hover");
3205        assert_eq!(hover["properties"]["ref"]["type"], "string");
3206        let drag = schema_for("browser_drag");
3207        assert_eq!(drag["properties"]["source_ref"]["type"], "string");
3208        assert_eq!(drag["properties"]["target_ref"]["type"], "string");
3209        assert!(drag.get("required").is_none());
3210
3211        // No required args on these.
3212        let snap = schema_for("browser_snapshot");
3213        assert!(snap.get("required").is_none() || snap["required"].as_array().unwrap().is_empty());
3214        for key in ["interactive_only", "max_chars", "ref", "depth"] {
3215            assert!(
3216                snap["properties"][key].is_object(),
3217                "snapshot missing {key}"
3218            );
3219        }
3220        let pdf = schema_for("browser_pdf_save");
3221        assert!(pdf.get("required").is_none() || pdf["required"].as_array().unwrap().is_empty());
3222
3223        let find = schema_for("browser_find");
3224        assert_eq!(find["required"], json!(["query"]));
3225        assert_eq!(find["properties"]["tab"]["type"], "string");
3226    }
3227
3228    #[test]
3229    fn route_mode_validates_selector_ref_pairs() {
3230        let pairs = &[("selector", "ref")];
3231        assert_eq!(
3232            route_mode(&json!({"ref": "e1"}), pairs, true).unwrap(),
3233            Route::Native
3234        );
3235        assert_eq!(
3236            route_mode(&json!({"selector": "#x"}), pairs, true).unwrap(),
3237            Route::Sidecar
3238        );
3239        let err = route_mode(&json!({}), pairs, true).unwrap_err().to_string();
3240        assert!(err.contains("exactly one of `selector`"), "{err}");
3241        let err = route_mode(&json!({"selector": "#x", "ref": "e1"}), pairs, true)
3242            .unwrap_err()
3243            .to_string();
3244        assert!(err.contains("mutually exclusive"), "{err}");
3245
3246        let drag = &[
3247            ("source_selector", "source_ref"),
3248            ("target_selector", "target_ref"),
3249        ];
3250        let err = route_mode(
3251            &json!({"source_ref": "e1", "target_selector": "#y"}),
3252            drag,
3253            true,
3254        )
3255        .unwrap_err()
3256        .to_string();
3257        assert!(err.contains("not a mix"), "{err}");
3258        assert_eq!(
3259            route_mode(&json!({"source_ref": "e1", "target_ref": "e2"}), drag, true).unwrap(),
3260            Route::Native
3261        );
3262        // Sidecar-only tools never route natively.
3263        // No ref pairs and no native path (wait_for, pdf_save) -> sidecar.
3264        assert_eq!(route_mode(&json!({}), &[], false).unwrap(), Route::Sidecar);
3265        // No ref pairs but a native path (press_key) -> native.
3266        assert_eq!(route_mode(&json!({}), &[], true).unwrap(), Route::Native);
3267    }
3268
3269    #[tokio::test]
3270    async fn click_without_selector_or_ref_errors_before_backend() {
3271        let h = handler_for("browser_click");
3272        let err = h(unreached_state(), json!({}))
3273            .await
3274            .expect_err("must error");
3275        assert!(err.to_string().contains("exactly one of"), "got: {err:#}");
3276        let h = handler_for("browser_drag");
3277        let err = h(
3278            unreached_state(),
3279            json!({"source_ref": "e1", "target_selector": "#a"}),
3280        )
3281        .await
3282        .expect_err("must error");
3283        assert!(err.to_string().contains("not a mix"), "got: {err:#}");
3284    }
3285
3286    #[tokio::test]
3287    async fn snapshot_rejects_bad_options_before_backend() {
3288        let h = handler_for("browser_snapshot");
3289        let err = h(unreached_state(), json!({"max_chars": 10}))
3290            .await
3291            .expect_err("must error");
3292        assert!(err.to_string().contains("at least 1000"), "got: {err:#}");
3293        let h = handler_for("browser_find");
3294        let err = h(unreached_state(), json!({"query": "  "}))
3295            .await
3296            .expect_err("must error");
3297        assert!(err.to_string().contains("missing 'query'"), "got: {err:#}");
3298    }
3299
3300    /// BiDi-framed mock for the native tools on Firefox: session handshake,
3301    /// one context, marker-dispatched `script.callFunction`, a mutable
3302    /// document token, and recorded `input.performActions`.
3303    struct BidiA11yMock {
3304        endpoint: String,
3305        doc_token: Arc<std::sync::atomic::AtomicU64>,
3306        requests: Arc<Mutex<Vec<Value>>>,
3307    }
3308
3309    async fn spawn_bidi_a11y_mock() -> BidiA11yMock {
3310        use std::sync::atomic::{AtomicU64, Ordering};
3311        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3312        let addr = listener.local_addr().unwrap();
3313        let doc_token = Arc::new(AtomicU64::new(4294967296));
3314        let requests = Arc::new(Mutex::new(Vec::new()));
3315        tokio::spawn({
3316            let doc_token = doc_token.clone();
3317            let requests = requests.clone();
3318            async move {
3319                let (stream, _) = listener.accept().await.unwrap();
3320                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3321                while let Some(Ok(Message::Text(t))) = ws.next().await {
3322                    let req: Value = serde_json::from_str(&t).unwrap();
3323                    requests.lock().await.push(req.clone());
3324                    let id = req["id"].as_u64().unwrap();
3325                    let method = req["method"].as_str().unwrap_or("");
3326                    let decl = req["params"]["functionDeclaration"].as_str().unwrap_or("");
3327                    let expr = req["params"]["expression"].as_str().unwrap_or("");
3328                    let string = |s: String| json!({"type": "success", "result": {"type": "string", "value": s}, "realm": "R1"});
3329                    let result = match method {
3330                        "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3331                        "browsingContext.getTree" => json!({"contexts": [
3332                            {"context": "CTX1", "url": "https://example.com/", "children": []}
3333                        ]}),
3334                        "browsingContext.captureScreenshot" => json!({"data": "PNGDATA"}),
3335                        "script.callFunction" if decl.contains("bc:snapshot") => string(
3336                            json!({"nodes": [
3337                                {"nodeId": "root", "backendDOMNodeId": 4294967296u64,
3338                                 "role": {"value": "RootWebArea"}, "name": {"value": "Example"}, "childIds": ["n1", "n2"]},
3339                                {"nodeId": "n1", "parentId": "root", "backendDOMNodeId": 1,
3340                                 "role": {"value": "button"}, "name": {"value": "Submit"}, "childIds": [],
3341                                 "properties": [{"name": "focusable", "value": {"value": true}}]},
3342                                {"nodeId": "n2", "parentId": "root", "backendDOMNodeId": 2,
3343                                 "role": {"value": "link"}, "name": {"value": "Docs"}, "childIds": [],
3344                                 "properties": [{"name": "focusable", "value": {"value": true}}]}
3345                            ], "truncated": false})
3346                            .to_string(),
3347                        ),
3348                        "script.callFunction" if decl.contains("bc:center") => {
3349                            string("{\"x\":30,\"y\":20}".into())
3350                        }
3351                        "script.callFunction" if decl.contains("bc:clip") => {
3352                            string("{\"x\":10,\"y\":30,\"width\":100,\"height\":20}".into())
3353                        }
3354                        "script.callFunction" if decl.contains("bc:type") => {
3355                            string("{\"kind\":\"field\",\"method\":\"execCommand\"}".into())
3356                        }
3357                        "script.evaluate" if expr.contains("__bcDocToken") => {
3358                            string(doc_token.load(Ordering::SeqCst).to_string())
3359                        }
3360                        "script.evaluate" => {
3361                            json!({"type": "success", "result": {"type": "number", "value": 1}, "realm": "R1"})
3362                        }
3363                        _ => json!({}),
3364                    };
3365                    ws.send(Message::Text(
3366                        json!({"type": "success", "id": id, "result": result}).to_string(),
3367                    ))
3368                    .await
3369                    .unwrap();
3370                }
3371            }
3372        });
3373        BidiA11yMock {
3374            endpoint: format!("ws://{addr}"),
3375            doc_token,
3376            requests,
3377        }
3378    }
3379
3380    fn bidi_state(endpoint: &str) -> ServerState {
3381        ServerState::new(ResolvedBrowser {
3382            engine: Engine::Bidi,
3383            endpoint: endpoint.to_string(),
3384            source: Source::External,
3385        })
3386    }
3387
3388    #[tokio::test]
3389    async fn bidi_snapshot_then_click_by_ref_performs_actions() {
3390        use std::sync::atomic::Ordering;
3391        let mock = spawn_bidi_a11y_mock().await;
3392        let state = bidi_state(&mock.endpoint);
3393        let route = json!({"target": "example\\.com"});
3394
3395        let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3396            .await
3397            .unwrap();
3398        assert_eq!(
3399            snap["content"][0]["text"],
3400            "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3401        );
3402
3403        let mut args = route.clone();
3404        args["ref"] = json!("e1");
3405        let out = handler_for("browser_click")(state.clone(), args.clone())
3406            .await
3407            .unwrap();
3408        assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3409        {
3410            let reqs = mock.requests.lock().await;
3411            let perform = reqs
3412                .iter()
3413                .find(|r| r["method"] == "input.performActions")
3414                .expect("performActions");
3415            assert_eq!(perform["params"]["context"], "CTX1");
3416            let acts = &perform["params"]["actions"][0]["actions"];
3417            assert_eq!(acts[0]["type"], "pointerMove");
3418            assert_eq!(acts[0]["x"], 30);
3419            assert_eq!(acts[1]["type"], "pointerDown");
3420            assert_eq!(acts[2]["type"], "pointerUp");
3421            assert!(reqs.iter().any(|r| r["method"] == "script.callFunction"
3422                && r["params"]["arguments"][0]["value"] == 1));
3423        }
3424        assert!(state.sidecar.lock().await.is_none());
3425
3426        let mut find_args = route.clone();
3427        find_args["query"] = json!("docs");
3428        let out = handler_for("browser_find")(state.clone(), find_args)
3429            .await
3430            .unwrap();
3431        assert_eq!(
3432            out["content"][0]["text"],
3433            "1 match for \"docs\":\ne2 link \"Docs\"\n"
3434        );
3435
3436        mock.doc_token.store(4294967297, Ordering::SeqCst);
3437        let err = handler_for("browser_click")(state.clone(), args.clone())
3438            .await
3439            .unwrap_err();
3440        assert!(matches!(
3441            err.downcast_ref::<SessionError>(),
3442            Some(SessionError::StaleRef {
3443                reason: "document changed",
3444                ..
3445            })
3446        ));
3447        let err = handler_for("browser_click")(state.clone(), args)
3448            .await
3449            .unwrap_err();
3450        assert!(matches!(
3451            err.downcast_ref::<SessionError>(),
3452            Some(SessionError::RefUnknown { .. })
3453        ));
3454    }
3455
3456    #[tokio::test]
3457    async fn bidi_type_by_ref_fills_and_submits() {
3458        let mock = spawn_bidi_a11y_mock().await;
3459        let state = bidi_state(&mock.endpoint);
3460        let route = json!({"target": "example\\.com"});
3461        handler_for("browser_snapshot")(state.clone(), route.clone())
3462            .await
3463            .unwrap();
3464        let mut args = route.clone();
3465        args["ref"] = json!("e1");
3466        args["text"] = json!("hello");
3467        args["submit"] = json!(true);
3468        let out = handler_for("browser_type")(state.clone(), args)
3469            .await
3470            .unwrap();
3471        assert_eq!(
3472            out["content"][0]["text"],
3473            "typed into e1 (button \"Submit\") and pressed Enter"
3474        );
3475        let reqs = mock.requests.lock().await;
3476        let typed = reqs
3477            .iter()
3478            .find(|r| {
3479                r["params"]["functionDeclaration"]
3480                    .as_str()
3481                    .is_some_and(|d| d.contains("bc:type"))
3482            })
3483            .expect("type helper");
3484        assert_eq!(typed["params"]["arguments"][1]["value"], "hello");
3485        assert_eq!(typed["params"]["arguments"][2]["value"], "fill");
3486        let keys = reqs
3487            .iter()
3488            .find(|r| r["method"] == "input.performActions")
3489            .expect("enter");
3490        assert_eq!(keys["params"]["actions"][0]["type"], "key");
3491        assert_eq!(
3492            keys["params"]["actions"][0]["actions"][0]["value"],
3493            "\u{e007}"
3494        );
3495    }
3496
3497    #[tokio::test]
3498    async fn bidi_screenshot_by_ref_and_full_page_clip_to_document() {
3499        let mock = spawn_bidi_a11y_mock().await;
3500        let state = bidi_state(&mock.endpoint);
3501        let route = json!({"target": "example\\.com"});
3502        handler_for("browser_snapshot")(state.clone(), route.clone())
3503            .await
3504            .unwrap();
3505        let mut args = route.clone();
3506        args["ref"] = json!("e2");
3507        let out = handler_for("browser_take_screenshot")(state.clone(), args)
3508            .await
3509            .unwrap();
3510        assert_eq!(out["content"][0]["type"], "image");
3511        let reqs = mock.requests.lock().await;
3512        let cap = reqs
3513            .iter()
3514            .find(|r| r["method"] == "browsingContext.captureScreenshot")
3515            .expect("capture");
3516        assert_eq!(cap["params"]["origin"], "document");
3517        assert_eq!(cap["params"]["clip"]["type"], "box");
3518        assert_eq!(cap["params"]["clip"]["x"], 10);
3519        assert_eq!(cap["params"]["clip"]["width"], 100);
3520    }
3521
3522    /// CDP mock serving an accessibility tree, document identity, and
3523    /// element geometry; records every request.
3524    struct A11yMock {
3525        endpoint: String,
3526        doc_token: Arc<std::sync::atomic::AtomicU64>,
3527        requests: Arc<Mutex<Vec<Value>>>,
3528    }
3529
3530    async fn spawn_a11y_mock() -> A11yMock {
3531        use std::sync::atomic::{AtomicU64, Ordering};
3532        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3533        let addr = listener.local_addr().unwrap();
3534        let doc_token = Arc::new(AtomicU64::new(101));
3535        let requests = Arc::new(Mutex::new(Vec::new()));
3536        tokio::spawn({
3537            let doc_token = doc_token.clone();
3538            let requests = requests.clone();
3539            async move {
3540                let (stream, _) = listener.accept().await.unwrap();
3541                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3542                let mut next_session = 0u32;
3543                while let Some(Ok(Message::Text(t))) = ws.next().await {
3544                    let req: Value = serde_json::from_str(&t).unwrap();
3545                    requests.lock().await.push(req.clone());
3546                    let id = req["id"].as_u64().unwrap();
3547                    let method = req["method"].as_str().unwrap_or("");
3548                    let result = match method {
3549                        "Target.getTargets" => json!({"targetInfos": [{
3550                            "targetId": "T1", "type": "page",
3551                            "url": "https://example.com/", "title": "Example",
3552                        }]}),
3553                        "Target.attachToTarget" => {
3554                            next_session += 1;
3555                            json!({"sessionId": format!("S{next_session}")})
3556                        }
3557                        "Runtime.evaluate" => json!({"result": {"value": 1}}),
3558                        "Accessibility.getFullAXTree" => json!({"nodes": [
3559                            {"nodeId": "1", "backendDOMNodeId": 101,
3560                             "role": {"value": "RootWebArea"}, "name": {"value": "Example"},
3561                             "childIds": ["2", "3"]},
3562                            {"nodeId": "2", "parentId": "1", "backendDOMNodeId": 106,
3563                             "role": {"value": "button"}, "name": {"value": "Submit"},
3564                             "properties": [{"name": "focusable", "value": {"value": true}}],
3565                             "childIds": []},
3566                            {"nodeId": "3", "parentId": "1", "backendDOMNodeId": 108,
3567                             "role": {"value": "link"}, "name": {"value": "Docs"},
3568                             "properties": [{"name": "focusable", "value": {"value": true}}],
3569                             "childIds": []},
3570                        ]}),
3571                        "DOM.getDocument" => {
3572                            json!({"root": {"backendNodeId": doc_token.load(Ordering::SeqCst)}})
3573                        }
3574                        "DOM.getContentQuads" => {
3575                            json!({"quads": [[10, 10, 50, 10, 50, 30, 10, 30]]})
3576                        }
3577                        "DOM.resolveNode" => json!({"object": {"objectId": "obj-1"}}),
3578                        "DOM.getBoxModel" => {
3579                            json!({"model": {"border": [10, 30, 110, 30, 110, 50, 10, 50]}})
3580                        }
3581                        "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
3582                        "Page.getLayoutMetrics" => json!({"cssLayoutViewport": {
3583                            "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600
3584                        }}),
3585                        _ => json!({}),
3586                    };
3587                    let resp = json!({"id": id, "result": result});
3588                    ws.send(Message::Text(resp.to_string())).await.unwrap();
3589                }
3590            }
3591        });
3592        A11yMock {
3593            endpoint: format!("ws://{addr}"),
3594            doc_token,
3595            requests,
3596        }
3597    }
3598
3599    #[tokio::test]
3600    async fn snapshot_then_click_by_ref_dispatches_native_input() {
3601        use std::sync::atomic::Ordering;
3602        let mock = spawn_a11y_mock().await;
3603        let state = ServerState::new(ResolvedBrowser {
3604            engine: Engine::Cdp,
3605            endpoint: mock.endpoint.clone(),
3606            source: Source::External,
3607        });
3608        let route = json!({"target": "example\\.com"});
3609
3610        let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3611            .await
3612            .unwrap();
3613        let text = snap["content"][0]["text"].as_str().unwrap();
3614        assert_eq!(
3615            text,
3616            "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3617        );
3618
3619        // Refs are stable across a second snapshot of the same document.
3620        let again = handler_for("browser_snapshot")(state.clone(), route.clone())
3621            .await
3622            .unwrap();
3623        assert_eq!(again["content"][0]["text"], snap["content"][0]["text"]);
3624
3625        let mut args = route.clone();
3626        args["ref"] = json!("e1");
3627        let out = handler_for("browser_click")(state.clone(), args.clone())
3628            .await
3629            .unwrap();
3630        assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3631        {
3632            let reqs = mock.requests.lock().await;
3633            let mouse: Vec<&Value> = reqs
3634                .iter()
3635                .filter(|r| r["method"] == "Input.dispatchMouseEvent")
3636                .collect();
3637            assert_eq!(mouse.len(), 3);
3638            assert_eq!(mouse[1]["params"]["type"], "mousePressed");
3639            assert_eq!(mouse[1]["params"]["x"], 30.0);
3640            assert_eq!(mouse[1]["params"]["y"], 20.0);
3641            assert!(reqs
3642                .iter()
3643                .any(|r| r["method"] == "DOM.scrollIntoViewIfNeeded"
3644                    && r["params"]["backendNodeId"] == 106));
3645        }
3646
3647        // find hands out the same refs.
3648        let mut find_args = route.clone();
3649        find_args["query"] = json!("docs");
3650        let out = handler_for("browser_find")(state.clone(), find_args)
3651            .await
3652            .unwrap();
3653        assert_eq!(
3654            out["content"][0]["text"],
3655            "1 match for \"docs\":\ne2 link \"Docs\"\n"
3656        );
3657
3658        // Unknown ref.
3659        let mut bad = route.clone();
3660        bad["ref"] = json!("e9");
3661        let err = handler_for("browser_click")(state.clone(), bad)
3662            .await
3663            .unwrap_err();
3664        assert!(matches!(
3665            err.downcast_ref::<SessionError>(),
3666            Some(SessionError::RefUnknown { .. })
3667        ));
3668
3669        // The page navigates: document token changes, refs become stale.
3670        mock.doc_token.store(202, Ordering::SeqCst);
3671        let err = handler_for("browser_click")(state.clone(), args.clone())
3672            .await
3673            .unwrap_err();
3674        match err.downcast_ref::<SessionError>() {
3675            Some(SessionError::StaleRef { reason, .. }) => assert_eq!(*reason, "document changed"),
3676            other => panic!("expected StaleRef, got {other:?}"),
3677        }
3678        assert!(err.to_string().contains("browser_snapshot"));
3679        // The stale table was dropped, so the same ref is now unknown.
3680        let err = handler_for("browser_click")(state.clone(), args)
3681            .await
3682            .unwrap_err();
3683        assert!(matches!(
3684            err.downcast_ref::<SessionError>(),
3685            Some(SessionError::RefUnknown { .. })
3686        ));
3687    }
3688
3689    #[tokio::test]
3690    async fn type_by_ref_inserts_text_and_submits() {
3691        let mock = spawn_a11y_mock().await;
3692        let state = ServerState::new(ResolvedBrowser {
3693            engine: Engine::Cdp,
3694            endpoint: mock.endpoint.clone(),
3695            source: Source::External,
3696        });
3697        let route = json!({"target": "example\\.com"});
3698        handler_for("browser_snapshot")(state.clone(), route.clone())
3699            .await
3700            .unwrap();
3701        let mut args = route.clone();
3702        args["ref"] = json!("e1");
3703        args["text"] = json!("hello");
3704        args["submit"] = json!(true);
3705        let out = handler_for("browser_type")(state.clone(), args)
3706            .await
3707            .unwrap();
3708        assert_eq!(
3709            out["content"][0]["text"],
3710            "typed into e1 (button \"Submit\") and pressed Enter"
3711        );
3712        let reqs = mock.requests.lock().await;
3713        let insert = reqs
3714            .iter()
3715            .find(|r| r["method"] == "Input.insertText")
3716            .expect("insertText");
3717        assert_eq!(insert["params"]["text"], "hello");
3718        assert!(reqs
3719            .iter()
3720            .any(|r| r["method"] == "Input.dispatchKeyEvent" && r["params"]["key"] == "Enter"));
3721        // Nothing was forwarded to a sidecar: no Node process, no `connect`.
3722        assert!(state.sidecar.lock().await.is_none());
3723    }
3724
3725    /// CDP mock for the capture tools: hands out sessions, and after
3726    /// `Network.enable` on a session pushes one console error and one
3727    /// finished request on that session.
3728    async fn spawn_capture_mock() -> String {
3729        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3730        let addr = listener.local_addr().unwrap();
3731        tokio::spawn(async move {
3732            let (stream, _) = listener.accept().await.unwrap();
3733            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3734            let mut next_session = 0u32;
3735            while let Some(Ok(Message::Text(t))) = ws.next().await {
3736                let req: Value = serde_json::from_str(&t).unwrap();
3737                let id = req["id"].as_u64().unwrap();
3738                let method = req["method"].as_str().unwrap_or("").to_string();
3739                let sid = req["sessionId"].as_str().unwrap_or("").to_string();
3740                let result = match method.as_str() {
3741                    "Target.getTargets" => json!({"targetInfos": [{
3742                        "targetId": "T1", "type": "page",
3743                        "url": "https://app.test/", "title": "App",
3744                    }]}),
3745                    "Target.attachToTarget" => {
3746                        next_session += 1;
3747                        json!({"sessionId": format!("S{next_session}")})
3748                    }
3749                    "Runtime.evaluate" => json!({"result": {"value": 1}}),
3750                    "Page.getNavigationHistory" => json!({
3751                        "currentIndex": 0, "entries": [{"url": "https://app.test/"}]
3752                    }),
3753                    "Network.getResponseBody" => {
3754                        json!({"body": "{\"ok\":true}", "base64Encoded": false})
3755                    }
3756                    _ => json!({}),
3757                };
3758                ws.send(Message::Text(
3759                    json!({"id": id, "result": result}).to_string(),
3760                ))
3761                .await
3762                .unwrap();
3763                if method == "Network.enable" {
3764                    for ev in [
3765                        json!({"method": "Runtime.consoleAPICalled", "sessionId": sid,
3766                               "params": {"type": "error", "timestamp": 1756816496120.0,
3767                                          "args": [{"type": "string", "value": "boom"}],
3768                                          "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3769                        json!({"method": "Network.requestWillBeSent", "sessionId": sid,
3770                               "params": {"requestId": "9.1", "timestamp": 10.0, "wallTime": 1756816496.0, "type": "XHR",
3771                                          "request": {"method": "GET", "url": "https://app.test/api/me"}}}),
3772                        json!({"method": "Network.responseReceived", "sessionId": sid,
3773                               "params": {"requestId": "9.1", "type": "XHR",
3774                                          "response": {"status": 401, "mimeType": "application/json"}}}),
3775                        json!({"method": "Network.loadingFinished", "sessionId": sid,
3776                               "params": {"requestId": "9.1", "timestamp": 10.084, "encodedDataLength": 11}}),
3777                    ] {
3778                        ws.send(Message::Text(ev.to_string())).await.unwrap();
3779                    }
3780                }
3781            }
3782        });
3783        format!("ws://{addr}")
3784    }
3785
3786    #[tokio::test]
3787    async fn capture_tools_read_buffered_console_and_network_after_navigate() {
3788        let endpoint = spawn_capture_mock().await;
3789        let state = ServerState::new(ResolvedBrowser {
3790            engine: Engine::Cdp,
3791            endpoint,
3792            source: Source::External,
3793        });
3794        let route = json!({"target": "app\\.test"});
3795        let mut nav = route.clone();
3796        nav["url"] = json!("https://app.test/x");
3797        handler_for("browser_navigate")(state.clone(), nav)
3798            .await
3799            .unwrap();
3800
3801        // Events are pushed by the mock right after Network.enable; give the
3802        // router a moment (test-side bounded polling only).
3803        let mut text = String::new();
3804        for _ in 0..100 {
3805            let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3806                .await
3807                .unwrap();
3808            text = out["content"][0]["text"].as_str().unwrap().to_string();
3809            if text.contains("boom") {
3810                break;
3811            }
3812            tokio::time::sleep(Duration::from_millis(10)).await;
3813        }
3814        assert!(
3815            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"),
3816            "{text}"
3817        );
3818
3819        // Pattern filtering and clear.
3820        let mut q = route.clone();
3821        q["pattern"] = json!("nomatch");
3822        let out = handler_for("browser_console_messages")(state.clone(), q)
3823            .await
3824            .unwrap();
3825        assert!(out["content"][0]["text"]
3826            .as_str()
3827            .unwrap()
3828            .contains("showing 0 of 0 matched (1 buffered"));
3829        let mut q = route.clone();
3830        q["pattern"] = json!("[");
3831        let err = handler_for("browser_console_messages")(state.clone(), q)
3832            .await
3833            .unwrap_err();
3834        assert!(err.to_string().contains("invalid `pattern` regex"));
3835        let mut q = route.clone();
3836        q["limit"] = json!(0);
3837        q["clear"] = json!(true);
3838        handler_for("browser_console_messages")(state.clone(), q)
3839            .await
3840            .unwrap();
3841        let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3842            .await
3843            .unwrap();
3844        assert!(out["content"][0]["text"]
3845            .as_str()
3846            .unwrap()
3847            .contains("(0 buffered"));
3848
3849        // Network listing with filters.
3850        let mut q = route.clone();
3851        q["status"] = json!("4xx");
3852        q["url_pattern"] = json!("/api/");
3853        let out = handler_for("browser_network_requests")(state.clone(), q)
3854            .await
3855            .unwrap();
3856        let text = out["content"][0]["text"].as_str().unwrap();
3857        assert!(
3858            text.contains(
3859                "9.1  GET    https://app.test/api/me  → 401 application/json 11B 84ms [XHR]"
3860            ),
3861            "{text}"
3862        );
3863        let mut q = route.clone();
3864        q["status"] = json!("2xx");
3865        let out = handler_for("browser_network_requests")(state.clone(), q)
3866            .await
3867            .unwrap();
3868        assert!(out["content"][0]["text"]
3869            .as_str()
3870            .unwrap()
3871            .contains("showing 0 of 0 matched (1 buffered"));
3872        let mut q = route.clone();
3873        q["format"] = json!("json");
3874        let out = handler_for("browser_network_requests")(state.clone(), q)
3875            .await
3876            .unwrap();
3877        let parsed: Value =
3878            serde_json::from_str(out["content"][0]["text"].as_str().unwrap()).unwrap();
3879        assert_eq!(parsed["entries"][0]["request_id"], "9.1");
3880        assert_eq!(parsed["entries"][0]["state"], "finished");
3881
3882        // Body fetch.
3883        let mut q = route.clone();
3884        q["request_id"] = json!("9.1");
3885        let out = handler_for("browser_network_body")(state.clone(), q)
3886            .await
3887            .unwrap();
3888        assert_eq!(out["content"][0]["text"], "{\"ok\":true}");
3889        let meta: Value =
3890            serde_json::from_str(out["content"][1]["text"].as_str().unwrap()).unwrap();
3891        assert_eq!(meta["status"], 401);
3892        assert_eq!(meta["truncated"], false);
3893
3894        // Closing the tab forgets its capture. (The mock keeps listing T1,
3895        // so a later tool call would re-touch it; assert on the hub directly.)
3896        assert_eq!(state.capture.captured_tabs(), vec!["T1".to_string()]);
3897        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
3898            .await
3899            .unwrap();
3900        assert!(state.capture.captured_tabs().is_empty());
3901    }
3902
3903    #[tokio::test]
3904    async fn capture_tools_validate_before_backend_and_gate_on_engine() {
3905        let h = handler_for("browser_network_requests");
3906        let err = h(unreached_state(), json!({"status": "lots"}))
3907            .await
3908            .expect_err("must error");
3909        assert!(err.to_string().contains("`status` must be"), "{err:#}");
3910        let h = handler_for("browser_network_body");
3911        let err = h(unreached_state(), json!({}))
3912            .await
3913            .expect_err("must error");
3914        assert!(err.to_string().contains("missing 'request_id'"), "{err:#}");
3915
3916        // Only bodies are gated on the engine; the listing tools reach the
3917        // backend on Firefox (and fail here only because nothing listens).
3918        let state = ServerState::new(ResolvedBrowser {
3919            engine: Engine::Bidi,
3920            endpoint: "ws://127.0.0.1:0".into(),
3921            source: Source::External,
3922        });
3923        let err = handler_for("browser_network_body")(state.clone(), json!({"request_id": "1"}))
3924            .await
3925            .expect_err("BiDi must error");
3926        match err.downcast_ref::<SessionError>() {
3927            Some(SessionError::EngineUnsupported { tool, hint, .. }) => {
3928                assert_eq!(tool, "browser_network_body");
3929                assert!(hint.contains("browser_fetch") && hint.contains("browser_select"));
3930            }
3931            other => panic!("expected EngineUnsupported, got {other:?}"),
3932        }
3933        for tool in ["browser_console_messages", "browser_network_requests"] {
3934            let err = handler_for(tool)(state.clone(), json!({}))
3935                .await
3936                .expect_err("unreachable endpoint must error");
3937            assert!(
3938                !matches!(
3939                    err.downcast_ref::<SessionError>(),
3940                    Some(SessionError::EngineUnsupported { .. })
3941                ),
3942                "{tool} must not be engine-gated on BiDi: {err:#}"
3943            );
3944        }
3945    }
3946
3947    /// BiDi-framed CDP-free mock for the capture tools on Firefox: answers
3948    /// the session handshake, tree, navigate and subscribe, then pushes one
3949    /// console error and one finished request on context `C1`.
3950    async fn spawn_bidi_capture_mock() -> String {
3951        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3952        let addr = listener.local_addr().unwrap();
3953        tokio::spawn(async move {
3954            let (stream, _) = listener.accept().await.unwrap();
3955            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3956            while let Some(Ok(Message::Text(t))) = ws.next().await {
3957                let req: Value = serde_json::from_str(&t).unwrap();
3958                let id = req["id"].as_u64().unwrap();
3959                let method = req["method"].as_str().unwrap_or("").to_string();
3960                let result = match method.as_str() {
3961                    "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3962                    "browsingContext.getTree" => json!({"contexts": [
3963                        {"context": "C1", "url": "https://app.test/", "children": []}
3964                    ]}),
3965                    "browsingContext.navigate" => {
3966                        json!({"navigation": "N1", "url": "https://app.test/x"})
3967                    }
3968                    "script.evaluate" => {
3969                        json!({"type": "success", "result": {"type": "number", "value": 1}})
3970                    }
3971                    _ => json!({}),
3972                };
3973                ws.send(Message::Text(
3974                    json!({"type": "success", "id": id, "result": result}).to_string(),
3975                ))
3976                .await
3977                .unwrap();
3978                let first_event = req["params"]["events"][0].as_str().unwrap_or("");
3979                if method == "session.subscribe" && first_event.starts_with("network.") {
3980                    for ev in [
3981                        json!({"type": "event", "method": "log.entryAdded", "params": {
3982                            "type": "console", "level": "error", "method": "error", "text": "boom",
3983                            "timestamp": 1756816496120.0, "source": {"context": "C1"},
3984                            "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3985                        json!({"type": "event", "method": "network.beforeRequestSent", "params": {
3986                            "context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1756816496000.0,
3987                            "initiator": {"type": "other"},
3988                            "request": {"request": "9.1", "url": "https://app.test/api/me", "method": "GET", "bodySize": 0, "initiatorType": "xmlhttprequest"}}}),
3989                        json!({"type": "event", "method": "network.responseCompleted", "params": {
3990                            "context": "C1", "timestamp": 1756816496084.0, "redirectCount": 0,
3991                            "request": {"request": "9.1"},
3992                            "response": {"status": 401, "mimeType": "application/json", "bytesReceived": 11}}}),
3993                    ] {
3994                        ws.send(Message::Text(ev.to_string())).await.unwrap();
3995                    }
3996                }
3997            }
3998        });
3999        format!("ws://{addr}")
4000    }
4001
4002    #[tokio::test]
4003    async fn capture_tools_work_on_bidi_and_body_is_cdp_only() {
4004        let endpoint = spawn_bidi_capture_mock().await;
4005        let state = ServerState::new(ResolvedBrowser {
4006            engine: Engine::Bidi,
4007            endpoint,
4008            source: Source::External,
4009        });
4010        let route = json!({"target": "app\\.test"});
4011        let mut nav = route.clone();
4012        nav["url"] = json!("https://app.test/x");
4013        handler_for("browser_navigate")(state.clone(), nav)
4014            .await
4015            .unwrap();
4016        let mut text = String::new();
4017        for _ in 0..100 {
4018            let out = handler_for("browser_console_messages")(state.clone(), route.clone())
4019                .await
4020                .unwrap();
4021            text = out["content"][0]["text"].as_str().unwrap().to_string();
4022            if text.contains("boom") {
4023                break;
4024            }
4025            tokio::time::sleep(Duration::from_millis(10)).await;
4026        }
4027        assert!(
4028            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"),
4029            "{text}"
4030        );
4031        let mut q = route.clone();
4032        q["status"] = json!("4xx");
4033        let out = handler_for("browser_network_requests")(state.clone(), q)
4034            .await
4035            .unwrap();
4036        let net = out["content"][0]["text"].as_str().unwrap();
4037        assert!(
4038            net.contains(
4039                "9.1  GET    https://app.test/api/me  → 401 application/json 11B 84ms [XHR]"
4040            ),
4041            "{net}"
4042        );
4043        let mut q = route.clone();
4044        q["request_id"] = json!("9.1");
4045        let err = handler_for("browser_network_body")(state.clone(), q)
4046            .await
4047            .unwrap_err();
4048        match err.downcast_ref::<SessionError>() {
4049            Some(SessionError::EngineUnsupported { hint, .. }) => {
4050                assert!(hint.contains("browser_fetch"))
4051            }
4052            other => panic!("expected EngineUnsupported, got {other:?}"),
4053        }
4054        assert_eq!(state.capture.captured_tabs(), vec!["C1".to_string()]);
4055        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "C1"}))
4056            .await
4057            .unwrap();
4058        assert!(state.capture.captured_tabs().is_empty());
4059    }
4060
4061    #[tokio::test]
4062    async fn tab_foreground_requires_registered_chromium_browser() {
4063        let state = ServerState::new(ResolvedBrowser {
4064            engine: Engine::Bidi,
4065            endpoint: "ws://127.0.0.1:0".into(),
4066            source: Source::External,
4067        });
4068        let err = handler_for("browser_tab_foreground")(state, json!({}))
4069            .await
4070            .expect_err("BiDi must error");
4071        match err.downcast_ref::<SessionError>() {
4072            Some(SessionError::EngineUnsupported { hint, .. }) => {
4073                assert!(hint.contains("setFocusEmulationEnabled"))
4074            }
4075            other => panic!("expected EngineUnsupported, got {other:?}"),
4076        }
4077        let mock = spawn_a11y_mock().await;
4078        let state = ServerState::new(ResolvedBrowser {
4079            engine: Engine::Cdp,
4080            endpoint: mock.endpoint.clone(),
4081            source: Source::External,
4082        });
4083        let err = handler_for("browser_tab_foreground")(state.clone(), json!({}))
4084            .await
4085            .expect_err("external endpoint must error");
4086        assert!(err.to_string().contains("registered browser"), "{err:#}");
4087        let err = handler_for("browser_tab_foreground")(
4088            state.clone(),
4089            json!({"enabled": true, "all": true}),
4090        )
4091        .await
4092        .expect_err("all requires enabled false");
4093        assert!(err.to_string().contains("`all` only applies"), "{err:#}");
4094        let list = handler_for("browser_tab_list")(state, json!({}))
4095            .await
4096            .unwrap();
4097        let rows: Value =
4098            serde_json::from_str(list["content"][0]["text"].as_str().unwrap()).unwrap();
4099        assert_eq!(rows[0]["foreground"], false);
4100    }
4101
4102    #[tokio::test]
4103    async fn tab_close_drops_refs() {
4104        let mock = spawn_a11y_mock().await;
4105        let state = ServerState::new(ResolvedBrowser {
4106            engine: Engine::Cdp,
4107            endpoint: mock.endpoint.clone(),
4108            source: Source::External,
4109        });
4110        handler_for("browser_snapshot")(state.clone(), json!({"target": "example\\.com"}))
4111            .await
4112            .unwrap();
4113        assert!(state.refs.lock().await.contains_key("T1"));
4114        handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
4115            .await
4116            .unwrap();
4117        assert!(!state.refs.lock().await.contains_key("T1"));
4118    }
4119
4120    /// Sidecar tool against a BiDi browser must error with
4121    /// `EngineUnsupported` BEFORE attempting to spawn the sidecar — so
4122    /// even systems without Node/Bun get a clean message.
4123    #[tokio::test]
4124    async fn sidecar_tool_on_bidi_returns_engine_unsupported() {
4125        use crate::cli::env_resolver::{ResolvedBrowser, Source};
4126        use crate::detect::Engine;
4127        use crate::errors::SessionError;
4128
4129        // ServerState bound to a BiDi browser. Endpoint never gets hit
4130        // because the engine check short-circuits.
4131        let resolved = ResolvedBrowser {
4132            engine: Engine::Bidi,
4133            endpoint: "ws://127.0.0.1:0".into(),
4134            source: Source::External,
4135        };
4136        let state = ServerState::new(resolved);
4137
4138        let err = match state.ensure_sidecar("browser_snapshot").await {
4139            Ok(_) => panic!("BiDi must error"),
4140            Err(e) => e,
4141        };
4142        let typed = err.downcast_ref::<SessionError>().expect("typed error");
4143        match typed {
4144            SessionError::EngineUnsupported { tool, hint, .. } => {
4145                assert_eq!(tool, "browser_snapshot");
4146                assert!(!hint.contains(concat!("browser_", "evaluate")));
4147                assert!(hint.contains("browser_get_html"));
4148                assert!(hint.contains("browser_select"));
4149            }
4150            other => panic!("expected EngineUnsupported, got {other:?}"),
4151        }
4152    }
4153
4154    #[test]
4155    fn sidecar_cdp_attach_failure_classifier_matches_connect_layer_errors() {
4156        let err = anyhow::anyhow!(
4157            "browserType.connectOverCDP: Timeout 5000ms exceeded while <ws connecting> to ws://127.0.0.1:64767/devtools/browser/x"
4158        );
4159        assert!(looks_like_sidecar_cdp_attach_failure(&err));
4160
4161        let err = anyhow::anyhow!("page.waitForLoadState: Timeout 30000ms exceeded");
4162        assert!(
4163            !looks_like_sidecar_cdp_attach_failure(&err),
4164            "normal page wait timeouts must not be reclassified as sidecar attach failures"
4165        );
4166    }
4167
4168    #[test]
4169    fn sidecar_connection_failed_message_discourages_page_hang_inference() {
4170        let err = SessionError::SidecarConnectionFailed {
4171            tool: "browser_snapshot".into(),
4172            method: "snapshot".into(),
4173            target_id: "T1".into(),
4174            url: Some("http://localhost:5173/404".into()),
4175            details: "browserType.connectOverCDP: Timeout 5000ms exceeded".into(),
4176            hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
4177        };
4178        let msg = err.to_string();
4179        assert!(msg.contains("Playwright sidecar connection failed"));
4180        assert!(msg.contains("not evidence that the page is hung"));
4181        assert!(msg.contains("browser_get_html"));
4182    }
4183
4184    #[tokio::test]
4185    async fn screenshot_selector_sends_cdp_clip() {
4186        let mock = spawn_screenshot_mock(json!({
4187            "x": 12.5,
4188            "y": 34.0,
4189            "width": 56.0,
4190            "height": 78.0,
4191        }))
4192        .await;
4193        let state = ServerState::new(ResolvedBrowser {
4194            engine: Engine::Cdp,
4195            endpoint: mock.endpoint,
4196            source: Source::External,
4197        });
4198        let h = handler_for("browser_take_screenshot");
4199        let out = h(
4200            state,
4201            json!({
4202                "target": "example\\.com",
4203                "selector": "#main",
4204            }),
4205        )
4206        .await
4207        .unwrap();
4208        assert_eq!(out["content"][0]["type"], "image");
4209        assert_eq!(out["content"][0]["data"], "PNGDATA");
4210
4211        let captures = mock.capture_params.lock().await;
4212        assert_eq!(captures.len(), 1);
4213        assert_eq!(captures[0]["format"], "png");
4214        assert_eq!(captures[0]["captureBeyondViewport"], true);
4215        assert_eq!(captures[0]["clip"]["x"], json!(12.5));
4216        assert_eq!(captures[0]["clip"]["y"], json!(34.0));
4217        assert_eq!(captures[0]["clip"]["width"], json!(56.0));
4218        assert_eq!(captures[0]["clip"]["height"], json!(78.0));
4219        assert_eq!(captures[0]["clip"]["scale"], json!(1));
4220    }
4221
4222    #[tokio::test]
4223    async fn screenshot_jpeg_quality_and_max_width_scale_clip() {
4224        // Every post-probe evaluate returns 2.0 → devicePixelRatio = 2.
4225        let mock = spawn_screenshot_mock(json!(2.0)).await;
4226        let state = ServerState::new(ResolvedBrowser {
4227            engine: Engine::Cdp,
4228            endpoint: mock.endpoint,
4229            source: Source::External,
4230        });
4231        let out = handler_for("browser_take_screenshot")(
4232            state.clone(),
4233            json!({
4234                "target": "example\\.com",
4235                "format": "jpeg",
4236                "quality": 60,
4237                "max_width": 500,
4238            }),
4239        )
4240        .await
4241        .unwrap();
4242        assert_eq!(out["content"][0]["mimeType"], "image/jpeg");
4243        let captures = mock.capture_params.lock().await;
4244        assert_eq!(captures.len(), 1);
4245        assert_eq!(captures[0]["format"], "jpeg");
4246        assert_eq!(captures[0]["quality"], 60);
4247        // Viewport is 1000 CSS px wide at DPR 2 → 2000 device px; 500 → 0.25.
4248        assert_eq!(captures[0]["captureBeyondViewport"], true);
4249        assert_eq!(captures[0]["clip"]["x"], json!(0.0));
4250        assert_eq!(captures[0]["clip"]["y"], json!(100.0));
4251        assert_eq!(captures[0]["clip"]["width"], json!(1000.0));
4252        assert_eq!(captures[0]["clip"]["height"], json!(500.0));
4253        assert_eq!(captures[0]["clip"]["scale"], json!(0.25));
4254    }
4255
4256    #[tokio::test]
4257    async fn screenshot_max_width_larger_than_page_keeps_default_params() {
4258        let mock = spawn_screenshot_mock(json!(1.0)).await;
4259        let state = ServerState::new(ResolvedBrowser {
4260            engine: Engine::Cdp,
4261            endpoint: mock.endpoint,
4262            source: Source::External,
4263        });
4264        handler_for("browser_take_screenshot")(
4265            state,
4266            json!({"target": "example\\.com", "max_width": 4000, "full_page": true}),
4267        )
4268        .await
4269        .unwrap();
4270        let captures = mock.capture_params.lock().await;
4271        assert_eq!(captures[0]["format"], "png");
4272        assert!(captures[0].get("clip").is_none());
4273        assert!(captures[0].get("quality").is_none());
4274        assert_eq!(captures[0]["captureBeyondViewport"], true);
4275    }
4276
4277    #[tokio::test]
4278    async fn screenshot_save_to_writes_private_file_and_reports_dimensions() {
4279        use base64::Engine as _;
4280        let data = base64::engine::general_purpose::STANDARD.encode(fake_png_1280x720());
4281        let mock = spawn_screenshot_mock_with_data(Value::Null, data).await;
4282        let state = ServerState::new(ResolvedBrowser {
4283            engine: Engine::Cdp,
4284            endpoint: mock.endpoint,
4285            source: Source::External,
4286        });
4287        let dir = tempfile::TempDir::new().unwrap();
4288        let path = dir.path().join("shot.png");
4289        let out = handler_for("browser_take_screenshot")(
4290            state,
4291            json!({"target": "example\\.com", "save_to": path.to_str().unwrap()}),
4292        )
4293        .await
4294        .unwrap();
4295        assert_eq!(out["content"][0]["type"], "text");
4296        let text = out["content"][0]["text"].as_str().unwrap();
4297        assert!(
4298            text.starts_with(&format!(
4299                "Saved screenshot to {} (1280x720, image/png, 1 KiB)",
4300                path.display()
4301            )),
4302            "{text}"
4303        );
4304        let bytes = std::fs::read(&path).unwrap();
4305        assert_eq!(bytes, fake_png_1280x720());
4306        #[cfg(unix)]
4307        {
4308            use std::os::unix::fs::PermissionsExt;
4309            assert_eq!(
4310                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4311                0o600
4312            );
4313        }
4314    }
4315
4316    #[tokio::test]
4317    async fn screenshot_option_validation_fires_before_backend() {
4318        let h = handler_for("browser_take_screenshot");
4319        for (args, needle) in [
4320            (json!({"quality": 50}), "only applies to"),
4321            (json!({"format": "gif"}), "`format` must be"),
4322            (json!({"max_width": 10}), "at least 64"),
4323            (json!({"save_to": "relative.png"}), "absolute path"),
4324            (
4325                // Absolute on every platform (`/x` is relative on Windows).
4326                json!({"save_to": std::env::temp_dir().join("definitely/missing/dir/x.png")}),
4327                "parent directory",
4328            ),
4329            (json!({"selector": "#a", "ref": "e1"}), "mutually exclusive"),
4330        ] {
4331            let err = h(unreached_state(), args.clone())
4332                .await
4333                .expect_err("must error");
4334            assert!(err.to_string().contains(needle), "{args}: {err:#}");
4335        }
4336    }
4337
4338    #[tokio::test]
4339    async fn screenshot_by_ref_clips_to_node_box() {
4340        let mock = spawn_a11y_mock().await;
4341        let state = ServerState::new(ResolvedBrowser {
4342            engine: Engine::Cdp,
4343            endpoint: mock.endpoint.clone(),
4344            source: Source::External,
4345        });
4346        let route = json!({"target": "example\\.com"});
4347        handler_for("browser_snapshot")(state.clone(), route.clone())
4348            .await
4349            .unwrap();
4350        let mut args = route.clone();
4351        args["ref"] = json!("e1");
4352        let out = handler_for("browser_take_screenshot")(state.clone(), args)
4353            .await
4354            .unwrap();
4355        assert_eq!(out["content"][0]["type"], "image");
4356        let reqs = mock.requests.lock().await;
4357        let cap = reqs
4358            .iter()
4359            .find(|r| r["method"] == "Page.captureScreenshot")
4360            .expect("capture");
4361        assert_eq!(cap["params"]["clip"]["x"], json!(10.0));
4362        assert_eq!(cap["params"]["clip"]["y"], json!(30.0));
4363        assert_eq!(cap["params"]["clip"]["width"], json!(100.0));
4364        assert_eq!(cap["params"]["clip"]["height"], json!(20.0));
4365        assert!(reqs
4366            .iter()
4367            .any(|r| r["method"] == "DOM.getBoxModel" && r["params"]["backendNodeId"] == 106));
4368    }
4369
4370    #[tokio::test]
4371    async fn get_page_text_formats_result_and_truncation() {
4372        let payload = json!({
4373            "title": "Docs",
4374            "url": "https://example.com/docs",
4375            "source": "main",
4376            "text": "# Welcome\nHello world",
4377            "truncated": true,
4378            "total_chars": 12345,
4379        })
4380        .to_string();
4381        let mock = spawn_screenshot_mock(Value::String(payload)).await;
4382        let state = ServerState::new(ResolvedBrowser {
4383            engine: Engine::Cdp,
4384            endpoint: mock.endpoint,
4385            source: Source::External,
4386        });
4387        let out = handler_for("browser_get_page_text")(
4388            state,
4389            json!({"target": "example\\.com", "max_chars": 1000}),
4390        )
4391        .await
4392        .unwrap();
4393        assert_eq!(
4394            out["content"][0]["text"],
4395            "Docs\nhttps://example.com/docs\n\n# Welcome\nHello world\n… [truncated at 1000 of 12345 chars; pass max_chars or selector to narrow]"
4396        );
4397
4398        let mock = spawn_screenshot_mock(Value::String(
4399            json!({"error": "selector matched no element: #x"}).to_string(),
4400        ))
4401        .await;
4402        let state = ServerState::new(ResolvedBrowser {
4403            engine: Engine::Cdp,
4404            endpoint: mock.endpoint,
4405            source: Source::External,
4406        });
4407        let err = handler_for("browser_get_page_text")(
4408            state,
4409            json!({"target": "example\\.com", "selector": "#x"}),
4410        )
4411        .await
4412        .unwrap_err();
4413        assert!(err.to_string().contains("selector matched no element"));
4414
4415        let err = handler_for("browser_get_page_text")(unreached_state(), json!({"max_chars": 10}))
4416            .await
4417            .unwrap_err();
4418        assert!(err.to_string().contains("at least 500"));
4419    }
4420
4421    #[tokio::test]
4422    async fn screenshot_selector_null_rect_errors_clearly() {
4423        let mock = spawn_screenshot_mock(Value::Null).await;
4424        let state = ServerState::new(ResolvedBrowser {
4425            engine: Engine::Cdp,
4426            endpoint: mock.endpoint,
4427            source: Source::External,
4428        });
4429        let h = handler_for("browser_take_screenshot");
4430        let err = h(
4431            state,
4432            json!({
4433                "target": "example\\.com",
4434                "selector": "#missing",
4435            }),
4436        )
4437        .await
4438        .expect_err("null selector rect must error");
4439        assert!(
4440            err.to_string()
4441                .contains("selector matched no visible element: #missing"),
4442            "got: {err:#}"
4443        );
4444        assert!(mock.capture_params.lock().await.is_empty());
4445    }
4446
4447    // -- Behavioral handler arg-validation -----------------------------------
4448    //
4449    // These invoke the real handler closures (not just the static schema)
4450    // against a `ServerState` whose endpoint is never reached, because the
4451    // arg-validation / mutual-exclusion checks fire *before* any backend
4452    // connection. No browser required.
4453
4454    use crate::cli::env_resolver::{ResolvedBrowser, Source};
4455    use crate::detect::Engine;
4456
4457    /// Fetch a registered tool's handler by name.
4458    fn handler_for(name: &str) -> ToolHandler {
4459        let registry = ToolRegistry::new();
4460        register_all(&registry);
4461        registry
4462            .handler(name)
4463            .unwrap_or_else(|| panic!("tool {name} not registered"))
4464    }
4465
4466    /// A `ServerState` bound to an endpoint that is never reached (the
4467    /// handler errors during validation first). Marked CDP so we don't
4468    /// trip the BiDi-lock path.
4469    fn unreached_state() -> ServerState {
4470        ServerState::new(ResolvedBrowser {
4471            engine: Engine::Cdp,
4472            // Port 0 never accepts; any attempt to open a backend would
4473            // fail, but these tests assert the *validation* error fires
4474            // first.
4475            endpoint: "ws://127.0.0.1:0".into(),
4476            source: Source::External,
4477        })
4478    }
4479
4480    #[tokio::test]
4481    async fn navigate_missing_url_errors_before_backend() {
4482        let h = handler_for("browser_navigate");
4483        let err = h(unreached_state(), json!({}))
4484            .await
4485            .expect_err("missing url must error");
4486        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4487    }
4488
4489    #[tokio::test]
4490    async fn fetch_missing_url_errors_before_backend() {
4491        let h = handler_for("browser_fetch");
4492        let err = h(unreached_state(), json!({"method": "GET"}))
4493            .await
4494            .expect_err("missing url must error");
4495        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4496    }
4497
4498    #[tokio::test]
4499    async fn curl_missing_args_errors_before_backend() {
4500        let h = handler_for("browser_curl");
4501        let err = h(unreached_state(), json!({}))
4502            .await
4503            .expect_err("missing args must error");
4504        assert!(err.to_string().contains("'args'"), "got: {err:#}");
4505    }
4506
4507    #[tokio::test]
4508    async fn curl_rejects_non_string_args_before_backend() {
4509        let h = handler_for("browser_curl");
4510        let err = h(unreached_state(), json!({"args": ["-L", 7]}))
4511            .await
4512            .expect_err("non-string args must error");
4513        assert!(
4514            err.to_string()
4515                .contains("every curl argument must be a string"),
4516            "got: {err:#}"
4517        );
4518    }
4519
4520    #[tokio::test]
4521    async fn storage_set_missing_value_errors_before_backend() {
4522        let h = handler_for("browser_storage_set");
4523        let err = h(unreached_state(), json!({"key": "k"}))
4524            .await
4525            .expect_err("missing value must error");
4526        assert!(err.to_string().contains("missing 'value'"), "got: {err:#}");
4527    }
4528
4529    #[tokio::test]
4530    async fn storage_get_missing_key_errors_before_backend() {
4531        let h = handler_for("browser_storage_get");
4532        let err = h(unreached_state(), json!({}))
4533            .await
4534            .expect_err("missing key must error");
4535        assert!(err.to_string().contains("missing 'key'"), "got: {err:#}");
4536    }
4537
4538    /// `tab` and `target` are mutually exclusive; the reject fires in
4539    /// `resolve_target_for_args` before any backend connection.
4540    #[tokio::test]
4541    async fn navigate_tab_and_target_mutually_exclusive() {
4542        let h = handler_for("browser_navigate");
4543        let err = h(
4544            unreached_state(),
4545            json!({"url": "https://e.test/", "tab": "a", "target": "b"}),
4546        )
4547        .await
4548        .expect_err("tab+target must error");
4549        assert!(
4550            err.to_string().contains("mutually exclusive"),
4551            "got: {err:#}"
4552        );
4553    }
4554}