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