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