1use 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
28const MCP_OP_TIMEOUT: Duration = Duration::from_secs(10);
33
34const MCP_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
38
39const MCP_SELECT_ELEMENT_TIMEOUT: Duration = Duration::from_secs(300);
44
45const TAB_SELECT_PROBE: Duration = Duration::from_millis(500);
49
50pub fn register_all(registry: &ToolRegistry) {
52 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 registry.register(make_list_targets());
64 registry.register(make_tab_list());
66 registry.register(make_tab_new());
67 registry.register(make_tab_select());
68 registry.register(make_tab_close());
69 registry.register(make_browser_select());
71 registry.register(make_browser_list());
72 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
85fn 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
109fn 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
125fn 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
139fn 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
166fn 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
194fn 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
231fn 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 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
276fn 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 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 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
343fn 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 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
374fn 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 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
428fn 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 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
477fn 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 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
579fn 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 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
652fn 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
672async 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 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 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
827fn 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 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
913async 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 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
972struct SidecarParam {
991 name: &'static str,
992 schema: Value,
993 required: bool,
994}
995
996struct SidecarTool {
999 name: &'static str,
1000 description: &'static str,
1001 method: &'static str,
1003 params: Vec<SidecarParam>,
1004 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 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 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 ¶m_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
1230fn 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 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(®istry);
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(®istry);
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(®istry);
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(®istry);
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 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 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 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 #[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 #[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 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 #[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 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 use crate::cli::env_resolver::{ResolvedBrowser, Source};
1688 use crate::detect::Engine;
1689
1690 fn handler_for(name: &str) -> ToolHandler {
1692 let registry = ToolRegistry::new();
1693 register_all(®istry);
1694 registry
1695 .handler(name)
1696 .unwrap_or_else(|| panic!("tool {name} not registered"))
1697 }
1698
1699 fn unreached_state() -> ServerState {
1703 ServerState::new(ResolvedBrowser {
1704 engine: Engine::Cdp,
1705 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 #[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}