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