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