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::cli::storage::{build_get_expr, build_set_expr, ns_global};
21use crate::cli::wait_for_cookie::cookie_matches;
22use crate::detect::Engine;
23use crate::dom::scripts::{FETCH_JS, GET_CLIP_RECT_JS, GET_DOM_JS, SELECT_ELEMENT_JS};
24use crate::errors::SessionError;
25use crate::mcp::server::{RegisteredTool, ServerState, ToolHandler, ToolRegistry};
26use crate::session::backend::TabBackend;
27use crate::session::freshness;
28use crate::session::targets::TargetInfo;
29
30/// Per-op timeout for read tools (`browser_get_html`,
31/// `browser_select_element` short path, storage). 10 s is generous for
32/// legitimate DOM work and tight enough that a wedged renderer
33/// fast-fails.
34const MCP_OP_TIMEOUT: Duration = Duration::from_secs(10);
35
36/// Per-op timeout for `browser_fetch`. Slow HTTP fetches over real
37/// networks can take many seconds; 60 s matches the CLI `fetch
38/// --timeout-ms` default.
39const MCP_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
40
41/// Per-op timeout for `browser_select_element`. The overlay waits for a
42/// human click, so the bound has to be much longer than for automated
43/// tools. Five minutes is plenty for an interactive selection without
44/// leaking forever if the page is left abandoned.
45const MCP_SELECT_ELEMENT_TIMEOUT: Duration = Duration::from_secs(300);
46
47/// Probe budget for `browser_tab_select`: how long we give the selected
48/// tab to answer `Runtime.evaluate("1")` / `script.evaluate("1")` before
49/// returning `TabHung`. Matches `session::attach::PICK_PROBE_TIMEOUT`.
50const TAB_SELECT_PROBE: Duration = Duration::from_millis(500);
51
52/// Native wake/probe budget used only after a Playwright sidecar CDP failure.
53const SIDECAR_WAKE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
54
55/// Register the standard tool set onto the given registry.
56pub fn register_all(registry: &ToolRegistry) {
57    // Renamed-from-Playwright tools.
58    registry.register(make_navigate());
59    registry.register(make_eval());
60    registry.register(make_get_html());
61    registry.register(make_take_screenshot());
62    registry.register(make_fetch());
63    registry.register(make_select_element());
64    registry.register(make_cookies());
65    registry.register(make_storage_get());
66    registry.register(make_storage_set());
67    registry.register(make_wait_for_cookie());
68    // Diagnostic enumeration (kept).
69    registry.register(make_list_targets());
70    // New tab-management tools.
71    registry.register(make_tab_list());
72    registry.register(make_tab_new());
73    registry.register(make_tab_select());
74    registry.register(make_tab_close());
75    // New browser-management tools.
76    registry.register(make_browser_start());
77    registry.register(make_browser_select());
78    registry.register(make_browser_list());
79    registry.register(make_browser_show());
80    // Playwright-only interaction tools — Chromium-family only (route
81    // through the Node sidecar). Each errors with `EngineUnsupported`
82    // when the active browser is BiDi.
83    registry.register(make_snapshot());
84    registry.register(make_click());
85    registry.register(make_type());
86    registry.register(make_hover());
87    registry.register(make_drag());
88    registry.register(make_press_key());
89    registry.register(make_wait_for());
90    registry.register(make_pdf_save());
91}
92
93// ---------------------------------------------------------------------------
94// Helpers.
95// ---------------------------------------------------------------------------
96
97fn text_content(text: impl Into<String>) -> Value {
98    json!({ "content": [ { "type": "text", "text": text.into() } ] })
99}
100
101fn image_content(data: String) -> Value {
102    json!({
103        "content": [ { "type": "image", "data": data, "mimeType": "image/png" } ]
104    })
105}
106
107fn handler<F>(f: F) -> ToolHandler
108where
109    F: Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
110        + Send
111        + Sync
112        + 'static,
113{
114    Arc::new(f)
115}
116
117/// Schema fragment for optional `tab` / `target` args. Inlined into
118/// every per-tab tool's input schema so the agent-facing contract is
119/// consistent.
120fn tab_args_schema() -> Value {
121    json!({
122        "tab": {
123            "type": "string",
124            "description": "Optional named tab; mutually exclusive with `target`."
125        },
126        "target": {
127            "type": "string",
128            "description": "Optional URL regex selecting an existing tab; mutually exclusive with `tab`."
129        }
130    })
131}
132
133/// Canonical builder for a per-tab tool's `properties` object: the shared
134/// `tab` / `target` schema merged with tool-specific `extra` fields. The
135/// merge result is order-independent — `serde_json::Map` serializes keys
136/// sorted — so callers may pass `extra` in any shape.
137fn tab_args_properties(extra: Value) -> Value {
138    let mut obj = extra.as_object().cloned().unwrap_or_default();
139    if let Some(ta) = tab_args_schema().as_object() {
140        for (k, v) in ta {
141            obj.insert(k.clone(), v.clone());
142        }
143    }
144    Value::Object(obj)
145}
146
147/// Canonical extraction of the optional `tab` (named) / `target` (URL
148/// regex) routing args from a tool's `args`. Mirrors the parse in
149/// [`ServerState::resolve_target_for_args`]; used by tools that need to
150/// branch on whether explicit routing was given before resolving.
151fn extract_tab_target(args: &Value) -> (Option<String>, Option<String>) {
152    let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
153    let target = args
154        .get("target")
155        .and_then(|v| v.as_str())
156        .map(String::from);
157    (tab, target)
158}
159
160fn max_age_arg(args: &Value) -> Result<Duration> {
161    match args.get("max_age") {
162        None | Some(Value::Null) => Ok(freshness::DEFAULT_MAX_AGE),
163        Some(Value::String(s)) => freshness::parse_max_age(s),
164        Some(Value::Number(n)) => n
165            .as_u64()
166            .map(Duration::from_secs)
167            .ok_or_else(|| anyhow!("`max_age` number must be non-negative seconds")),
168        Some(_) => Err(anyhow!(
169            "`max_age` must be a duration string, e.g. `10m` or `1h`"
170        )),
171    }
172}
173
174fn timeout_ms_arg(args: &Value, key: &str, default: Duration) -> Result<Duration> {
175    match args.get(key) {
176        None | Some(Value::Null) => Ok(default),
177        Some(Value::Number(n)) => n
178            .as_u64()
179            .map(Duration::from_millis)
180            .ok_or_else(|| anyhow!("`{key}` number must be non-negative milliseconds")),
181        Some(_) => Err(anyhow!(
182            "`{key}` must be a non-negative number of milliseconds"
183        )),
184    }
185}
186
187// ---------------------------------------------------------------------------
188// browser_navigate
189// ---------------------------------------------------------------------------
190
191fn make_navigate() -> RegisteredTool {
192    RegisteredTool {
193        name: "browser_navigate".into(),
194        description: "Navigate the active page to a URL.".into(),
195        input_schema: json!({
196            "type": "object",
197            "properties": tab_args_properties(json!({ "url": { "type": "string" } })),
198            "required": ["url"],
199        }),
200        handler: handler(|state, args| {
201            Box::pin(async move {
202                let url = args
203                    .get("url")
204                    .and_then(|v| v.as_str())
205                    .ok_or_else(|| anyhow!("missing 'url'"))?
206                    .to_string();
207                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
208                backend.navigate(&target_id, &url).await?;
209                Ok(text_content(format!("Navigated to {url}")))
210            })
211        }),
212    }
213}
214
215// ---------------------------------------------------------------------------
216// browser_eval
217// ---------------------------------------------------------------------------
218
219fn make_eval() -> RegisteredTool {
220    RegisteredTool {
221        name: "browser_eval".into(),
222        description: "Evaluate a JavaScript expression in the active page.".into(),
223        input_schema: json!({
224            "type": "object",
225            "properties": tab_args_properties(json!({
226                "expression": {
227                    "type": "string",
228                    "description": "JavaScript expression to evaluate."
229                },
230                "await_promise": {
231                    "type": "boolean",
232                    "default": true,
233                    "description": "Treat the expression as a Promise and await it."
234                },
235                "timeout_ms": {
236                    "type": "number",
237                    "description": "Per-call timeout in milliseconds (default 10000)."
238                },
239                "max_age": {
240                    "type": "string",
241                    "description": "Reload the page first if its document is older than this duration (default 10m)."
242                }
243            })),
244            "required": ["expression"],
245        }),
246        handler: handler(|state, args| {
247            Box::pin(async move {
248                let expression = args
249                    .get("expression")
250                    .and_then(|v| v.as_str())
251                    .ok_or_else(|| anyhow!("missing 'expression'"))?
252                    .to_string();
253                let await_promise = args
254                    .get("await_promise")
255                    .and_then(Value::as_bool)
256                    .unwrap_or(true);
257                let timeout = timeout_ms_arg(&args, "timeout_ms", MCP_OP_TIMEOUT)?;
258                let max_age = max_age_arg(&args)?;
259                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
260                backend.ensure_fresh(&target_id, max_age).await?;
261                let value = backend
262                    .evaluate(&target_id, &expression, await_promise, timeout)
263                    .await?;
264                Ok(text_content(serde_json::to_string_pretty(&value)?))
265            })
266        }),
267    }
268}
269
270// ---------------------------------------------------------------------------
271// browser_get_html
272// ---------------------------------------------------------------------------
273
274fn make_get_html() -> RegisteredTool {
275    RegisteredTool {
276        name: "browser_get_html".into(),
277        description: "Get the rendered DOM as HTML, with shadow roots serialized when supported."
278            .into(),
279        input_schema: json!({
280            "type": "object",
281            "properties": tab_args_properties(json!({
282                "selector": {
283                    "type": "string",
284                    "description": "Optional CSS selector; defaults to the document element."
285                }
286            })),
287        }),
288        handler: handler(|state, args| {
289            Box::pin(async move {
290                let selector_arg = args.get("selector").and_then(|v| v.as_str());
291                let selector_literal = match selector_arg {
292                    Some(s) => serde_json::to_string(s)?,
293                    None => "null".to_string(),
294                };
295                let expr = format!("({GET_DOM_JS})({selector_literal})");
296                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
297                let value = backend
298                    .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
299                    .await?;
300                let html = value.as_str().unwrap_or("").to_string();
301                Ok(text_content(html))
302            })
303        }),
304    }
305}
306
307// ---------------------------------------------------------------------------
308// browser_take_screenshot
309// ---------------------------------------------------------------------------
310
311fn make_take_screenshot() -> RegisteredTool {
312    RegisteredTool {
313        name: "browser_take_screenshot".into(),
314        description: "Capture a PNG screenshot of the active page.".into(),
315        input_schema: json!({
316            "type": "object",
317            "properties": tab_args_properties(json!({
318                "full_page": { "type": "boolean", "default": false },
319                "selector": { "type": "string" }
320            })),
321        }),
322        handler: handler(|state, args| {
323            Box::pin(async move {
324                let full_page = args
325                    .get("full_page")
326                    .and_then(|v| v.as_bool())
327                    .unwrap_or(false);
328                let selector = args.get("selector").and_then(|v| v.as_str());
329                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
330                // A selector clips the capture to that element's bounding box.
331                let clip = match selector {
332                    Some(sel) => {
333                        let sel_literal = serde_json::to_string(sel)?;
334                        let expr = format!("({GET_CLIP_RECT_JS})({sel_literal})");
335                        let rect = backend
336                            .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
337                            .await?;
338                        if rect.is_null() {
339                            return Err(anyhow!("selector matched no visible element: {sel}"));
340                        }
341                        Some(rect)
342                    }
343                    None => None,
344                };
345                let b64 = backend.screenshot(&target_id, full_page, clip).await?;
346                Ok(image_content(b64))
347            })
348        }),
349    }
350}
351
352// ---------------------------------------------------------------------------
353// browser_fetch
354// ---------------------------------------------------------------------------
355
356fn make_fetch() -> RegisteredTool {
357    RegisteredTool {
358        name: "browser_fetch".into(),
359        description:
360            "Perform an HTTP request from the page context (preserves cookies, bypasses CORS)."
361                .into(),
362        input_schema: json!({
363            "type": "object",
364            "properties": tab_args_properties(json!({
365                "url": { "type": "string" },
366                "method": { "type": "string" },
367                "headers": { "type": "object" },
368                "body": { "type": "string" },
369                "max_age": {
370                    "type": "string",
371                    "description": "Reload the page first if its document is older than this duration (default 10m)."
372                }
373            })),
374            "required": ["url"],
375        }),
376        handler: handler(|state, args| {
377            Box::pin(async move {
378                if args.get("url").and_then(|v| v.as_str()).is_none() {
379                    return Err(anyhow!("missing 'url'"));
380                }
381                // Strip routing args before forwarding to the JS shim.
382                let mut for_js = args.clone();
383                if let Some(obj) = for_js.as_object_mut() {
384                    obj.remove("tab");
385                    obj.remove("target");
386                    obj.remove("max_age");
387                }
388                let max_age = max_age_arg(&args)?;
389                let args_json = serde_json::to_string(&for_js)?;
390                let args_literal = serde_json::to_string(&args_json)?;
391                let expr = format!("({FETCH_JS})({args_literal})");
392                // Explicit `tab`/`target` routing is honoured verbatim. With
393                // neither, route to a tab on the URL's origin rather than the
394                // server's `about:blank` active tab — an opaque-origin fetch
395                // silently drops cookies/credentials and trips CORS. Mirrors
396                // `cli::fetch`'s origin-bound default path.
397                let (tab, target) = extract_tab_target(&args);
398                let has_route = tab.is_some() || target.is_some();
399                let (backend, target_id) = if has_route {
400                    state.resolve_target_for_args(&args).await?
401                } else {
402                    let url = args.get("url").and_then(|v| v.as_str()).unwrap();
403                    state.resolve_or_create_for_origin(url).await?
404                };
405                backend.ensure_fresh(&target_id, max_age).await?;
406                let value = backend
407                    .evaluate(&target_id, &expr, true, MCP_FETCH_TIMEOUT)
408                    .await?;
409                let raw = value.as_str().unwrap_or("").to_string();
410                let parsed: Value = serde_json::from_str(&raw)
411                    .map_err(|e| anyhow!("invalid fetch response JSON: {e}"))?;
412                let pretty = serde_json::to_string_pretty(&parsed)?;
413                Ok(text_content(pretty))
414            })
415        }),
416    }
417}
418
419// ---------------------------------------------------------------------------
420// browser_select_element
421// ---------------------------------------------------------------------------
422
423fn make_select_element() -> RegisteredTool {
424    RegisteredTool {
425        name: "browser_select_element".into(),
426        description:
427            "Show an interactive overlay; resolve with the CSS selector for the clicked element."
428                .into(),
429        input_schema: json!({
430            "type": "object",
431            "properties": tab_args_properties(json!({})),
432        }),
433        handler: handler(|state, args| {
434            Box::pin(async move {
435                let expr = SELECT_ELEMENT_JS.to_string();
436                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
437                // select_element shows an interactive overlay that the
438                // human clicks — extend the bound generously so the
439                // human has time to click.
440                let value = backend
441                    .evaluate(&target_id, &expr, true, MCP_SELECT_ELEMENT_TIMEOUT)
442                    .await?;
443                let selector = value.as_str().unwrap_or("").to_string();
444                Ok(text_content(selector))
445            })
446        }),
447    }
448}
449
450// ---------------------------------------------------------------------------
451// list_targets (legacy, CDP-shaped info-dense diagnostic)
452// ---------------------------------------------------------------------------
453
454fn make_list_targets() -> RegisteredTool {
455    RegisteredTool {
456        name: "list_targets".into(),
457        description: "List open page targets, optionally filtered by an unanchored URL regex. \
458                      CDP-shaped diagnostic; agents typically want `browser_tab_list`."
459            .into(),
460        input_schema: json!({
461            "type": "object",
462            "properties": {
463                "filter": {
464                    "type": "string",
465                    "description": "Optional unanchored URL regex."
466                }
467            },
468        }),
469        handler: handler(|state, args| {
470            Box::pin(async move {
471                let filter_re = args
472                    .get("filter")
473                    .and_then(|v| v.as_str())
474                    .map(Regex::new)
475                    .transpose()
476                    .map_err(|e| anyhow!("invalid `filter` regex: {e}"))?;
477                // Route through the server-owned backend rather than opening
478                // a fresh BiDi session (which would fail/race on Firefox).
479                // `live_targets` is the same primitive `browser_tab_list`
480                // uses; re-shape it into the legacy CDP-style `TargetInfo`.
481                let backend = state.ensure_backend().await?;
482                let kind = match state.browser_snapshot().await.engine {
483                    Engine::Cdp => "page",
484                    Engine::Bidi => "context",
485                };
486                let targets: Vec<TargetInfo> = backend
487                    .live_targets()
488                    .await?
489                    .into_iter()
490                    .filter(|t| filter_re.as_ref().map_or(true, |re| re.is_match(&t.url)))
491                    .map(|t| TargetInfo {
492                        id: t.id,
493                        url: t.url,
494                        title: t.title,
495                        kind: kind.to_string(),
496                    })
497                    .collect();
498                Ok(text_content(serde_json::to_string_pretty(&targets)?))
499            })
500        }),
501    }
502}
503
504// ---------------------------------------------------------------------------
505// browser_cookies
506// ---------------------------------------------------------------------------
507
508fn make_cookies() -> RegisteredTool {
509    RegisteredTool {
510        name: "browser_cookies".into(),
511        description: "Fetch cookies from the active browser. Returns full values (MCP is a \
512                      trusted local channel). Optional unanchored regex filters."
513            .into(),
514        input_schema: json!({
515            "type": "object",
516            "properties": {
517                "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
518                "name":   { "type": "string", "description": "Unanchored regex on cookie name." }
519            },
520        }),
521        handler: handler(|state, args| {
522            Box::pin(async move {
523                let domain_re = args
524                    .get("domain")
525                    .and_then(|v| v.as_str())
526                    .map(Regex::new)
527                    .transpose()
528                    .map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
529                let name_re = args
530                    .get("name")
531                    .and_then(|v| v.as_str())
532                    .map(Regex::new)
533                    .transpose()
534                    .map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
535                // Route through the server-owned backend (reuses the open
536                // session) instead of `fetch_cookies`, which opens a fresh
537                // BiDi session and would fail/race on Firefox.
538                let backend = state.ensure_backend().await?;
539                let all = backend.cookies().await?;
540                let filtered: Vec<_> = all
541                    .into_iter()
542                    .filter(|c| {
543                        domain_re.as_ref().map_or(true, |re| re.is_match(&c.domain))
544                            && name_re.as_ref().map_or(true, |re| re.is_match(&c.name))
545                    })
546                    .collect();
547                Ok(text_content(serde_json::to_string_pretty(&filtered)?))
548            })
549        }),
550    }
551}
552
553// ---------------------------------------------------------------------------
554// browser_storage_get / browser_storage_set
555// ---------------------------------------------------------------------------
556
557fn make_storage_get() -> RegisteredTool {
558    RegisteredTool {
559        name: "browser_storage_get".into(),
560        description: "Read a value from localStorage or sessionStorage on the active page.".into(),
561        input_schema: json!({
562            "type": "object",
563            "properties": tab_args_properties(json!({
564                "key": { "type": "string" },
565                "namespace": {
566                    "type": "string",
567                    "enum": ["local", "session"],
568                    "default": "local"
569                },
570                "max_age": {
571                    "type": "string",
572                    "description": "Reload the page first if its document is older than this duration (default 10m)."
573                }
574            })),
575            "required": ["key"],
576        }),
577        handler: handler(|state, args| {
578            Box::pin(async move {
579                let key = args
580                    .get("key")
581                    .and_then(|v| v.as_str())
582                    .ok_or_else(|| anyhow!("missing 'key'"))?
583                    .to_string();
584                let namespace = args
585                    .get("namespace")
586                    .and_then(|v| v.as_str())
587                    .unwrap_or("local");
588                let ns = ns_global(namespace)?;
589                let expr = build_get_expr(ns, &key);
590                let max_age = max_age_arg(&args)?;
591                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
592                backend.ensure_fresh(&target_id, max_age).await?;
593                let value = backend
594                    .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
595                    .await?;
596                // `build_get_expr` wraps the result in JSON.stringify, so the
597                // evaluator returns a JSON string. Unwrap one layer to surface
598                // the raw value (or `null` when the key is absent).
599                let text = match value {
600                    Value::String(s) => s,
601                    Value::Null => "null".to_string(),
602                    other => other.to_string(),
603                };
604                Ok(text_content(text))
605            })
606        }),
607    }
608}
609
610fn make_storage_set() -> RegisteredTool {
611    RegisteredTool {
612        name: "browser_storage_set".into(),
613        description: "Write a value to localStorage or sessionStorage on the active page.".into(),
614        input_schema: json!({
615            "type": "object",
616            "properties": tab_args_properties(json!({
617                "key": { "type": "string" },
618                "value": { "type": "string" },
619                "namespace": {
620                    "type": "string",
621                    "enum": ["local", "session"],
622                    "default": "local"
623                }
624            })),
625            "required": ["key", "value"],
626        }),
627        handler: handler(|state, args| {
628            Box::pin(async move {
629                let key = args
630                    .get("key")
631                    .and_then(|v| v.as_str())
632                    .ok_or_else(|| anyhow!("missing 'key'"))?
633                    .to_string();
634                let value = args
635                    .get("value")
636                    .and_then(|v| v.as_str())
637                    .ok_or_else(|| anyhow!("missing 'value'"))?
638                    .to_string();
639                let namespace = args
640                    .get("namespace")
641                    .and_then(|v| v.as_str())
642                    .unwrap_or("local");
643                let ns = ns_global(namespace)?;
644                let expr = build_set_expr(ns, &key, &value);
645                let (backend, target_id) = state.resolve_target_for_args(&args).await?;
646                let _ = backend
647                    .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
648                    .await?;
649                Ok(text_content("ok"))
650            })
651        }),
652    }
653}
654
655// ---------------------------------------------------------------------------
656// browser_wait_for_cookie
657// ---------------------------------------------------------------------------
658
659fn make_wait_for_cookie() -> RegisteredTool {
660    RegisteredTool {
661        name: "browser_wait_for_cookie".into(),
662        description: "Poll the browser until a cookie matching the regex filters appears, or \
663                      timeout elapses."
664            .into(),
665        input_schema: json!({
666            "type": "object",
667            "properties": {
668                "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
669                "name":   { "type": "string", "description": "Unanchored regex on cookie name." },
670                "timeout_seconds": { "type": "number", "default": 120 },
671                "poll_interval_seconds": { "type": "number", "default": 1 }
672            },
673            "required": ["domain", "name"],
674        }),
675        handler: handler(|state, args| {
676            Box::pin(async move {
677                let domain = args
678                    .get("domain")
679                    .and_then(|v| v.as_str())
680                    .ok_or_else(|| anyhow!("missing 'domain'"))?;
681                let name = args
682                    .get("name")
683                    .and_then(|v| v.as_str())
684                    .ok_or_else(|| anyhow!("missing 'name'"))?;
685                let domain_re =
686                    Regex::new(domain).map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
687                let name_re = Regex::new(name).map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
688                let timeout_s = args
689                    .get("timeout_seconds")
690                    .and_then(|v| v.as_f64())
691                    .unwrap_or(120.0)
692                    .max(0.0);
693                let interval_s = args
694                    .get("poll_interval_seconds")
695                    .and_then(|v| v.as_f64())
696                    .unwrap_or(1.0)
697                    .max(0.001);
698                let deadline = Instant::now() + Duration::from_secs_f64(timeout_s);
699                let interval = Duration::from_secs_f64(interval_s);
700                // Acquire the server-owned backend once; reuse it each poll
701                // rather than opening a fresh BiDi session per iteration
702                // (which would fail/race on Firefox).
703                let backend = state.ensure_backend().await?;
704                loop {
705                    let cookies = backend.cookies().await?;
706                    if let Some(c) = cookies
707                        .into_iter()
708                        .find(|c| cookie_matches(c, &domain_re, &name_re))
709                    {
710                        return Ok(text_content(c.name));
711                    }
712                    let now = Instant::now();
713                    if now >= deadline {
714                        return Err(anyhow!("timed out waiting for cookie"));
715                    }
716                    let remaining = deadline.saturating_duration_since(now);
717                    let nap = std::cmp::min(interval, remaining);
718                    if nap.is_zero() {
719                        return Err(anyhow!("timed out waiting for cookie"));
720                    }
721                    tokio::time::sleep(nap).await;
722                }
723            })
724        }),
725    }
726}
727
728// ---------------------------------------------------------------------------
729// browser_tab_list / browser_tab_new / browser_tab_select / browser_tab_close
730// ---------------------------------------------------------------------------
731
732fn make_tab_list() -> RegisteredTool {
733    RegisteredTool {
734        name: "browser_tab_list".into(),
735        description: "List open tabs in the active browser, Playwright-shaped \
736                      (`[{target_id, url, title, active}]`)."
737            .into(),
738        input_schema: json!({"type": "object", "properties": {}}),
739        handler: handler(|state, _args| {
740            Box::pin(async move {
741                let v = tab_list_value(&state).await?;
742                Ok(text_content(serde_json::to_string_pretty(&v)?))
743            })
744        }),
745    }
746}
747
748/// Build the `[{target_id, url, title, active}]` value for the current
749/// browser. Shared between `browser_tab_list` and `browser_select`'s
750/// response.
751async fn tab_list_value(state: &ServerState) -> Result<Value> {
752    let backend = state.ensure_backend().await?;
753    let targets = backend.live_targets().await?;
754    let active = state.active_target_id.lock().await.clone();
755    let arr: Vec<Value> = targets
756        .into_iter()
757        .map(|t| {
758            json!({
759                "target_id": t.id,
760                "url": t.url,
761                "title": t.title,
762                "active": active.as_deref() == Some(t.id.as_str()),
763            })
764        })
765        .collect();
766    Ok(Value::Array(arr))
767}
768
769fn make_tab_new() -> RegisteredTool {
770    RegisteredTool {
771        name: "browser_tab_new".into(),
772        description: "Create a new tab and make it the active tab. Defaults to about:blank. \
773                      Pass `name` to create or select a durable named tab addressable as \
774                      `<browser>/<name>`."
775            .into(),
776        input_schema: json!({
777            "type": "object",
778            "properties": {
779                "name": { "type": "string", "description": "Optional named-tab id (a-z, 0-9, '-', '_')." },
780                "url": { "type": "string", "description": "Optional URL; defaults to about:blank." }
781            },
782        }),
783        handler: handler(|state, args| {
784            Box::pin(async move {
785                if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
786                    let url = args.get("url").and_then(|v| v.as_str());
787                    let opened = open_or_create_named_tab(&state, name, url).await?;
788                    return Ok(text_content(serde_json::to_string_pretty(&opened)?));
789                }
790                let url = args
791                    .get("url")
792                    .and_then(|v| v.as_str())
793                    .unwrap_or("about:blank")
794                    .to_string();
795                let backend = state.ensure_backend().await?;
796                let tid = backend.create_tab(&url).await?;
797                *state.active_target_id.lock().await = Some(tid.clone());
798                Ok(text_content(serde_json::to_string_pretty(&json!({
799                    "target_id": tid,
800                    "url": url,
801                    "active": true,
802                }))?))
803            })
804        }),
805    }
806}
807
808async fn open_or_create_named_tab(
809    state: &ServerState,
810    name: &str,
811    url: Option<&str>,
812) -> Result<Value> {
813    crate::cli::env_resolver::validate_tab_name(name)?;
814    let want_url = url.unwrap_or("about:blank").to_string();
815    let backend = state.ensure_backend().await?;
816    let browser_name = state.registered_browser_name().await?;
817
818    let existing = {
819        let bn = browser_name.clone();
820        let n = name.to_string();
821        crate::mcp::server::sync_registry_op(move |reg| reg.tab_get(&bn, &n)).await?
822    };
823    if let Some(row) = existing {
824        let live = backend.live_target_ids().await?;
825        if live.contains(&row.target_id) {
826            if url.is_some() && row.last_url != want_url {
827                backend.navigate(&row.target_id, &want_url).await?;
828                let bn = browser_name.clone();
829                let n = name.to_string();
830                let u = want_url.clone();
831                crate::mcp::server::sync_registry_op(move |reg| reg.tab_set_url(&bn, &n, &u))
832                    .await?;
833            } else {
834                let bn = browser_name.clone();
835                let n = name.to_string();
836                crate::mcp::server::sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
837            }
838            *state.active_target_id.lock().await = Some(row.target_id.clone());
839            return Ok(json!({
840                "name": name,
841                "target_id": row.target_id,
842                "url": if url.is_some() { want_url } else { row.last_url },
843                "active": true,
844                "created": false,
845            }));
846        }
847
848        let _ = backend.close_tab(&row.target_id).await;
849        let bn = browser_name.clone();
850        let n = name.to_string();
851        crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
852    }
853
854    let victim = {
855        let bn = browser_name.clone();
856        crate::mcp::server::sync_registry_op(
857            move |reg| -> Result<Option<crate::registry::TabRow>> {
858                if reg.tabs_count_daemon_created(&bn)? >= crate::session::tabs::HARD_CAP {
859                    reg.tabs_lru_daemon_created(&bn)
860                } else {
861                    Ok(None)
862                }
863            },
864        )
865        .await?
866    };
867    if let Some(victim) = victim {
868        let _ = backend.close_tab(&victim.target_id).await;
869        let bn = victim.browser_name;
870        let n = victim.name;
871        crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
872    }
873
874    let target_id = backend.create_tab(&want_url).await?;
875    let bn = browser_name;
876    let n = name.to_string();
877    let tid = target_id.clone();
878    let u = want_url.clone();
879    crate::mcp::server::sync_registry_op(move |reg| reg.tab_upsert(&bn, &n, &tid, &u, true))
880        .await?;
881    *state.active_target_id.lock().await = Some(target_id.clone());
882    Ok(json!({
883        "name": name,
884        "target_id": target_id,
885        "url": want_url,
886        "active": true,
887        "created": true,
888    }))
889}
890
891fn make_tab_select() -> RegisteredTool {
892    RegisteredTool {
893        name: "browser_tab_select".into(),
894        description: "Set the active tab. Probe-and-iterate: errors `TabHung` if the selected \
895                      tab doesn't respond to a 500ms probe (agent should pick another or call \
896                      `browser_tab_new`)."
897            .into(),
898        input_schema: json!({
899            "type": "object",
900            "properties": {
901                "target_id": { "type": "string" }
902            },
903            "required": ["target_id"],
904        }),
905        handler: handler(|state, args| {
906            Box::pin(async move {
907                use crate::errors::SessionError;
908                let tid = args
909                    .get("target_id")
910                    .and_then(|v| v.as_str())
911                    .ok_or_else(|| anyhow!("missing 'target_id'"))?
912                    .to_string();
913                let backend = state.ensure_backend().await?;
914                let live = backend.live_target_ids().await?;
915                if !live.contains(&tid) {
916                    return Err(SessionError::TabNotFound {
917                        browser: state
918                            .registered_browser_name()
919                            .await
920                            .unwrap_or_else(|_| "<external>".to_string()),
921                        name: tid,
922                    }
923                    .into());
924                }
925                // Probe the tab. We don't auto-recreate on hang — the
926                // agent asked for THIS tab; bubble up `TabHung` so they
927                // can choose to `browser_tab_new` or pick a different
928                // tab.
929                let probed = tokio::time::timeout(
930                    TAB_SELECT_PROBE,
931                    backend.evaluate(&tid, "1", false, TAB_SELECT_PROBE),
932                )
933                .await;
934                let ok = matches!(probed, Ok(Ok(_)));
935                if !ok {
936                    return Err(SessionError::TabHung {
937                        target_id: Some(tid),
938                        url: None,
939                        timeout_ms: TAB_SELECT_PROBE.as_millis() as u64,
940                        hint: "selected-tab-hung",
941                    }
942                    .into());
943                }
944                *state.active_target_id.lock().await = Some(tid.clone());
945                Ok(text_content(serde_json::to_string_pretty(&json!({
946                    "target_id": tid,
947                    "active": true,
948                }))?))
949            })
950        }),
951    }
952}
953
954fn make_tab_close() -> RegisteredTool {
955    RegisteredTool {
956        name: "browser_tab_close".into(),
957        description: "Close a tab. Defaults to the active tab; clears the active pointer if the \
958                      closed tab was active."
959            .into(),
960        input_schema: json!({
961            "type": "object",
962            "properties": {
963                "target_id": { "type": "string", "description": "Optional; defaults to active tab." }
964            },
965        }),
966        handler: handler(|state, args| {
967            Box::pin(async move {
968                let backend = state.ensure_backend().await?;
969                let explicit = args
970                    .get("target_id")
971                    .and_then(|v| v.as_str())
972                    .map(|s| s.to_string());
973                let active = state.active_target_id.lock().await.clone();
974                let tid = match (explicit, &active) {
975                    (Some(e), _) => e,
976                    (None, Some(a)) => a.clone(),
977                    (None, None) => {
978                        return Err(anyhow!("no `target_id` given and no active tab to close"));
979                    }
980                };
981                backend.close_tab(&tid).await?;
982                // If we just closed the active tab, clear the pointer.
983                let mut ptr = state.active_target_id.lock().await;
984                if ptr.as_deref() == Some(tid.as_str()) {
985                    *ptr = None;
986                }
987                Ok(text_content(serde_json::to_string_pretty(&json!({
988                    "closed": tid,
989                }))?))
990            })
991        }),
992    }
993}
994
995// ---------------------------------------------------------------------------
996// browser_select / browser_list
997// ---------------------------------------------------------------------------
998
999fn make_browser_start() -> RegisteredTool {
1000    RegisteredTool {
1001        name: "browser_start".into(),
1002        description: "Start or reuse a browser, then make it the active MCP browser. \
1003                      Use this to recover after the active browser exits."
1004            .into(),
1005        input_schema: json!({
1006            "type": "object",
1007            "properties": {
1008                "browser": { "type": "string", "description": "Optional browser kind (chrome, edge, chromium, brave, firefox). Defaults to the first installed Chromium-family browser." },
1009                "headless": { "type": "boolean", "default": false },
1010                "wait_timeout_seconds": { "type": "integer", "default": 30 }
1011            },
1012        }),
1013        handler: handler(|state, args| {
1014            Box::pin(async move {
1015                let browser = args
1016                    .get("browser")
1017                    .and_then(|v| v.as_str())
1018                    .map(|s| s.to_string());
1019                let headless = args
1020                    .get("headless")
1021                    .and_then(|v| v.as_bool())
1022                    .unwrap_or(false);
1023                let wait_timeout = args
1024                    .get("wait_timeout_seconds")
1025                    .and_then(|v| v.as_u64())
1026                    .unwrap_or(30);
1027                let started =
1028                    crate::cli::start::ensure_started(browser, headless, false, wait_timeout)
1029                        .await?;
1030                let resolved = crate::cli::env_resolver::ResolvedBrowser {
1031                    endpoint: started.endpoint.clone(),
1032                    engine: started.engine,
1033                    source: crate::cli::env_resolver::Source::Registered {
1034                        name: started.name.clone(),
1035                    },
1036                };
1037                state.switch_browser(resolved).await?;
1038                let tabs = tab_list_value(&state).await?;
1039                Ok(text_content(serde_json::to_string_pretty(&json!({
1040                    "name": started.name,
1041                    "kind": started.kind.as_str(),
1042                    "engine": match started.engine {
1043                        crate::detect::Engine::Cdp => "cdp",
1044                        crate::detect::Engine::Bidi => "bidi",
1045                    },
1046                    "endpoint": started.endpoint,
1047                    "reused": started.reused,
1048                    "selected": true,
1049                    "tabs": tabs,
1050                }))?))
1051            })
1052        }),
1053    }
1054}
1055
1056fn make_browser_select() -> RegisteredTool {
1057    RegisteredTool {
1058        name: "browser_select".into(),
1059        description: "Switch the active browser by registered name, kind, URL, or CLI target \
1060                      syntax such as `chrome` or `brave/cart`. A kind selector starts or reuses \
1061                      that browser when none is live. The switch is committed before \
1062                      Firefox BiDi lock preparation; if preparation fails, the new browser remains \
1063                      active and the caller decides whether to retry, switch elsewhere, or switch back."
1064            .into(),
1065        input_schema: json!({
1066            "type": "object",
1067            "properties": {
1068                "name": { "type": "string", "description": "Browser selector, optionally `<browser>/<tab>`." }
1069            },
1070            "required": ["name"],
1071        }),
1072        handler: handler(|state, args| {
1073            Box::pin(async move {
1074                let name = args
1075                    .get("name")
1076                    .and_then(|v| v.as_str())
1077                    .ok_or_else(|| anyhow!("missing 'name'"))?
1078                    .to_string();
1079                let target = crate::cli::env_resolver::parse_target(&name)?;
1080                let resolved =
1081                    crate::mcp::server::resolve_browser_send(target.browser.clone()).await?;
1082                let resolved_clone = resolved.clone();
1083                state.switch_browser(resolved).await?;
1084                let selected_tab = if let Some(tab) = target.tab.as_deref() {
1085                    Some(open_or_create_named_tab(&state, tab, None).await?)
1086                } else {
1087                    None
1088                };
1089                let tabs = tab_list_value(&state).await?;
1090                Ok(text_content(serde_json::to_string_pretty(&json!({
1091                    "name": match &resolved_clone.source {
1092                        crate::cli::env_resolver::Source::Registered { name } => name.as_str(),
1093                        crate::cli::env_resolver::Source::External => "<external>",
1094                    },
1095                    "engine": match resolved_clone.engine {
1096                        crate::detect::Engine::Cdp => "cdp",
1097                        crate::detect::Engine::Bidi => "bidi",
1098                    },
1099                    "endpoint": resolved_clone.endpoint,
1100                    "selected_tab": selected_tab,
1101                    "tabs": tabs,
1102                }))?))
1103            })
1104        }),
1105    }
1106}
1107
1108fn make_browser_list() -> RegisteredTool {
1109    RegisteredTool {
1110        name: "browser_list".into(),
1111        description: "List live registered browsers with `[{name, kind, engine, endpoint, alive}]`; dead-process rows are pruned."
1112            .into(),
1113        input_schema: json!({"type": "object", "properties": {}}),
1114        handler: handler(|_state, _args| {
1115            Box::pin(async move {
1116                // `Registry` is `!Send`; do the read on a blocking thread.
1117                let arr = tokio::task::spawn_blocking(|| -> Result<Vec<Value>> {
1118                    let registry = crate::registry::Registry::open()?;
1119                    let rows = registry.list_alive()?;
1120                    Ok(rows
1121                        .into_iter()
1122                        .map(|r| {
1123                            json!({
1124                                "name": r.name,
1125                                "kind": r.kind.as_str(),
1126                                "engine": match r.engine {
1127                                    crate::detect::Engine::Cdp => "cdp",
1128                                    crate::detect::Engine::Bidi => "bidi",
1129                                },
1130                                "endpoint": r.endpoint,
1131                                "alive": true,
1132                            })
1133                        })
1134                        .collect())
1135                })
1136                .await??;
1137                Ok(text_content(serde_json::to_string_pretty(&Value::Array(
1138                    arr,
1139                ))?))
1140            })
1141        }),
1142    }
1143}
1144
1145fn make_browser_show() -> RegisteredTool {
1146    RegisteredTool {
1147        name: "browser_show".into(),
1148        description: "Explicitly reveal the active browser window for login or debugging. \
1149                      Normal automation keeps new tabs in the background."
1150            .into(),
1151        input_schema: json!({"type": "object", "properties": {}}),
1152        handler: handler(|state, _args| {
1153            Box::pin(async move {
1154                let backend = state.ensure_backend().await?;
1155                let target_id = backend.target_for_show().await?;
1156                let resolved = state.browser_snapshot().await;
1157                let source = resolved.source.clone();
1158                let os_activated = tokio::task::spawn_blocking(move || -> Result<bool> {
1159                    let registry = crate::registry::Registry::open()?;
1160                    crate::cli::show::activate_resolved_app(&registry, &source)
1161                })
1162                .await??;
1163                backend.show_tab(&target_id).await?;
1164                Ok(text_content(serde_json::to_string_pretty(&json!({
1165                    "target_id": target_id,
1166                    "os_activated": os_activated,
1167                }))?))
1168            })
1169        }),
1170    }
1171}
1172
1173// ---------------------------------------------------------------------------
1174// Playwright-only interaction tools (routed through the Node sidecar).
1175// ---------------------------------------------------------------------------
1176//
1177// Each tool:
1178//   1. Resolves the target tab via `state.resolve_target_for_args(args)`.
1179//   2. Acquires the sidecar via `state.ensure_sidecar(tool_name)`. On
1180//      BiDi browsers this errors with `EngineUnsupported`.
1181//   3. Forwards to the sidecar with `target_id` + tool-specific params.
1182
1183/// Forward a sidecar call. Resolves the target natively first, ensures the
1184/// sidecar is up, then sends the RPC with `target_id` merged into the params.
1185/// If Playwright fails at the CDP attachment/connection layer, wake and probe
1186/// the tab through browser-control's native backend before returning a typed
1187/// sidecar-specific error. This prevents agents from misreading a sidecar CDP
1188/// timeout as evidence that the page itself is hung.
1189async fn forward_to_sidecar(
1190    state: &ServerState,
1191    tool_name: &str,
1192    args: &Value,
1193    sidecar_method: &str,
1194    mut params: serde_json::Map<String, Value>,
1195) -> Result<Value> {
1196    // Preflight: check engine support before resolving the target, but do not
1197    // spawn the sidecar yet. If Playwright attach fails, we still need a native
1198    // backend + target id for the wake/probe diagnostic.
1199    state.ensure_sidecar_supported(tool_name).await?;
1200    let (backend, target_id) = state.resolve_target_for_args(args).await?;
1201    params.insert("target_id".into(), Value::String(target_id));
1202    let sc = match state.ensure_sidecar(tool_name).await {
1203        Ok(sc) => sc,
1204        Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1205            return sidecar_cdp_failure_after_probe(
1206                state,
1207                &backend,
1208                tool_name,
1209                sidecar_method,
1210                params
1211                    .get("target_id")
1212                    .and_then(|v| v.as_str())
1213                    .unwrap_or_default(),
1214                e,
1215            )
1216            .await;
1217        }
1218        Err(e) => return Err(e),
1219    };
1220    match sc.call(sidecar_method, Value::Object(params.clone())).await {
1221        Ok(v) => Ok(v),
1222        Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1223            sidecar_cdp_failure_after_probe(
1224                state,
1225                &backend,
1226                tool_name,
1227                sidecar_method,
1228                params
1229                    .get("target_id")
1230                    .and_then(|v| v.as_str())
1231                    .unwrap_or_default(),
1232                e,
1233            )
1234            .await
1235        }
1236        Err(e) => Err(e),
1237    }
1238}
1239
1240async fn sidecar_cdp_failure_after_probe(
1241    state: &ServerState,
1242    backend: &TabBackend,
1243    tool_name: &str,
1244    sidecar_method: &str,
1245    target_id: &str,
1246    err: anyhow::Error,
1247) -> Result<Value> {
1248    state.reset_sidecar().await;
1249    let url = wake_and_probe_target(backend, target_id).await?;
1250    Err(SessionError::SidecarConnectionFailed {
1251        tool: tool_name.to_string(),
1252        method: sidecar_method.to_string(),
1253        target_id: target_id.to_string(),
1254        url,
1255        details: format!("{err:#}"),
1256        hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
1257    }
1258    .into())
1259}
1260
1261async fn wake_and_probe_target(backend: &TabBackend, target_id: &str) -> Result<Option<String>> {
1262    match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.show_tab(target_id)).await {
1263        Ok(r) => r?,
1264        Err(_) => {
1265            return Err(SessionError::TabHung {
1266                target_id: Some(target_id.to_string()),
1267                url: None,
1268                timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1269                hint: "sidecar-wake-timeout",
1270            }
1271            .into());
1272        }
1273    }
1274
1275    match tokio::time::timeout(
1276        SIDECAR_WAKE_PROBE_TIMEOUT,
1277        backend.evaluate(target_id, "1", false, SIDECAR_WAKE_PROBE_TIMEOUT),
1278    )
1279    .await
1280    {
1281        Ok(r) => {
1282            let _ = r?;
1283        }
1284        Err(_) => {
1285            return Err(SessionError::TabHung {
1286                target_id: Some(target_id.to_string()),
1287                url: None,
1288                timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1289                hint: "sidecar-probe-timeout",
1290            }
1291            .into());
1292        }
1293    }
1294
1295    match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.live_targets()).await {
1296        Ok(Ok(targets)) => Ok(targets
1297            .into_iter()
1298            .find(|t| t.id == target_id)
1299            .map(|t| t.url)),
1300        _ => Ok(None),
1301    }
1302}
1303
1304fn looks_like_sidecar_cdp_attach_failure(err: &anyhow::Error) -> bool {
1305    let msg = format!("{err:#}").to_ascii_lowercase();
1306    msg.contains("<ws connecting>")
1307        || msg.contains("connectovercdp")
1308        || msg.contains("websocket")
1309        || msg.contains("browser has been closed")
1310        || msg.contains("browser closed")
1311        || msg.contains("browser disconnected")
1312        || msg.contains("target closed")
1313        || msg.contains("cdp session closed")
1314        || msg.contains("econnrefused")
1315        || msg.contains("econnreset")
1316        || msg.contains("socket hang up")
1317        || msg.contains("sidecar stdout closed")
1318        || msg.contains("sidecar writer closed")
1319        || msg.contains("sidecar response channel dropped")
1320}
1321
1322fn make_snapshot() -> RegisteredTool {
1323    RegisteredTool {
1324        name: "browser_snapshot".into(),
1325        description: "Capture an accessibility-tree snapshot (YAML) of the active page. \
1326                      Chromium-only via Playwright sidecar."
1327            .into(),
1328        input_schema: json!({
1329            "type": "object",
1330            "properties": tab_args_schema(),
1331        }),
1332        handler: handler(|state, args| {
1333            Box::pin(async move {
1334                let v = forward_to_sidecar(
1335                    &state,
1336                    "browser_snapshot",
1337                    &args,
1338                    "snapshot",
1339                    serde_json::Map::new(),
1340                )
1341                .await?;
1342                let yaml = v
1343                    .get("snapshot")
1344                    .and_then(|s| s.as_str())
1345                    .unwrap_or_default();
1346                Ok(text_content(yaml))
1347            })
1348        }),
1349    }
1350}
1351
1352// ---------------------------------------------------------------------------
1353// Table-driven sidecar interaction tools.
1354//
1355// click / type / hover / drag / press_key / wait_for all share one shape:
1356// build a param map from a fixed set of args, forward to the sidecar, return a
1357// fixed success string. Previously each tool declared its params *twice* — once
1358// in the input schema (`tab_args_properties`) and once in the handler (`copy_arg` per
1359// param) — with no compiler link, so a schema param missing a matching
1360// `copy_arg` was silently dropped before reaching the sidecar.
1361//
1362// `SidecarTool` is the single source of truth: each param's name + schema +
1363// required-ness is declared once in `params`, and BOTH the input schema and the
1364// param-forwarding are derived from it, so a param can't be in the schema but
1365// missing from the wire (or vice versa).
1366// ---------------------------------------------------------------------------
1367
1368/// One sidecar-forwarded parameter, declared once. Drives both the JSON schema
1369/// (`schema`, `required`) and the runtime forwarding (`name`).
1370struct SidecarParam {
1371    name: &'static str,
1372    schema: Value,
1373    required: bool,
1374}
1375
1376/// Declarative spec for a sidecar interaction tool. Both the input schema and
1377/// the param-forwarding are derived from the single `params` slice.
1378struct SidecarTool {
1379    name: &'static str,
1380    description: &'static str,
1381    /// The sidecar RPC method (e.g. `"click"`).
1382    method: &'static str,
1383    params: Vec<SidecarParam>,
1384    /// Fixed success message returned as text content.
1385    success: &'static str,
1386}
1387
1388impl SidecarTool {
1389    fn build(self) -> RegisteredTool {
1390        let SidecarTool {
1391            name,
1392            description,
1393            method,
1394            params,
1395            success,
1396        } = self;
1397
1398        // Schema: shared tab/target args plus this tool's params, with the
1399        // `required` list derived from the same table.
1400        let extra = Value::Object(
1401            params
1402                .iter()
1403                .map(|p| (p.name.to_string(), p.schema.clone()))
1404                .collect(),
1405        );
1406        let required: Vec<&str> = params
1407            .iter()
1408            .filter(|p| p.required)
1409            .map(|p| p.name)
1410            .collect();
1411        let mut input_schema = json!({
1412            "type": "object",
1413            "properties": tab_args_properties(extra),
1414        });
1415        if !required.is_empty() {
1416            input_schema["required"] = json!(required);
1417        }
1418
1419        // Forwarding: copy exactly the params declared above — no second list
1420        // to drift out of sync.
1421        let param_names: Vec<&'static str> = params.iter().map(|p| p.name).collect();
1422        RegisteredTool {
1423            name: name.into(),
1424            description: description.into(),
1425            input_schema,
1426            handler: handler(move |state, args| {
1427                let param_names = param_names.clone();
1428                Box::pin(async move {
1429                    let mut params = serde_json::Map::new();
1430                    for key in &param_names {
1431                        copy_arg(&args, key, &mut params);
1432                    }
1433                    forward_to_sidecar(&state, name, &args, method, params).await?;
1434                    Ok(text_content(success))
1435                })
1436            }),
1437        }
1438    }
1439}
1440
1441fn make_click() -> RegisteredTool {
1442    SidecarTool {
1443        name: "browser_click",
1444        description: "Click an element matched by CSS selector. Chromium-only.",
1445        method: "click",
1446        params: vec![
1447            SidecarParam {
1448                name: "selector",
1449                schema: json!({"type": "string"}),
1450                required: true,
1451            },
1452            SidecarParam {
1453                name: "timeout_ms",
1454                schema: json!({"type": "integer"}),
1455                required: false,
1456            },
1457        ],
1458        success: "clicked",
1459    }
1460    .build()
1461}
1462
1463fn make_type() -> RegisteredTool {
1464    SidecarTool {
1465        name: "browser_type",
1466        description: "Type text into an input matched by CSS selector. \
1467                      `press_sequentially=true` simulates keystrokes; default uses fast `fill`. \
1468                      Chromium-only.",
1469        method: "type",
1470        params: vec![
1471            SidecarParam {
1472                name: "selector",
1473                schema: json!({"type": "string"}),
1474                required: true,
1475            },
1476            SidecarParam {
1477                name: "text",
1478                schema: json!({"type": "string"}),
1479                required: true,
1480            },
1481            SidecarParam {
1482                name: "press_sequentially",
1483                schema: json!({"type": "boolean"}),
1484                required: false,
1485            },
1486            SidecarParam {
1487                name: "timeout_ms",
1488                schema: json!({"type": "integer"}),
1489                required: false,
1490            },
1491        ],
1492        success: "typed",
1493    }
1494    .build()
1495}
1496
1497fn make_hover() -> RegisteredTool {
1498    SidecarTool {
1499        name: "browser_hover",
1500        description: "Hover an element matched by CSS selector. Chromium-only.",
1501        method: "hover",
1502        params: vec![
1503            SidecarParam {
1504                name: "selector",
1505                schema: json!({"type": "string"}),
1506                required: true,
1507            },
1508            SidecarParam {
1509                name: "timeout_ms",
1510                schema: json!({"type": "integer"}),
1511                required: false,
1512            },
1513        ],
1514        success: "hovered",
1515    }
1516    .build()
1517}
1518
1519fn make_drag() -> RegisteredTool {
1520    SidecarTool {
1521        name: "browser_drag",
1522        description: "Drag from one CSS-selected element to another. Chromium-only.",
1523        method: "drag",
1524        params: vec![
1525            SidecarParam {
1526                name: "source_selector",
1527                schema: json!({"type": "string"}),
1528                required: true,
1529            },
1530            SidecarParam {
1531                name: "target_selector",
1532                schema: json!({"type": "string"}),
1533                required: true,
1534            },
1535        ],
1536        success: "dragged",
1537    }
1538    .build()
1539}
1540
1541fn make_press_key() -> RegisteredTool {
1542    SidecarTool {
1543        name: "browser_press_key",
1544        description: "Press a keyboard key (Playwright key name, e.g. 'Enter', 'Control+A'). \
1545                      Chromium-only.",
1546        method: "press_key",
1547        params: vec![SidecarParam {
1548            name: "key",
1549            schema: json!({"type": "string"}),
1550            required: true,
1551        }],
1552        success: "pressed",
1553    }
1554    .build()
1555}
1556
1557fn make_wait_for() -> RegisteredTool {
1558    SidecarTool {
1559        name: "browser_wait_for",
1560        description: "Wait for a condition: a selector reaching `state`, a URL matching \
1561                      `url_regex`, or the page reaching `load_state` (`load` / \
1562                      `domcontentloaded` / `networkidle`). Chromium-only.",
1563        method: "wait_for",
1564        params: vec![
1565            SidecarParam { name: "selector", schema: json!({"type": "string"}), required: false },
1566            SidecarParam { name: "state", schema: json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), required: false },
1567            SidecarParam { name: "url_regex", schema: json!({"type": "string"}), required: false },
1568            SidecarParam { name: "load_state", schema: json!({"type": "string", "enum": ["load", "domcontentloaded", "networkidle"]}), required: false },
1569            SidecarParam { name: "timeout_ms", schema: json!({"type": "integer"}), required: false },
1570        ],
1571        success: "ok",
1572    }
1573    .build()
1574}
1575
1576fn make_pdf_save() -> RegisteredTool {
1577    RegisteredTool {
1578        name: "browser_pdf_save".into(),
1579        description: "Render the active page to PDF (base64 in `pdf_base64`). Chromium-only."
1580            .into(),
1581        input_schema: json!({
1582            "type": "object",
1583            "properties": tab_args_schema(),
1584        }),
1585        handler: handler(|state, args| {
1586            Box::pin(async move {
1587                let v = forward_to_sidecar(
1588                    &state,
1589                    "browser_pdf_save",
1590                    &args,
1591                    "pdf",
1592                    serde_json::Map::new(),
1593                )
1594                .await?;
1595                let b64 = v
1596                    .get("pdf_base64")
1597                    .and_then(|s| s.as_str())
1598                    .unwrap_or_default();
1599                Ok(json!({
1600                    "content": [{
1601                        "type": "resource",
1602                        "resource": { "mimeType": "application/pdf", "blob": b64 }
1603                    }]
1604                }))
1605            })
1606        }),
1607    }
1608}
1609
1610/// Helper: copy a key from `args` into `dst` if present.
1611fn copy_arg(args: &Value, key: &str, dst: &mut serde_json::Map<String, Value>) {
1612    if let Some(v) = args.get(key) {
1613        dst.insert(key.into(), v.clone());
1614    }
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619    use super::*;
1620    use futures_util::{SinkExt, StreamExt};
1621    use tokio::sync::Mutex;
1622    use tokio_tungstenite::tungstenite::Message;
1623
1624    /// All tools the registry exposes after `register_all`. Mirrors the
1625    /// registration order in `register_all`.
1626    const EXPECTED_TOOLS: &[&str] = &[
1627        "browser_navigate",
1628        "browser_eval",
1629        "browser_get_html",
1630        "browser_take_screenshot",
1631        "browser_fetch",
1632        "browser_select_element",
1633        "browser_cookies",
1634        "browser_storage_get",
1635        "browser_storage_set",
1636        "browser_wait_for_cookie",
1637        "list_targets",
1638        "browser_tab_list",
1639        "browser_tab_new",
1640        "browser_tab_select",
1641        "browser_tab_close",
1642        "browser_start",
1643        "browser_select",
1644        "browser_list",
1645        "browser_show",
1646        "browser_snapshot",
1647        "browser_click",
1648        "browser_type",
1649        "browser_hover",
1650        "browser_drag",
1651        "browser_press_key",
1652        "browser_wait_for",
1653        "browser_pdf_save",
1654    ];
1655
1656    fn schema_for(name: &str) -> Value {
1657        let registry = ToolRegistry::new();
1658        register_all(&registry);
1659        registry
1660            .list()
1661            .into_iter()
1662            .find(|t| t["name"] == name)
1663            .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
1664            .clone()
1665    }
1666
1667    fn tool_description(name: &str) -> String {
1668        let registry = ToolRegistry::new();
1669        register_all(&registry);
1670        registry
1671            .list()
1672            .into_iter()
1673            .find(|t| t["name"] == name)
1674            .unwrap_or_else(|| panic!("tool {name} not registered"))["description"]
1675            .as_str()
1676            .unwrap_or("")
1677            .to_string()
1678    }
1679
1680    struct ScreenshotMock {
1681        endpoint: String,
1682        capture_params: Arc<Mutex<Vec<Value>>>,
1683    }
1684
1685    async fn spawn_screenshot_mock(selector_rect: Value) -> ScreenshotMock {
1686        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1687        let addr = listener.local_addr().unwrap();
1688        let capture_params = Arc::new(Mutex::new(Vec::new()));
1689        tokio::spawn({
1690            let capture_params = capture_params.clone();
1691            async move {
1692                let (stream, _) = listener.accept().await.unwrap();
1693                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1694                let mut next_session = 0u32;
1695                let mut eval_count = 0u32;
1696                while let Some(Ok(Message::Text(t))) = ws.next().await {
1697                    let req: Value = serde_json::from_str(&t).unwrap();
1698                    let id = req["id"].as_u64().unwrap();
1699                    let method = req["method"].as_str().unwrap_or("");
1700                    let result = match method {
1701                        "Target.getTargets" => json!({
1702                            "targetInfos": [{
1703                                "targetId": "T1",
1704                                "type": "page",
1705                                "url": "https://example.com/",
1706                                "title": "Example",
1707                            }]
1708                        }),
1709                        "Target.attachToTarget" => {
1710                            next_session += 1;
1711                            json!({"sessionId": format!("S{next_session}")})
1712                        }
1713                        "Target.detachFromTarget" => json!({}),
1714                        "Inspector.enable" => json!({}),
1715                        "Runtime.evaluate" => {
1716                            eval_count += 1;
1717                            if eval_count == 1 {
1718                                json!({"result": {"value": 1}})
1719                            } else {
1720                                json!({"result": {"value": selector_rect.clone()}})
1721                            }
1722                        }
1723                        "Page.captureScreenshot" => {
1724                            capture_params.lock().await.push(req["params"].clone());
1725                            json!({"data": "PNGDATA"})
1726                        }
1727                        _ => json!({}),
1728                    };
1729                    let resp = json!({"id": id, "result": result});
1730                    ws.send(Message::Text(resp.to_string())).await.unwrap();
1731                }
1732            }
1733        });
1734        ScreenshotMock {
1735            endpoint: format!("ws://{addr}"),
1736            capture_params,
1737        }
1738    }
1739
1740    #[test]
1741    fn register_all_includes_expected_set() {
1742        let registry = ToolRegistry::new();
1743        register_all(&registry);
1744        let list = registry.list();
1745        let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
1746        for expected in EXPECTED_TOOLS {
1747            assert!(
1748                names.contains(expected),
1749                "missing tool {expected} in {names:?}"
1750            );
1751        }
1752        assert_eq!(
1753            list.len(),
1754            EXPECTED_TOOLS.len(),
1755            "extra tools present: {names:?}"
1756        );
1757    }
1758
1759    #[test]
1760    fn every_tool_has_object_input_schema() {
1761        let registry = ToolRegistry::new();
1762        register_all(&registry);
1763        for t in registry.list() {
1764            let schema = &t["inputSchema"];
1765            assert!(schema.is_object(), "schema not object: {schema}");
1766            assert_eq!(
1767                schema["type"], "object",
1768                "schema type != object for {}: {schema}",
1769                t["name"]
1770            );
1771        }
1772    }
1773
1774    #[test]
1775    fn list_targets_schema_has_optional_filter() {
1776        let schema = schema_for("list_targets");
1777        assert_eq!(schema["properties"]["filter"]["type"], "string");
1778        assert!(
1779            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
1780        );
1781    }
1782
1783    #[test]
1784    fn browser_cookies_schema_has_optional_filters() {
1785        let schema = schema_for("browser_cookies");
1786        assert_eq!(schema["properties"]["domain"]["type"], "string");
1787        assert_eq!(schema["properties"]["name"]["type"], "string");
1788        assert!(
1789            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
1790        );
1791    }
1792
1793    #[test]
1794    fn browser_eval_requires_expression_and_supports_routing() {
1795        let schema = schema_for("browser_eval");
1796        let required = schema["required"].as_array().expect("required array");
1797        assert!(required.iter().any(|v| v == "expression"));
1798        assert_eq!(schema["properties"]["expression"]["type"], "string");
1799        assert_eq!(schema["properties"]["await_promise"]["type"], "boolean");
1800        assert_eq!(schema["properties"]["timeout_ms"]["type"], "number");
1801        assert_eq!(schema["properties"]["tab"]["type"], "string");
1802        assert_eq!(schema["properties"]["target"]["type"], "string");
1803    }
1804
1805    #[test]
1806    fn browser_storage_get_requires_key() {
1807        let schema = schema_for("browser_storage_get");
1808        let required = schema["required"].as_array().expect("required array");
1809        assert!(required.iter().any(|v| v == "key"));
1810        assert_eq!(schema["properties"]["key"]["type"], "string");
1811        assert_eq!(schema["properties"]["namespace"]["type"], "string");
1812    }
1813
1814    #[test]
1815    fn browser_storage_set_requires_key_and_value() {
1816        let schema = schema_for("browser_storage_set");
1817        let required: Vec<&str> = schema["required"]
1818            .as_array()
1819            .unwrap()
1820            .iter()
1821            .map(|v| v.as_str().unwrap())
1822            .collect();
1823        assert!(required.contains(&"key"));
1824        assert!(required.contains(&"value"));
1825        assert_eq!(schema["properties"]["value"]["type"], "string");
1826    }
1827
1828    #[test]
1829    fn browser_wait_for_cookie_requires_domain_and_name() {
1830        let schema = schema_for("browser_wait_for_cookie");
1831        let required: Vec<&str> = schema["required"]
1832            .as_array()
1833            .unwrap()
1834            .iter()
1835            .map(|v| v.as_str().unwrap())
1836            .collect();
1837        assert!(required.contains(&"domain"));
1838        assert!(required.contains(&"name"));
1839        assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
1840        assert_eq!(
1841            schema["properties"]["poll_interval_seconds"]["type"],
1842            "number"
1843        );
1844    }
1845
1846    #[test]
1847    fn browser_navigate_schema_has_tab_and_target() {
1848        // Per-tab tools expose optional `tab`/`target` for routing.
1849        let schema = schema_for("browser_navigate");
1850        assert_eq!(schema["properties"]["tab"]["type"], "string");
1851        assert_eq!(schema["properties"]["target"]["type"], "string");
1852        let required: Vec<&str> = schema["required"]
1853            .as_array()
1854            .unwrap()
1855            .iter()
1856            .map(|v| v.as_str().unwrap())
1857            .collect();
1858        assert!(required.contains(&"url"));
1859        assert!(!required.contains(&"tab"));
1860        assert!(!required.contains(&"target"));
1861    }
1862
1863    #[test]
1864    fn browser_tab_select_requires_target_id() {
1865        let schema = schema_for("browser_tab_select");
1866        let required: Vec<&str> = schema["required"]
1867            .as_array()
1868            .unwrap()
1869            .iter()
1870            .map(|v| v.as_str().unwrap())
1871            .collect();
1872        assert!(required.contains(&"target_id"));
1873    }
1874
1875    #[test]
1876    fn browser_tab_close_target_id_is_optional() {
1877        // Default = close active tab; no required args.
1878        let schema = schema_for("browser_tab_close");
1879        assert!(
1880            schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
1881        );
1882        assert_eq!(schema["properties"]["target_id"]["type"], "string");
1883    }
1884
1885    #[test]
1886    fn browser_select_requires_name() {
1887        let schema = schema_for("browser_select");
1888        let required: Vec<&str> = schema["required"]
1889            .as_array()
1890            .unwrap()
1891            .iter()
1892            .map(|v| v.as_str().unwrap())
1893            .collect();
1894        assert!(required.contains(&"name"));
1895    }
1896
1897    #[test]
1898    fn browser_select_description_documents_failed_lock_contract() {
1899        let desc = tool_description("browser_select");
1900        assert!(desc.contains("committed before"));
1901        assert!(desc.contains("new browser remains active"));
1902        assert!(desc.contains("switch back"));
1903    }
1904
1905    #[test]
1906    fn browser_list_has_no_args() {
1907        let schema = schema_for("browser_list");
1908        assert_eq!(schema["properties"], json!({}));
1909    }
1910
1911    #[test]
1912    fn browser_cookies_schema_has_no_tab_arg() {
1913        // Cookies are browser-wide; no per-tab routing.
1914        let schema = schema_for("browser_cookies");
1915        assert!(schema["properties"].get("tab").is_none());
1916        assert!(schema["properties"].get("target").is_none());
1917    }
1918
1919    /// Sidecar-routed tools expose `tab`/`target` for the same routing
1920    /// surface as the other per-tab tools.
1921    #[test]
1922    fn sidecar_tools_expose_tab_and_target() {
1923        for name in &[
1924            "browser_snapshot",
1925            "browser_click",
1926            "browser_type",
1927            "browser_hover",
1928            "browser_drag",
1929            "browser_press_key",
1930            "browser_wait_for",
1931            "browser_pdf_save",
1932        ] {
1933            let schema = schema_for(name);
1934            assert_eq!(
1935                schema["properties"]["tab"]["type"], "string",
1936                "{name} missing tab arg"
1937            );
1938            assert_eq!(
1939                schema["properties"]["target"]["type"], "string",
1940                "{name} missing target arg"
1941            );
1942        }
1943    }
1944
1945    /// `browser_click` / `browser_type` etc. require their selector
1946    /// args; `browser_snapshot` / `browser_pdf_save` / `browser_wait_for`
1947    /// don't (snapshot is page-wide, wait_for has multiple alternative
1948    /// conditions, pdf is page-wide).
1949    #[test]
1950    fn sidecar_tools_required_args() {
1951        let click = schema_for("browser_click");
1952        let req: Vec<&str> = click["required"]
1953            .as_array()
1954            .unwrap()
1955            .iter()
1956            .map(|v| v.as_str().unwrap())
1957            .collect();
1958        assert!(req.contains(&"selector"));
1959
1960        let t = schema_for("browser_type");
1961        let req: Vec<&str> = t["required"]
1962            .as_array()
1963            .unwrap()
1964            .iter()
1965            .map(|v| v.as_str().unwrap())
1966            .collect();
1967        assert!(req.contains(&"selector"));
1968        assert!(req.contains(&"text"));
1969
1970        // No required args on these.
1971        let snap = schema_for("browser_snapshot");
1972        assert!(snap.get("required").is_none() || snap["required"].as_array().unwrap().is_empty());
1973        let pdf = schema_for("browser_pdf_save");
1974        assert!(pdf.get("required").is_none() || pdf["required"].as_array().unwrap().is_empty());
1975    }
1976
1977    /// Sidecar tool against a BiDi browser must error with
1978    /// `EngineUnsupported` BEFORE attempting to spawn the sidecar — so
1979    /// even systems without Node/Bun get a clean message.
1980    #[tokio::test]
1981    async fn sidecar_tool_on_bidi_returns_engine_unsupported() {
1982        use crate::cli::env_resolver::{ResolvedBrowser, Source};
1983        use crate::detect::Engine;
1984        use crate::errors::SessionError;
1985
1986        // ServerState bound to a BiDi browser. Endpoint never gets hit
1987        // because the engine check short-circuits.
1988        let resolved = ResolvedBrowser {
1989            engine: Engine::Bidi,
1990            endpoint: "ws://127.0.0.1:0".into(),
1991            source: Source::External,
1992        };
1993        let state = ServerState::new(resolved);
1994
1995        let err = match state.ensure_sidecar("browser_snapshot").await {
1996            Ok(_) => panic!("BiDi must error"),
1997            Err(e) => e,
1998        };
1999        let typed = err.downcast_ref::<SessionError>().expect("typed error");
2000        match typed {
2001            SessionError::EngineUnsupported { tool, hint, .. } => {
2002                assert_eq!(tool, "browser_snapshot");
2003                assert!(!hint.contains(concat!("browser_", "evaluate")));
2004                assert!(hint.contains("browser_get_html"));
2005                assert!(hint.contains("browser_select"));
2006            }
2007            other => panic!("expected EngineUnsupported, got {other:?}"),
2008        }
2009    }
2010
2011    #[test]
2012    fn sidecar_cdp_attach_failure_classifier_matches_connect_layer_errors() {
2013        let err = anyhow::anyhow!(
2014            "browserType.connectOverCDP: Timeout 5000ms exceeded while <ws connecting> to ws://127.0.0.1:64767/devtools/browser/x"
2015        );
2016        assert!(looks_like_sidecar_cdp_attach_failure(&err));
2017
2018        let err = anyhow::anyhow!("page.waitForLoadState: Timeout 30000ms exceeded");
2019        assert!(
2020            !looks_like_sidecar_cdp_attach_failure(&err),
2021            "normal page wait timeouts must not be reclassified as sidecar attach failures"
2022        );
2023    }
2024
2025    #[test]
2026    fn sidecar_connection_failed_message_discourages_page_hang_inference() {
2027        let err = SessionError::SidecarConnectionFailed {
2028            tool: "browser_snapshot".into(),
2029            method: "snapshot".into(),
2030            target_id: "T1".into(),
2031            url: Some("http://localhost:5173/404".into()),
2032            details: "browserType.connectOverCDP: Timeout 5000ms exceeded".into(),
2033            hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
2034        };
2035        let msg = err.to_string();
2036        assert!(msg.contains("Playwright sidecar connection failed"));
2037        assert!(msg.contains("not evidence that the page is hung"));
2038        assert!(msg.contains("browser_get_html"));
2039    }
2040
2041    #[tokio::test]
2042    async fn screenshot_selector_sends_cdp_clip() {
2043        let mock = spawn_screenshot_mock(json!({
2044            "x": 12.5,
2045            "y": 34.0,
2046            "width": 56.0,
2047            "height": 78.0,
2048        }))
2049        .await;
2050        let state = ServerState::new(ResolvedBrowser {
2051            engine: Engine::Cdp,
2052            endpoint: mock.endpoint,
2053            source: Source::External,
2054        });
2055        let h = handler_for("browser_take_screenshot");
2056        let out = h(
2057            state,
2058            json!({
2059                "target": "example\\.com",
2060                "selector": "#main",
2061            }),
2062        )
2063        .await
2064        .unwrap();
2065        assert_eq!(out["content"][0]["type"], "image");
2066        assert_eq!(out["content"][0]["data"], "PNGDATA");
2067
2068        let captures = mock.capture_params.lock().await;
2069        assert_eq!(captures.len(), 1);
2070        assert_eq!(captures[0]["format"], "png");
2071        assert_eq!(captures[0]["captureBeyondViewport"], true);
2072        assert_eq!(captures[0]["clip"]["x"], json!(12.5));
2073        assert_eq!(captures[0]["clip"]["y"], json!(34.0));
2074        assert_eq!(captures[0]["clip"]["width"], json!(56.0));
2075        assert_eq!(captures[0]["clip"]["height"], json!(78.0));
2076        assert_eq!(captures[0]["clip"]["scale"], json!(1));
2077    }
2078
2079    #[tokio::test]
2080    async fn screenshot_selector_null_rect_errors_clearly() {
2081        let mock = spawn_screenshot_mock(Value::Null).await;
2082        let state = ServerState::new(ResolvedBrowser {
2083            engine: Engine::Cdp,
2084            endpoint: mock.endpoint,
2085            source: Source::External,
2086        });
2087        let h = handler_for("browser_take_screenshot");
2088        let err = h(
2089            state,
2090            json!({
2091                "target": "example\\.com",
2092                "selector": "#missing",
2093            }),
2094        )
2095        .await
2096        .expect_err("null selector rect must error");
2097        assert!(
2098            err.to_string()
2099                .contains("selector matched no visible element: #missing"),
2100            "got: {err:#}"
2101        );
2102        assert!(mock.capture_params.lock().await.is_empty());
2103    }
2104
2105    // -- Behavioral handler arg-validation -----------------------------------
2106    //
2107    // These invoke the real handler closures (not just the static schema)
2108    // against a `ServerState` whose endpoint is never reached, because the
2109    // arg-validation / mutual-exclusion checks fire *before* any backend
2110    // connection. No browser required.
2111
2112    use crate::cli::env_resolver::{ResolvedBrowser, Source};
2113    use crate::detect::Engine;
2114
2115    /// Fetch a registered tool's handler by name.
2116    fn handler_for(name: &str) -> ToolHandler {
2117        let registry = ToolRegistry::new();
2118        register_all(&registry);
2119        registry
2120            .handler(name)
2121            .unwrap_or_else(|| panic!("tool {name} not registered"))
2122    }
2123
2124    /// A `ServerState` bound to an endpoint that is never reached (the
2125    /// handler errors during validation first). Marked CDP so we don't
2126    /// trip the BiDi-lock path.
2127    fn unreached_state() -> ServerState {
2128        ServerState::new(ResolvedBrowser {
2129            engine: Engine::Cdp,
2130            // Port 0 never accepts; any attempt to open a backend would
2131            // fail, but these tests assert the *validation* error fires
2132            // first.
2133            endpoint: "ws://127.0.0.1:0".into(),
2134            source: Source::External,
2135        })
2136    }
2137
2138    #[tokio::test]
2139    async fn navigate_missing_url_errors_before_backend() {
2140        let h = handler_for("browser_navigate");
2141        let err = h(unreached_state(), json!({}))
2142            .await
2143            .expect_err("missing url must error");
2144        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
2145    }
2146
2147    #[tokio::test]
2148    async fn fetch_missing_url_errors_before_backend() {
2149        let h = handler_for("browser_fetch");
2150        let err = h(unreached_state(), json!({"method": "GET"}))
2151            .await
2152            .expect_err("missing url must error");
2153        assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
2154    }
2155
2156    #[tokio::test]
2157    async fn storage_set_missing_value_errors_before_backend() {
2158        let h = handler_for("browser_storage_set");
2159        let err = h(unreached_state(), json!({"key": "k"}))
2160            .await
2161            .expect_err("missing value must error");
2162        assert!(err.to_string().contains("missing 'value'"), "got: {err:#}");
2163    }
2164
2165    #[tokio::test]
2166    async fn storage_get_missing_key_errors_before_backend() {
2167        let h = handler_for("browser_storage_get");
2168        let err = h(unreached_state(), json!({}))
2169            .await
2170            .expect_err("missing key must error");
2171        assert!(err.to_string().contains("missing 'key'"), "got: {err:#}");
2172    }
2173
2174    /// `tab` and `target` are mutually exclusive; the reject fires in
2175    /// `resolve_target_for_args` before any backend connection.
2176    #[tokio::test]
2177    async fn navigate_tab_and_target_mutually_exclusive() {
2178        let h = handler_for("browser_navigate");
2179        let err = h(
2180            unreached_state(),
2181            json!({"url": "https://e.test/", "tab": "a", "target": "b"}),
2182        )
2183        .await
2184        .expect_err("tab+target must error");
2185        assert!(
2186            err.to_string().contains("mutually exclusive"),
2187            "got: {err:#}"
2188        );
2189    }
2190}