1use anyhow::{anyhow, Result};
15use regex::Regex;
16use serde_json::{json, Value};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use crate::a11y::{self, FindOptions, RefEntry, SnapshotOptions};
21use crate::cli::fetch::script_fetch_timeout_ms;
22use crate::cli::storage::{build_get_expr, build_set_expr, ns_global};
23use crate::cli::wait_for_cookie::cookie_matches;
24use crate::detect::Engine;
25use crate::dom::scripts::{
26 FETCH_JS, GET_CLIP_RECT_JS, GET_DOM_JS, GET_PAGE_TEXT_JS, SELECT_ELEMENT_JS,
27};
28use crate::errors::SessionError;
29use crate::mcp::server::{RegisteredTool, ServerState, ToolHandler, ToolRegistry};
30use crate::session::backend::{ImageFormat, ScreenshotOptions, TabBackend};
31use crate::session::freshness;
32use crate::session::targets::TargetInfo;
33
34const MCP_OP_TIMEOUT: Duration = Duration::from_secs(10);
39
40const MCP_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
44
45const MCP_SELECT_ELEMENT_TIMEOUT: Duration = Duration::from_secs(300);
50
51const TAB_SELECT_PROBE: Duration = Duration::from_millis(500);
55
56const SIDECAR_WAKE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
58
59const MCP_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(20);
63
64pub fn register_all(registry: &ToolRegistry) {
66 registry.register(make_navigate());
68 registry.register(make_eval());
69 registry.register(make_get_html());
70 registry.register(make_get_page_text());
71 registry.register(make_take_screenshot());
72 registry.register(make_fetch());
73 registry.register(make_curl());
74 registry.register(make_select_element());
75 registry.register(make_cookies());
76 registry.register(make_storage_get());
77 registry.register(make_storage_set());
78 registry.register(make_wait_for_cookie());
79 registry.register(make_console_messages());
81 registry.register(make_network_requests());
82 registry.register(make_network_body());
83 registry.register(make_list_targets());
85 registry.register(make_tab_list());
87 registry.register(make_tab_new());
88 registry.register(make_tab_select());
89 registry.register(make_tab_close());
90 registry.register(make_tab_foreground());
91 registry.register(make_browser_start());
93 registry.register(make_browser_select());
94 registry.register(make_browser_list());
95 registry.register(make_browser_show());
96 registry.register(make_snapshot());
98 registry.register(make_find());
99 registry.register(make_click());
103 registry.register(make_type());
104 registry.register(make_hover());
105 registry.register(make_drag());
106 registry.register(make_press_key());
107 registry.register(make_wait_for());
108 registry.register(make_pdf_save());
109}
110
111fn text_content(text: impl Into<String>) -> Value {
116 json!({ "content": [ { "type": "text", "text": text.into() } ] })
117}
118
119fn image_content(data: String, mime: &str) -> Value {
120 json!({
121 "content": [ { "type": "image", "data": data, "mimeType": mime } ]
122 })
123}
124
125fn handler<F>(f: F) -> ToolHandler
126where
127 F: Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
128 + Send
129 + Sync
130 + 'static,
131{
132 Arc::new(f)
133}
134
135fn tab_args_schema() -> Value {
139 json!({
140 "tab": {
141 "type": "string",
142 "description": "Optional named tab; mutually exclusive with `target`."
143 },
144 "target": {
145 "type": "string",
146 "description": "Optional URL regex selecting an existing tab; mutually exclusive with `tab`."
147 }
148 })
149}
150
151fn tab_args_properties(extra: Value) -> Value {
156 let mut obj = extra.as_object().cloned().unwrap_or_default();
157 if let Some(ta) = tab_args_schema().as_object() {
158 for (k, v) in ta {
159 obj.insert(k.clone(), v.clone());
160 }
161 }
162 Value::Object(obj)
163}
164
165fn extract_tab_target(args: &Value) -> (Option<String>, Option<String>) {
170 let tab = args.get("tab").and_then(|v| v.as_str()).map(String::from);
171 let target = args
172 .get("target")
173 .and_then(|v| v.as_str())
174 .map(String::from);
175 (tab, target)
176}
177
178fn max_age_arg(args: &Value) -> Result<Duration> {
179 match args.get("max_age") {
180 None | Some(Value::Null) => Ok(freshness::DEFAULT_MAX_AGE),
181 Some(Value::String(s)) => freshness::parse_max_age(s),
182 Some(Value::Number(n)) => n
183 .as_u64()
184 .map(Duration::from_secs)
185 .ok_or_else(|| anyhow!("`max_age` number must be non-negative seconds")),
186 Some(_) => Err(anyhow!(
187 "`max_age` must be a duration string, e.g. `10m` or `1h`"
188 )),
189 }
190}
191
192fn timeout_ms_arg(args: &Value, key: &str, default: Duration) -> Result<Duration> {
193 match args.get(key) {
194 None | Some(Value::Null) => Ok(default),
195 Some(Value::Number(n)) => n
196 .as_u64()
197 .map(Duration::from_millis)
198 .ok_or_else(|| anyhow!("`{key}` number must be non-negative milliseconds")),
199 Some(_) => Err(anyhow!(
200 "`{key}` must be a non-negative number of milliseconds"
201 )),
202 }
203}
204
205fn make_navigate() -> RegisteredTool {
210 RegisteredTool {
211 name: "browser_navigate".into(),
212 description: "Navigate the active page to a URL.".into(),
213 input_schema: json!({
214 "type": "object",
215 "properties": tab_args_properties(json!({ "url": { "type": "string" } })),
216 "required": ["url"],
217 }),
218 handler: handler(|state, args| {
219 Box::pin(async move {
220 let url = args
221 .get("url")
222 .and_then(|v| v.as_str())
223 .ok_or_else(|| anyhow!("missing 'url'"))?
224 .to_string();
225 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
226 state.capture.touch_and_wait(&backend, &target_id).await;
230 backend.navigate(&target_id, &url).await?;
231 Ok(text_content(format!("Navigated to {url}")))
232 })
233 }),
234 }
235}
236
237fn make_eval() -> RegisteredTool {
242 RegisteredTool {
243 name: "browser_eval".into(),
244 description: "Evaluate a JavaScript expression in the active page.".into(),
245 input_schema: json!({
246 "type": "object",
247 "properties": tab_args_properties(json!({
248 "expression": {
249 "type": "string",
250 "description": "JavaScript expression to evaluate."
251 },
252 "await_promise": {
253 "type": "boolean",
254 "default": true,
255 "description": "Treat the expression as a Promise and await it. Ignored on Firefox, which always awaits."
256 },
257 "timeout_ms": {
258 "type": "number",
259 "description": "Per-call timeout in milliseconds (default 10000)."
260 },
261 "max_age": {
262 "type": "string",
263 "description": "Reload the page first if its document is older than this duration (default 10m)."
264 }
265 })),
266 "required": ["expression"],
267 }),
268 handler: handler(|state, args| {
269 Box::pin(async move {
270 let expression = args
271 .get("expression")
272 .and_then(|v| v.as_str())
273 .ok_or_else(|| anyhow!("missing 'expression'"))?
274 .to_string();
275 let await_promise = args
276 .get("await_promise")
277 .and_then(Value::as_bool)
278 .unwrap_or(true);
279 let timeout = timeout_ms_arg(&args, "timeout_ms", MCP_OP_TIMEOUT)?;
280 let max_age = max_age_arg(&args)?;
281 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
282 backend.ensure_fresh(&target_id, max_age).await?;
283 let value = backend
284 .evaluate(&target_id, &expression, await_promise, timeout)
285 .await?;
286 Ok(text_content(serde_json::to_string_pretty(&value)?))
287 })
288 }),
289 }
290}
291
292fn make_get_html() -> RegisteredTool {
297 RegisteredTool {
298 name: "browser_get_html".into(),
299 description: "Get the rendered DOM as HTML, with shadow roots serialized when supported."
300 .into(),
301 input_schema: json!({
302 "type": "object",
303 "properties": tab_args_properties(json!({
304 "selector": {
305 "type": "string",
306 "description": "Optional CSS selector; defaults to the document element."
307 }
308 })),
309 }),
310 handler: handler(|state, args| {
311 Box::pin(async move {
312 let selector_arg = args.get("selector").and_then(|v| v.as_str());
313 let selector_literal = match selector_arg {
314 Some(s) => serde_json::to_string(s)?,
315 None => "null".to_string(),
316 };
317 let expr = format!("({GET_DOM_JS})({selector_literal})");
318 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
319 let value = backend
320 .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
321 .await?;
322 let html = value.as_str().unwrap_or("").to_string();
323 Ok(text_content(html))
324 })
325 }),
326 }
327}
328
329const PAGE_TEXT_DEFAULT_MAX: usize = 20_000;
334
335fn make_get_page_text() -> RegisteredTool {
336 RegisteredTool {
337 name: "browser_get_page_text".into(),
338 description: "Readable text of the page (article-first: main/article content, page \
339 chrome and hidden elements stripped, headings and list items kept) as \
340 plain text. The cheapest way to read a page; use browser_snapshot when you \
341 need structure and refs, browser_get_html for markup. Works on every \
342 engine including Firefox."
343 .into(),
344 input_schema: json!({
345 "type": "object",
346 "properties": tab_args_properties(json!({
347 "max_chars": {
348 "type": "integer",
349 "minimum": 500,
350 "description": "Truncate at a line boundary before this many characters. Default 20000."
351 },
352 "selector": {
353 "type": "string",
354 "description": "Optional CSS selector to extract from instead of the auto-detected main content."
355 }
356 })),
357 }),
358 handler: handler(|state, args| {
359 Box::pin(async move {
360 let max_chars =
361 count_arg(&args, "max_chars", PAGE_TEXT_DEFAULT_MAX, 500, usize::MAX)?;
362 let selector_literal = match string_arg(&args, "selector")? {
363 Some(s) => serde_json::to_string(&s)?,
364 None => "null".to_string(),
365 };
366 let expr = format!("({GET_PAGE_TEXT_JS})({max_chars}, {selector_literal})");
367 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
368 let value = backend
369 .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
370 .await?;
371 let raw = value
372 .as_str()
373 .ok_or_else(|| anyhow!("page text script returned no result"))?;
374 let parsed: Value = serde_json::from_str(raw)
375 .map_err(|e| anyhow!("page text script returned invalid JSON: {e}"))?;
376 if let Some(err) = parsed["error"].as_str() {
377 return Err(anyhow!("{err}"));
378 }
379 let mut out = String::new();
380 if let Some(t) = parsed["title"].as_str().filter(|t| !t.is_empty()) {
381 out.push_str(t);
382 out.push('\n');
383 }
384 if let Some(u) = parsed["url"].as_str() {
385 out.push_str(u);
386 out.push('\n');
387 }
388 out.push('\n');
389 out.push_str(parsed["text"].as_str().unwrap_or_default());
390 if parsed["truncated"].as_bool().unwrap_or(false) {
391 out.push_str(&format!(
392 "\n… [truncated at {} of {} chars; pass max_chars or selector to narrow]",
393 max_chars,
394 parsed["total_chars"].as_u64().unwrap_or(0)
395 ));
396 }
397 Ok(text_content(out))
398 })
399 }),
400 }
401}
402
403fn screenshot_opts(args: &Value) -> Result<(ScreenshotOptions, Option<std::path::PathBuf>)> {
410 let full_page = bool_arg(args, "full_page", false)?;
411 let format = match args.get("format") {
412 None | Some(Value::Null) => ImageFormat::Png,
413 Some(Value::String(s)) if s == "png" => ImageFormat::Png,
414 Some(Value::String(s)) if s == "jpeg" || s == "jpg" => ImageFormat::Jpeg,
415 Some(_) => return Err(anyhow!("`format` must be \"png\" or \"jpeg\"")),
416 };
417 let quality = match args.get("quality") {
418 None | Some(Value::Null) => None,
419 Some(Value::Number(n)) => {
420 let q = n
421 .as_u64()
422 .filter(|q| (1..=100).contains(q))
423 .ok_or_else(|| anyhow!("`quality` must be an integer from 1 to 100"))?;
424 if format != ImageFormat::Jpeg {
425 return Err(anyhow!("`quality` only applies to `format: \"jpeg\"`"));
426 }
427 Some(q as u8)
428 }
429 Some(_) => return Err(anyhow!("`quality` must be an integer from 1 to 100")),
430 };
431 let max_width = match args.get("max_width") {
432 None | Some(Value::Null) => None,
433 Some(Value::Number(n)) => Some(
434 n.as_u64()
435 .filter(|w| *w >= 64)
436 .ok_or_else(|| anyhow!("`max_width` must be an integer of at least 64"))?
437 as u32,
438 ),
439 Some(_) => return Err(anyhow!("`max_width` must be an integer of at least 64")),
440 };
441 let save_to = match string_arg(args, "save_to")? {
442 None => None,
443 Some(p) => {
444 let path = std::path::PathBuf::from(&p);
445 if !path.is_absolute() {
446 return Err(anyhow!("`save_to` must be an absolute path, got `{p}`"));
447 }
448 match path.parent() {
449 Some(dir) if dir.is_dir() => {}
450 _ => return Err(anyhow!("`save_to` parent directory does not exist: `{p}`")),
451 }
452 Some(path)
453 }
454 };
455 if args.get("selector").is_some_and(Value::is_string)
456 && args.get("ref").is_some_and(Value::is_string)
457 {
458 return Err(anyhow!("`selector` and `ref` are mutually exclusive"));
459 }
460 Ok((
461 ScreenshotOptions {
462 full_page,
463 clip: None,
464 format,
465 quality,
466 max_width,
467 },
468 save_to,
469 ))
470}
471
472fn make_take_screenshot() -> RegisteredTool {
473 RegisteredTool {
474 name: "browser_take_screenshot".into(),
475 description: "Capture a screenshot of the page, or of one element via `selector` or \
476 `ref`. Screenshots are expensive in context: prefer browser_snapshot or \
477 browser_get_page_text for reading, and when you do need pixels use \
478 `format: \"jpeg\"` with `max_width` (e.g. 1024), or `save_to` to write the \
479 file to disk and keep it out of the conversation. Default output is an \
480 unscaled PNG image."
481 .into(),
482 input_schema: json!({
483 "type": "object",
484 "properties": tab_args_properties(json!({
485 "full_page": { "type": "boolean", "default": false },
486 "selector": { "type": "string", "description": "CSS selector to clip to; mutually exclusive with `ref`." },
487 "ref": { "type": "string", "description": "Element ref from browser_snapshot/browser_find to clip to; mutually exclusive with `selector`." },
488 "format": { "type": "string", "enum": ["png", "jpeg"], "description": "Default png." },
489 "quality": { "type": "integer", "minimum": 1, "maximum": 100, "description": "JPEG quality (default 80). jpeg only." },
490 "max_width": { "type": "integer", "minimum": 64, "description": "Downscale so the image is at most this many pixels wide. Chromium only; ignored on Firefox." },
491 "save_to": { "type": "string", "description": "Absolute file path. When set, the image is written there (0600) and only the path and dimensions are returned." }
492 })),
493 }),
494 handler: handler(|state, args| {
495 Box::pin(async move {
496 let (mut opts, save_to) = screenshot_opts(&args)?;
497 let selector = args.get("selector").and_then(|v| v.as_str());
498 let r = args.get("ref").and_then(|v| v.as_str());
499 if r.is_some() {
500 state.ensure_native_ready("browser_take_screenshot").await?;
501 }
502 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
503 opts.clip = match (selector, r) {
505 (Some(sel), _) => {
506 let sel_literal = serde_json::to_string(sel)?;
507 let expr = format!("({GET_CLIP_RECT_JS})({sel_literal})");
508 let rect = backend
509 .evaluate(&target_id, &expr, false, MCP_OP_TIMEOUT)
510 .await?;
511 if rect.is_null() {
512 return Err(anyhow!("selector matched no visible element: {sel}"));
513 }
514 Some(rect)
515 }
516 (None, Some(r)) => {
517 let entry = resolve_ref(&state, &backend, &target_id, r).await?;
518 Some(
519 backend
520 .node_clip_rect(&target_id, entry.backend_node_id, MCP_OP_TIMEOUT)
521 .await
522 .map_err(|e| stale_on_node_gone(e, r, &target_id))?,
523 )
524 }
525 (None, None) => None,
526 };
527 let b64 = backend.screenshot(&target_id, &opts).await?;
528 match save_to {
529 None => Ok(image_content(b64, opts.format.mime())),
530 Some(path) => {
531 use base64::Engine as _;
532 let bytes = base64::engine::general_purpose::STANDARD
533 .decode(b64.as_bytes())
534 .map_err(|e| anyhow!("decoding screenshot data: {e}"))?;
535 crate::cli::output::write_private_file(&path, &bytes)?;
536 let dims = crate::cli::output::image_dimensions(&bytes)
537 .map(|(w, h)| format!("{w}x{h}, "))
538 .unwrap_or_default();
539 Ok(text_content(format!(
540 "Saved screenshot to {} ({dims}{}, {} KiB)",
541 path.display(),
542 opts.format.mime(),
543 bytes.len().div_ceil(1024)
544 )))
545 }
546 }
547 })
548 }),
549 }
550}
551
552fn make_fetch() -> RegisteredTool {
557 RegisteredTool {
558 name: "browser_fetch".into(),
559 description:
560 "Perform an HTTP request from the page context. Preserves cookies and remains subject to browser CORS/CSP rules. Prefer `browser_curl` for large responses or direct file downloads."
561 .into(),
562 input_schema: json!({
563 "type": "object",
564 "properties": tab_args_properties(json!({
565 "url": { "type": "string" },
566 "method": { "type": "string" },
567 "headers": { "type": "object" },
568 "body": { "type": "string" },
569 "timeout_ms": {
570 "type": "number",
571 "description": "Per-call timeout in milliseconds for the in-page fetch. Default 60s."
572 },
573 "max_age": {
574 "type": "string",
575 "description": "Reload the page first if its document is older than this duration (default 10m)."
576 }
577 })),
578 "required": ["url"],
579 }),
580 handler: handler(|state, args| {
581 Box::pin(async move {
582 if args.get("url").and_then(|v| v.as_str()).is_none() {
583 return Err(anyhow!("missing 'url'"));
584 }
585 let mut for_js = args.clone();
587 if let Some(obj) = for_js.as_object_mut() {
588 obj.remove("tab");
589 obj.remove("target");
590 obj.remove("max_age");
591 obj.remove("timeout_ms");
592 }
593 let timeout = timeout_ms_arg(&args, "timeout_ms", MCP_FETCH_TIMEOUT)?;
594 if let Some(obj) = for_js.as_object_mut() {
595 obj.insert(
596 "timeoutMs".to_string(),
597 json!(script_fetch_timeout_ms(timeout)),
598 );
599 }
600 let max_age = max_age_arg(&args)?;
601 let args_json = serde_json::to_string(&for_js)?;
602 let args_literal = serde_json::to_string(&args_json)?;
603 let expr = format!("({FETCH_JS})({args_literal})");
604 let (tab, target) = extract_tab_target(&args);
610 let has_route = tab.is_some() || target.is_some();
611 let (backend, target_id) = if has_route {
612 state.resolve_target_for_args(&args).await?
613 } else {
614 let url = args.get("url").and_then(|v| v.as_str()).unwrap();
615 state.resolve_or_create_for_origin(url).await?
616 };
617 backend.ensure_fresh(&target_id, max_age).await?;
618 let value = backend.evaluate(&target_id, &expr, true, timeout).await?;
619 let raw = value.as_str().unwrap_or("").to_string();
620 let mut parsed: Value = serde_json::from_str(&raw)
621 .map_err(|e| anyhow!("invalid fetch response JSON: {e}"))?;
622 if parsed.get("ok").and_then(Value::as_bool) == Some(false) {
623 let mut msg = parsed
624 .get("error")
625 .and_then(Value::as_str)
626 .unwrap_or("fetch failed")
627 .to_string();
628 if let Some(name) = parsed.get("errorName").and_then(Value::as_str) {
629 if !name.is_empty() {
630 msg.push_str(&format!(" ({name})"));
631 }
632 }
633 return Err(anyhow!(msg));
634 }
635 if let Some(obj) = parsed.as_object_mut() {
636 obj.remove("ok");
637 }
638 let pretty = serde_json::to_string_pretty(&parsed)?;
639 Ok(text_content(pretty))
640 })
641 }),
642 }
643}
644
645fn make_curl() -> RegisteredTool {
650 RegisteredTool {
651 name: "browser_curl".into(),
652 description: format!(
653 "Run the real curl out of page context with cookies and User-Agent copied from the active browser, plus Origin and Referer derived from the selected source tab. Arguments use ordinary curl syntax and are forwarded unchanged. Omit `-o` to return up to {} MiB through MCP; use `-o <path>`/`--output <path>` for unrestricted streaming downloads. Unlike browser_fetch, curl is not subject to browser CORS/CSP and does not reproduce the browser TLS fingerprint.",
654 crate::cli::curl::MCP_RESPONSE_LIMIT / (1024 * 1024)
655 ),
656 input_schema: json!({
657 "type": "object",
658 "properties": tab_args_properties(json!({
659 "args": {
660 "type": "array",
661 "items": { "type": "string" },
662 "minItems": 1,
663 "description": "Exact curl arguments, including options and URL(s), e.g. [\"-L\", \"--fail-with-body\", \"-o\", \"/tmp/file.zip\", \"https://example.com/file.zip\"]."
664 }
665 })),
666 "required": ["args"],
667 }),
668 handler: handler(|state, args| {
669 Box::pin(async move {
670 let curl_args = args
671 .get("args")
672 .and_then(Value::as_array)
673 .ok_or_else(|| anyhow!("missing or invalid 'args': expected an array of strings"))?
674 .iter()
675 .map(|arg| {
676 arg.as_str()
677 .map(String::from)
678 .ok_or_else(|| anyhow!("every curl argument must be a string"))
679 })
680 .collect::<Result<Vec<_>>>()?;
681 if curl_args.is_empty() {
682 return Err(anyhow!(
683 "'args' must contain curl options and at least one URL"
684 ));
685 }
686
687 let (tab, target) = extract_tab_target(&args);
692 let has_route = tab.is_some() || target.is_some();
693 let (backend, target_id) = if has_route {
694 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
695 (backend, Some(target_id))
696 } else {
697 let backend = state.ensure_backend().await?;
698 let target_id = state.active_target_id.lock().await.clone();
699 (backend, target_id)
700 };
701 let prepared = crate::cli::curl::prepare(&backend, target_id.as_deref()).await?;
702 let output = crate::cli::curl::execute_mcp(&prepared, &curl_args).await?;
703 Ok(crate::cli::curl::mcp_result(output))
704 })
705 }),
706 }
707}
708
709fn make_select_element() -> RegisteredTool {
714 RegisteredTool {
715 name: "browser_select_element".into(),
716 description:
717 "Show an interactive overlay; resolve with the CSS selector for the clicked element."
718 .into(),
719 input_schema: json!({
720 "type": "object",
721 "properties": tab_args_properties(json!({})),
722 }),
723 handler: handler(|state, args| {
724 Box::pin(async move {
725 let expr = SELECT_ELEMENT_JS.to_string();
726 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
727 let value = backend
731 .evaluate(&target_id, &expr, true, MCP_SELECT_ELEMENT_TIMEOUT)
732 .await?;
733 let selector = value.as_str().unwrap_or("").to_string();
734 Ok(text_content(selector))
735 })
736 }),
737 }
738}
739
740fn make_list_targets() -> RegisteredTool {
745 RegisteredTool {
746 name: "list_targets".into(),
747 description: "List open page targets, optionally filtered by an unanchored URL regex. \
748 CDP-shaped diagnostic; agents typically want `browser_tab_list`."
749 .into(),
750 input_schema: json!({
751 "type": "object",
752 "properties": {
753 "filter": {
754 "type": "string",
755 "description": "Optional unanchored URL regex."
756 }
757 },
758 }),
759 handler: handler(|state, args| {
760 Box::pin(async move {
761 let filter_re = args
762 .get("filter")
763 .and_then(|v| v.as_str())
764 .map(Regex::new)
765 .transpose()
766 .map_err(|e| anyhow!("invalid `filter` regex: {e}"))?;
767 let backend = state.ensure_backend().await?;
772 let kind = match state.browser_snapshot().await.engine {
773 Engine::Cdp => "page",
774 Engine::Bidi => "context",
775 };
776 let targets: Vec<TargetInfo> = backend
777 .live_targets()
778 .await?
779 .into_iter()
780 .filter(|t| filter_re.as_ref().map_or(true, |re| re.is_match(&t.url)))
781 .map(|t| TargetInfo {
782 id: t.id,
783 url: t.url,
784 title: t.title,
785 kind: kind.to_string(),
786 })
787 .collect();
788 Ok(text_content(serde_json::to_string_pretty(&targets)?))
789 })
790 }),
791 }
792}
793
794fn make_cookies() -> RegisteredTool {
799 RegisteredTool {
800 name: "browser_cookies".into(),
801 description: "Fetch cookies from the active browser. Returns full values (MCP is a \
802 trusted local channel). Optional unanchored regex filters."
803 .into(),
804 input_schema: json!({
805 "type": "object",
806 "properties": {
807 "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
808 "name": { "type": "string", "description": "Unanchored regex on cookie name." }
809 },
810 }),
811 handler: handler(|state, args| {
812 Box::pin(async move {
813 let domain_re = args
814 .get("domain")
815 .and_then(|v| v.as_str())
816 .map(Regex::new)
817 .transpose()
818 .map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
819 let name_re = args
820 .get("name")
821 .and_then(|v| v.as_str())
822 .map(Regex::new)
823 .transpose()
824 .map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
825 let backend = state.ensure_backend().await?;
829 let all = backend.cookies().await?;
830 let filtered: Vec<_> = all
831 .into_iter()
832 .filter(|c| {
833 domain_re.as_ref().map_or(true, |re| re.is_match(&c.domain))
834 && name_re.as_ref().map_or(true, |re| re.is_match(&c.name))
835 })
836 .collect();
837 Ok(text_content(serde_json::to_string_pretty(&filtered)?))
838 })
839 }),
840 }
841}
842
843fn make_storage_get() -> RegisteredTool {
848 RegisteredTool {
849 name: "browser_storage_get".into(),
850 description: "Read a value from localStorage or sessionStorage on the active page.".into(),
851 input_schema: json!({
852 "type": "object",
853 "properties": tab_args_properties(json!({
854 "key": { "type": "string" },
855 "namespace": {
856 "type": "string",
857 "enum": ["local", "session"],
858 "default": "local"
859 },
860 "max_age": {
861 "type": "string",
862 "description": "Reload the page first if its document is older than this duration (default 10m)."
863 }
864 })),
865 "required": ["key"],
866 }),
867 handler: handler(|state, args| {
868 Box::pin(async move {
869 let key = args
870 .get("key")
871 .and_then(|v| v.as_str())
872 .ok_or_else(|| anyhow!("missing 'key'"))?
873 .to_string();
874 let namespace = args
875 .get("namespace")
876 .and_then(|v| v.as_str())
877 .unwrap_or("local");
878 let ns = ns_global(namespace)?;
879 let expr = build_get_expr(ns, &key);
880 let max_age = max_age_arg(&args)?;
881 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
882 backend.ensure_fresh(&target_id, max_age).await?;
883 let value = backend
884 .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
885 .await?;
886 let text = match value {
890 Value::String(s) => s,
891 Value::Null => "null".to_string(),
892 other => other.to_string(),
893 };
894 Ok(text_content(text))
895 })
896 }),
897 }
898}
899
900fn make_storage_set() -> RegisteredTool {
901 RegisteredTool {
902 name: "browser_storage_set".into(),
903 description: "Write a value to localStorage or sessionStorage on the active page.".into(),
904 input_schema: json!({
905 "type": "object",
906 "properties": tab_args_properties(json!({
907 "key": { "type": "string" },
908 "value": { "type": "string" },
909 "namespace": {
910 "type": "string",
911 "enum": ["local", "session"],
912 "default": "local"
913 }
914 })),
915 "required": ["key", "value"],
916 }),
917 handler: handler(|state, args| {
918 Box::pin(async move {
919 let key = args
920 .get("key")
921 .and_then(|v| v.as_str())
922 .ok_or_else(|| anyhow!("missing 'key'"))?
923 .to_string();
924 let value = args
925 .get("value")
926 .and_then(|v| v.as_str())
927 .ok_or_else(|| anyhow!("missing 'value'"))?
928 .to_string();
929 let namespace = args
930 .get("namespace")
931 .and_then(|v| v.as_str())
932 .unwrap_or("local");
933 let ns = ns_global(namespace)?;
934 let expr = build_set_expr(ns, &key, &value);
935 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
936 let _ = backend
937 .evaluate(&target_id, &expr, true, MCP_OP_TIMEOUT)
938 .await?;
939 Ok(text_content("ok"))
940 })
941 }),
942 }
943}
944
945fn make_wait_for_cookie() -> RegisteredTool {
950 RegisteredTool {
951 name: "browser_wait_for_cookie".into(),
952 description: "Poll the browser until a cookie matching the regex filters appears, or \
953 timeout elapses."
954 .into(),
955 input_schema: json!({
956 "type": "object",
957 "properties": {
958 "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
959 "name": { "type": "string", "description": "Unanchored regex on cookie name." },
960 "timeout_seconds": { "type": "number", "default": 120 },
961 "poll_interval_seconds": { "type": "number", "default": 1 }
962 },
963 "required": ["domain", "name"],
964 }),
965 handler: handler(|state, args| {
966 Box::pin(async move {
967 let domain = args
968 .get("domain")
969 .and_then(|v| v.as_str())
970 .ok_or_else(|| anyhow!("missing 'domain'"))?;
971 let name = args
972 .get("name")
973 .and_then(|v| v.as_str())
974 .ok_or_else(|| anyhow!("missing 'name'"))?;
975 let domain_re =
976 Regex::new(domain).map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
977 let name_re = Regex::new(name).map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
978 let timeout_s = args
979 .get("timeout_seconds")
980 .and_then(|v| v.as_f64())
981 .unwrap_or(120.0)
982 .max(0.0);
983 let interval_s = args
984 .get("poll_interval_seconds")
985 .and_then(|v| v.as_f64())
986 .unwrap_or(1.0)
987 .max(0.001);
988 let deadline = Instant::now() + Duration::from_secs_f64(timeout_s);
989 let interval = Duration::from_secs_f64(interval_s);
990 let backend = state.ensure_backend().await?;
994 loop {
995 let cookies = backend.cookies().await?;
996 if let Some(c) = cookies
997 .into_iter()
998 .find(|c| cookie_matches(c, &domain_re, &name_re))
999 {
1000 return Ok(text_content(c.name));
1001 }
1002 let now = Instant::now();
1003 if now >= deadline {
1004 return Err(anyhow!("timed out waiting for cookie"));
1005 }
1006 let remaining = deadline.saturating_duration_since(now);
1007 let nap = std::cmp::min(interval, remaining);
1008 if nap.is_zero() {
1009 return Err(anyhow!("timed out waiting for cookie"));
1010 }
1011 tokio::time::sleep(nap).await;
1012 }
1013 })
1014 }),
1015 }
1016}
1017
1018fn make_tab_list() -> RegisteredTool {
1023 RegisteredTool {
1024 name: "browser_tab_list".into(),
1025 description: "List open tabs in the active browser, Playwright-shaped \
1026 (`[{target_id, url, title, active, foreground}]`). Titles are empty on \
1027 Firefox; `foreground` reports foreground emulation (browser_tab_foreground)."
1028 .into(),
1029 input_schema: json!({"type": "object", "properties": {}}),
1030 handler: handler(|state, _args| {
1031 Box::pin(async move {
1032 let v = tab_list_value(&state).await?;
1033 Ok(text_content(serde_json::to_string_pretty(&v)?))
1034 })
1035 }),
1036 }
1037}
1038
1039async fn tab_list_value(state: &ServerState) -> Result<Value> {
1043 let backend = state.ensure_backend().await?;
1044 let targets = backend.live_targets().await?;
1045 let active = state.active_target_id.lock().await.clone();
1046 let foreground: Vec<String> = match state.registered_browser_name().await {
1047 Ok(name) => {
1048 crate::mcp::server::sync_registry_op(move |reg| {
1049 crate::session::foreground::active_targets(reg, &name)
1050 })
1051 .await?
1052 }
1053 Err(_) => Vec::new(),
1054 };
1055 let arr: Vec<Value> = targets
1056 .into_iter()
1057 .map(|t| {
1058 json!({
1059 "target_id": t.id,
1060 "url": t.url,
1061 "title": t.title,
1062 "active": active.as_deref() == Some(t.id.as_str()),
1063 "foreground": foreground.contains(&t.id),
1064 })
1065 })
1066 .collect();
1067 Ok(Value::Array(arr))
1068}
1069
1070fn make_tab_new() -> RegisteredTool {
1071 RegisteredTool {
1072 name: "browser_tab_new".into(),
1073 description: "Create a new tab and make it the active tab. Defaults to about:blank. \
1074 Pass `name` to create or select a durable named tab addressable as \
1075 `<browser>/<name>`."
1076 .into(),
1077 input_schema: json!({
1078 "type": "object",
1079 "properties": {
1080 "name": { "type": "string", "description": "Optional named-tab id (a-z, 0-9, '-', '_')." },
1081 "url": { "type": "string", "description": "Optional URL; defaults to about:blank." }
1082 },
1083 }),
1084 handler: handler(|state, args| {
1085 Box::pin(async move {
1086 if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
1087 let url = args.get("url").and_then(|v| v.as_str());
1088 let opened = open_or_create_named_tab(&state, name, url).await?;
1089 return Ok(text_content(serde_json::to_string_pretty(&opened)?));
1090 }
1091 let url = args
1092 .get("url")
1093 .and_then(|v| v.as_str())
1094 .unwrap_or("about:blank")
1095 .to_string();
1096 let backend = state.ensure_backend().await?;
1097 let tid = backend.create_tab(&url).await?;
1098 *state.active_target_id.lock().await = Some(tid.clone());
1099 state.capture.touch(&backend, &tid);
1100 Ok(text_content(serde_json::to_string_pretty(&json!({
1101 "target_id": tid,
1102 "url": url,
1103 "active": true,
1104 }))?))
1105 })
1106 }),
1107 }
1108}
1109
1110async fn open_or_create_named_tab(
1111 state: &ServerState,
1112 name: &str,
1113 url: Option<&str>,
1114) -> Result<Value> {
1115 crate::cli::env_resolver::validate_tab_name(name)?;
1116 let want_url = url.unwrap_or("about:blank").to_string();
1117 let backend = state.ensure_backend().await?;
1118 let browser_name = state.registered_browser_name().await?;
1119
1120 let existing = {
1121 let bn = browser_name.clone();
1122 let n = name.to_string();
1123 crate::mcp::server::sync_registry_op(move |reg| reg.tab_get(&bn, &n)).await?
1124 };
1125 if let Some(row) = existing {
1126 let live = backend.live_target_ids().await?;
1127 if live.contains(&row.target_id) {
1128 if url.is_some() && row.last_url != want_url {
1129 backend.navigate(&row.target_id, &want_url).await?;
1130 let bn = browser_name.clone();
1131 let n = name.to_string();
1132 let u = want_url.clone();
1133 crate::mcp::server::sync_registry_op(move |reg| reg.tab_set_url(&bn, &n, &u))
1134 .await?;
1135 } else {
1136 let bn = browser_name.clone();
1137 let n = name.to_string();
1138 crate::mcp::server::sync_registry_op(move |reg| reg.tab_touch(&bn, &n)).await?;
1139 }
1140 *state.active_target_id.lock().await = Some(row.target_id.clone());
1141 state.capture.touch(&backend, &row.target_id);
1142 return Ok(json!({
1143 "name": name,
1144 "target_id": row.target_id,
1145 "url": if url.is_some() { want_url } else { row.last_url },
1146 "active": true,
1147 "created": false,
1148 }));
1149 }
1150
1151 let _ = backend.close_tab(&row.target_id).await;
1152 state.capture.forget(&backend, &row.target_id);
1153 let bn = browser_name.clone();
1154 let n = name.to_string();
1155 crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
1156 }
1157
1158 let victim = {
1159 let bn = browser_name.clone();
1160 crate::mcp::server::sync_registry_op(
1161 move |reg| -> Result<Option<crate::registry::TabRow>> {
1162 if reg.tabs_count_daemon_created(&bn)? >= crate::session::tabs::HARD_CAP {
1163 reg.tabs_lru_daemon_created(&bn)
1164 } else {
1165 Ok(None)
1166 }
1167 },
1168 )
1169 .await?
1170 };
1171 if let Some(victim) = victim {
1172 let _ = backend.close_tab(&victim.target_id).await;
1173 state.capture.forget(&backend, &victim.target_id);
1174 let bn = victim.browser_name;
1175 let n = victim.name;
1176 crate::mcp::server::sync_registry_op(move |reg| reg.tab_delete(&bn, &n)).await?;
1177 }
1178
1179 let target_id = backend.create_tab(&want_url).await?;
1180 let bn = browser_name;
1181 let n = name.to_string();
1182 let tid = target_id.clone();
1183 let u = want_url.clone();
1184 crate::mcp::server::sync_registry_op(move |reg| reg.tab_upsert(&bn, &n, &tid, &u, true))
1185 .await?;
1186 *state.active_target_id.lock().await = Some(target_id.clone());
1187 state.capture.touch(&backend, &target_id);
1188 Ok(json!({
1189 "name": name,
1190 "target_id": target_id,
1191 "url": want_url,
1192 "active": true,
1193 "created": true,
1194 }))
1195}
1196
1197fn make_tab_select() -> RegisteredTool {
1198 RegisteredTool {
1199 name: "browser_tab_select".into(),
1200 description: "Set the active tab. Probe-and-iterate: errors `TabHung` if the selected \
1201 tab doesn't respond to a 500ms probe (agent should pick another or call \
1202 `browser_tab_new`)."
1203 .into(),
1204 input_schema: json!({
1205 "type": "object",
1206 "properties": {
1207 "target_id": { "type": "string" }
1208 },
1209 "required": ["target_id"],
1210 }),
1211 handler: handler(|state, args| {
1212 Box::pin(async move {
1213 use crate::errors::SessionError;
1214 let tid = args
1215 .get("target_id")
1216 .and_then(|v| v.as_str())
1217 .ok_or_else(|| anyhow!("missing 'target_id'"))?
1218 .to_string();
1219 let backend = state.ensure_backend().await?;
1220 let live = backend.live_target_ids().await?;
1221 if !live.contains(&tid) {
1222 return Err(SessionError::TabNotFound {
1223 browser: state
1224 .registered_browser_name()
1225 .await
1226 .unwrap_or_else(|_| "<external>".to_string()),
1227 name: tid,
1228 }
1229 .into());
1230 }
1231 let probed = tokio::time::timeout(
1236 TAB_SELECT_PROBE,
1237 backend.evaluate(&tid, "1", false, TAB_SELECT_PROBE),
1238 )
1239 .await;
1240 let ok = matches!(probed, Ok(Ok(_)));
1241 if !ok {
1242 return Err(SessionError::TabHung {
1243 target_id: Some(tid),
1244 url: None,
1245 timeout_ms: TAB_SELECT_PROBE.as_millis() as u64,
1246 hint: "selected-tab-hung",
1247 }
1248 .into());
1249 }
1250 *state.active_target_id.lock().await = Some(tid.clone());
1251 state.capture.touch(&backend, &tid);
1252 Ok(text_content(serde_json::to_string_pretty(&json!({
1253 "target_id": tid,
1254 "active": true,
1255 }))?))
1256 })
1257 }),
1258 }
1259}
1260
1261fn make_tab_foreground() -> RegisteredTool {
1262 RegisteredTool {
1263 name: "browser_tab_foreground".into(),
1264 description: "Make a tab behave as the focused, visible foreground tab on an unlocked \
1265 display even while the browser window is minimized or the machine's \
1266 display is locked: `document.visibilityState` reports `visible`, \
1267 `document.hasFocus()` is true, `requestAnimationFrame` and timers run at \
1268 full rate, and screenshots show live content. Use it for games, canvas \
1269 apps, and anything that pauses in the background. A small holder process \
1270 keeps it on until `enabled: false`, the `timeout` (default 1h) elapses, \
1271 the tab closes, or the browser exits; the same state is visible to the CLI \
1272 (`browser-control tab foreground`). `enabled: false, all: true` stops every \
1273 holder on the browser. Chromium only; requires a registered browser."
1274 .into(),
1275 input_schema: json!({
1276 "type": "object",
1277 "properties": tab_args_properties(json!({
1278 "enabled": { "type": "boolean", "description": "Default true; false turns emulation off." },
1279 "all": { "type": "boolean", "description": "With `enabled: false`: stop foreground emulation on every tab of the browser." },
1280 "timeout": { "type": "string", "description": "How long to hold it, e.g. \"30m\", \"2h\". Default 1h." }
1281 })),
1282 }),
1283 handler: handler(|state, args| {
1284 Box::pin(async move {
1285 use crate::session::foreground;
1286 let enabled = bool_arg(&args, "enabled", true)?;
1287 let all = bool_arg(&args, "all", false)?;
1288 let timeout = match string_arg(&args, "timeout")? {
1289 Some(t) => freshness::parse_max_age(&t)?,
1290 None => foreground::DEFAULT_TIMEOUT,
1291 };
1292 if all && enabled {
1293 return Err(anyhow!("`all` only applies with `enabled: false`"));
1294 }
1295 state
1296 .ensure_cdp_engine("browser_tab_foreground", FOREGROUND_HINT)
1297 .await?;
1298 let browser_name = state.registered_browser_name().await.map_err(|e| {
1299 anyhow!("{e}; foreground emulation records its holder per registered browser")
1300 })?;
1301 if all {
1302 let bn = browser_name.clone();
1303 let n = crate::mcp::server::sync_registry_op(move |reg| {
1304 foreground::stop_all(reg, &bn)
1305 })
1306 .await?;
1307 return Ok(text_content(format!(
1308 "foreground emulation off for {n} tab(s) on {browser_name}"
1309 )));
1310 }
1311 let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1312 let bn = browser_name.clone();
1313 let tid = target_id.clone();
1314 if enabled {
1315 let (pid, created, expires_in) =
1316 crate::mcp::server::sync_registry_op(move |reg| {
1317 let (pid, created) = foreground::spawn_holder(reg, &bn, &tid, timeout)?;
1318 let expires_in = foreground::status(reg, &bn, &tid)?
1321 .map(|r| {
1322 (r.expires_at_epoch_s - crate::registry::now_epoch_s()).max(0)
1323 as u64
1324 })
1325 .map(std::time::Duration::from_secs)
1326 .unwrap_or(timeout);
1327 Ok((pid, created, expires_in))
1328 })
1329 .await?;
1330 Ok(text_content(format!(
1331 "foreground emulation {} for tab {target_id} (holder pid {pid}, expires in {}): the page reports visible and focused, and requestAnimationFrame/timers run at full rate while the window is minimized or the display is locked.",
1332 if created { "on" } else { "already on" },
1333 freshness::format_duration(expires_in)
1334 )))
1335 } else {
1336 let was_on = crate::mcp::server::sync_registry_op(move |reg| {
1337 foreground::stop_holder(reg, &bn, &tid)
1338 })
1339 .await?;
1340 Ok(text_content(format!(
1341 "foreground emulation {} for tab {target_id}",
1342 if was_on { "off" } else { "already off" }
1343 )))
1344 }
1345 })
1346 }),
1347 }
1348}
1349
1350const FOREGROUND_HINT: &str = "foreground emulation uses CDP Emulation.setFocusEmulationEnabled; Firefox has no WebDriver BiDi equivalent, so switch to a Chromium browser via browser_select";
1351
1352fn make_tab_close() -> RegisteredTool {
1353 RegisteredTool {
1354 name: "browser_tab_close".into(),
1355 description: "Close a tab. Defaults to the active tab; clears the active pointer if the \
1356 closed tab was active."
1357 .into(),
1358 input_schema: json!({
1359 "type": "object",
1360 "properties": {
1361 "target_id": { "type": "string", "description": "Optional; defaults to active tab." }
1362 },
1363 }),
1364 handler: handler(|state, args| {
1365 Box::pin(async move {
1366 let backend = state.ensure_backend().await?;
1367 let explicit = args
1368 .get("target_id")
1369 .and_then(|v| v.as_str())
1370 .map(|s| s.to_string());
1371 let active = state.active_target_id.lock().await.clone();
1372 let tid = match (explicit, &active) {
1373 (Some(e), _) => e,
1374 (None, Some(a)) => a.clone(),
1375 (None, None) => {
1376 return Err(anyhow!("no `target_id` given and no active tab to close"));
1377 }
1378 };
1379 let closed = backend.close_tab(&tid).await;
1380 state.capture.forget(&backend, &tid);
1384 state.refs.lock().await.remove(&tid);
1385 closed?;
1386 let mut ptr = state.active_target_id.lock().await;
1388 if ptr.as_deref() == Some(tid.as_str()) {
1389 *ptr = None;
1390 }
1391 Ok(text_content(serde_json::to_string_pretty(&json!({
1392 "closed": tid,
1393 }))?))
1394 })
1395 }),
1396 }
1397}
1398
1399fn make_browser_start() -> RegisteredTool {
1404 RegisteredTool {
1405 name: "browser_start".into(),
1406 description: "Start or reuse a browser, then make it the active MCP browser. \
1407 Use this to recover after the active browser exits."
1408 .into(),
1409 input_schema: json!({
1410 "type": "object",
1411 "properties": {
1412 "browser": { "type": "string", "description": "Optional browser kind (chrome, edge, chromium, brave, firefox). Defaults to an already-running installed browser if any, otherwise the first installed Chromium-family browser." },
1413 "headless": { "type": "boolean", "default": false },
1414 "wait_timeout_seconds": { "type": "integer", "default": 30 }
1415 },
1416 }),
1417 handler: handler(|state, args| {
1418 Box::pin(async move {
1419 let browser = args
1420 .get("browser")
1421 .and_then(|v| v.as_str())
1422 .map(|s| s.to_string());
1423 let headless = args
1424 .get("headless")
1425 .and_then(|v| v.as_bool())
1426 .unwrap_or(false);
1427 let wait_timeout = args
1428 .get("wait_timeout_seconds")
1429 .and_then(|v| v.as_u64())
1430 .unwrap_or(30);
1431 let started =
1432 crate::cli::start::ensure_started(browser, headless, false, wait_timeout)
1433 .await?;
1434 let resolved = crate::cli::env_resolver::ResolvedBrowser {
1435 endpoint: started.endpoint.clone(),
1436 engine: started.engine,
1437 source: crate::cli::env_resolver::Source::Registered {
1438 name: started.name.clone(),
1439 },
1440 };
1441 state.switch_browser(resolved).await?;
1442 let tabs = tab_list_value(&state).await?;
1443 Ok(text_content(serde_json::to_string_pretty(&json!({
1444 "name": started.name,
1445 "kind": started.kind.as_str(),
1446 "engine": match started.engine {
1447 crate::detect::Engine::Cdp => "cdp",
1448 crate::detect::Engine::Bidi => "bidi",
1449 },
1450 "endpoint": started.endpoint,
1451 "reused": started.reused,
1452 "selected": true,
1453 "tabs": tabs,
1454 }))?))
1455 })
1456 }),
1457 }
1458}
1459
1460fn make_browser_select() -> RegisteredTool {
1461 RegisteredTool {
1462 name: "browser_select".into(),
1463 description: "Switch the active browser by registered name, kind, URL, or CLI target \
1464 syntax such as `chrome` or `brave/cart`. A kind selector starts or reuses \
1465 that browser when none is live. The switch is committed before \
1466 Firefox BiDi lock preparation; if preparation fails, the new browser remains \
1467 active and the caller decides whether to retry, switch elsewhere, or switch back."
1468 .into(),
1469 input_schema: json!({
1470 "type": "object",
1471 "properties": {
1472 "name": { "type": "string", "description": "Browser selector, optionally `<browser>/<tab>`." }
1473 },
1474 "required": ["name"],
1475 }),
1476 handler: handler(|state, args| {
1477 Box::pin(async move {
1478 let name = args
1479 .get("name")
1480 .and_then(|v| v.as_str())
1481 .ok_or_else(|| anyhow!("missing 'name'"))?
1482 .to_string();
1483 let target = crate::cli::env_resolver::parse_target(&name)?;
1484 let resolved =
1485 crate::mcp::server::resolve_browser_send(target.browser.clone()).await?;
1486 let resolved_clone = resolved.clone();
1487 state.switch_browser(resolved).await?;
1488 let selected_tab = if let Some(tab) = target.tab.as_deref() {
1489 Some(open_or_create_named_tab(&state, tab, None).await?)
1490 } else {
1491 None
1492 };
1493 let tabs = tab_list_value(&state).await?;
1494 Ok(text_content(serde_json::to_string_pretty(&json!({
1495 "name": match &resolved_clone.source {
1496 crate::cli::env_resolver::Source::Registered { name } => name.as_str(),
1497 crate::cli::env_resolver::Source::External => "<external>",
1498 },
1499 "engine": match resolved_clone.engine {
1500 crate::detect::Engine::Cdp => "cdp",
1501 crate::detect::Engine::Bidi => "bidi",
1502 },
1503 "endpoint": resolved_clone.endpoint,
1504 "selected_tab": selected_tab,
1505 "tabs": tabs,
1506 }))?))
1507 })
1508 }),
1509 }
1510}
1511
1512fn make_browser_list() -> RegisteredTool {
1513 RegisteredTool {
1514 name: "browser_list".into(),
1515 description: "List live registered browsers with `[{name, kind, engine, endpoint, alive}]`; dead-process rows are pruned."
1516 .into(),
1517 input_schema: json!({"type": "object", "properties": {}}),
1518 handler: handler(|_state, _args| {
1519 Box::pin(async move {
1520 let arr = tokio::task::spawn_blocking(|| -> Result<Vec<Value>> {
1522 let registry = crate::registry::Registry::open()?;
1523 let rows = registry.list_alive()?;
1524 Ok(rows
1525 .into_iter()
1526 .map(|r| {
1527 json!({
1528 "name": r.name,
1529 "kind": r.kind.as_str(),
1530 "engine": match r.engine {
1531 crate::detect::Engine::Cdp => "cdp",
1532 crate::detect::Engine::Bidi => "bidi",
1533 },
1534 "endpoint": r.endpoint,
1535 "alive": true,
1536 })
1537 })
1538 .collect())
1539 })
1540 .await??;
1541 Ok(text_content(serde_json::to_string_pretty(&Value::Array(
1542 arr,
1543 ))?))
1544 })
1545 }),
1546 }
1547}
1548
1549fn make_browser_show() -> RegisteredTool {
1550 RegisteredTool {
1551 name: "browser_show".into(),
1552 description: "Explicitly reveal the active browser window for login or debugging. \
1553 Normal automation keeps new tabs in the background."
1554 .into(),
1555 input_schema: json!({"type": "object", "properties": {}}),
1556 handler: handler(|state, _args| {
1557 Box::pin(async move {
1558 let backend = state.ensure_backend().await?;
1559 let target_id = backend.target_for_show().await?;
1560 let resolved = state.browser_snapshot().await;
1561 let source = resolved.source.clone();
1562 let os_activated = match source {
1567 crate::cli::env_resolver::Source::External => false,
1568 source @ crate::cli::env_resolver::Source::Registered { .. } => {
1569 tokio::task::spawn_blocking(move || -> Result<bool> {
1570 let registry = crate::registry::Registry::open()?;
1571 crate::cli::show::activate_resolved_app(®istry, &source)
1572 })
1573 .await??
1574 }
1575 };
1576 backend.show_tab(&target_id).await?;
1577 Ok(text_content(serde_json::to_string_pretty(&json!({
1578 "target_id": target_id,
1579 "os_activated": os_activated,
1580 }))?))
1581 })
1582 }),
1583 }
1584}
1585
1586async fn forward_to_sidecar(
1603 state: &ServerState,
1604 tool_name: &str,
1605 args: &Value,
1606 sidecar_method: &str,
1607 mut params: serde_json::Map<String, Value>,
1608) -> Result<Value> {
1609 state.ensure_sidecar_supported(tool_name).await?;
1613 let (backend, target_id) = state.resolve_target_for_args(args).await?;
1614 params.insert("target_id".into(), Value::String(target_id));
1615 let sc = match state.ensure_sidecar(tool_name).await {
1616 Ok(sc) => sc,
1617 Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1618 return sidecar_cdp_failure_after_probe(
1619 state,
1620 &backend,
1621 tool_name,
1622 sidecar_method,
1623 params
1624 .get("target_id")
1625 .and_then(|v| v.as_str())
1626 .unwrap_or_default(),
1627 e,
1628 )
1629 .await;
1630 }
1631 Err(e) => return Err(e),
1632 };
1633 match sc.call(sidecar_method, Value::Object(params.clone())).await {
1634 Ok(v) => Ok(v),
1635 Err(e) if looks_like_sidecar_cdp_attach_failure(&e) => {
1636 sidecar_cdp_failure_after_probe(
1637 state,
1638 &backend,
1639 tool_name,
1640 sidecar_method,
1641 params
1642 .get("target_id")
1643 .and_then(|v| v.as_str())
1644 .unwrap_or_default(),
1645 e,
1646 )
1647 .await
1648 }
1649 Err(e) => Err(e),
1650 }
1651}
1652
1653async fn sidecar_cdp_failure_after_probe(
1654 state: &ServerState,
1655 backend: &TabBackend,
1656 tool_name: &str,
1657 sidecar_method: &str,
1658 target_id: &str,
1659 err: anyhow::Error,
1660) -> Result<Value> {
1661 state.reset_sidecar().await;
1662 let url = wake_and_probe_target(backend, target_id).await?;
1663 Err(SessionError::SidecarConnectionFailed {
1664 tool: tool_name.to_string(),
1665 method: sidecar_method.to_string(),
1666 target_id: target_id.to_string(),
1667 url,
1668 details: format!("{err:#}"),
1669 hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
1670 }
1671 .into())
1672}
1673
1674async fn wake_and_probe_target(backend: &TabBackend, target_id: &str) -> Result<Option<String>> {
1675 match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.show_tab(target_id)).await {
1676 Ok(r) => r?,
1677 Err(_) => {
1678 return Err(SessionError::TabHung {
1679 target_id: Some(target_id.to_string()),
1680 url: None,
1681 timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1682 hint: "sidecar-wake-timeout",
1683 }
1684 .into());
1685 }
1686 }
1687
1688 match tokio::time::timeout(
1689 SIDECAR_WAKE_PROBE_TIMEOUT,
1690 backend.evaluate(target_id, "1", false, SIDECAR_WAKE_PROBE_TIMEOUT),
1691 )
1692 .await
1693 {
1694 Ok(r) => {
1695 let _ = r?;
1696 }
1697 Err(_) => {
1698 return Err(SessionError::TabHung {
1699 target_id: Some(target_id.to_string()),
1700 url: None,
1701 timeout_ms: SIDECAR_WAKE_PROBE_TIMEOUT.as_millis() as u64,
1702 hint: "sidecar-probe-timeout",
1703 }
1704 .into());
1705 }
1706 }
1707
1708 match tokio::time::timeout(SIDECAR_WAKE_PROBE_TIMEOUT, backend.live_targets()).await {
1709 Ok(Ok(targets)) => Ok(targets
1710 .into_iter()
1711 .find(|t| t.id == target_id)
1712 .map(|t| t.url)),
1713 _ => Ok(None),
1714 }
1715}
1716
1717fn looks_like_sidecar_cdp_attach_failure(err: &anyhow::Error) -> bool {
1718 let msg = format!("{err:#}").to_ascii_lowercase();
1719 msg.contains("<ws connecting>")
1720 || msg.contains("connectovercdp")
1721 || msg.contains("websocket")
1722 || msg.contains("browser has been closed")
1723 || msg.contains("browser closed")
1724 || msg.contains("browser disconnected")
1725 || msg.contains("target closed")
1726 || msg.contains("cdp session closed")
1727 || msg.contains("econnrefused")
1728 || msg.contains("econnreset")
1729 || msg.contains("socket hang up")
1730 || msg.contains("sidecar stdout closed")
1731 || msg.contains("sidecar writer closed")
1732 || msg.contains("sidecar response channel dropped")
1733}
1734
1735fn regex_arg(args: &Value, key: &str) -> Result<Option<Regex>> {
1740 match args.get(key) {
1741 None | Some(Value::Null) => Ok(None),
1742 Some(Value::String(s)) if s.is_empty() => Ok(None),
1743 Some(Value::String(s)) => Regex::new(s)
1744 .map(Some)
1745 .map_err(|e| anyhow!("invalid `{key}` regex: {e}")),
1746 Some(_) => Err(anyhow!("`{key}` must be a string")),
1747 }
1748}
1749
1750fn bool_arg(args: &Value, key: &str, default: bool) -> Result<bool> {
1751 match args.get(key) {
1752 None | Some(Value::Null) => Ok(default),
1753 Some(Value::Bool(b)) => Ok(*b),
1754 Some(_) => Err(anyhow!("`{key}` must be a boolean")),
1755 }
1756}
1757
1758fn count_arg(args: &Value, key: &str, default: usize, min: usize, max: usize) -> Result<usize> {
1759 match args.get(key) {
1760 None | Some(Value::Null) => Ok(default),
1761 Some(Value::Number(n)) => {
1762 let n = n
1763 .as_u64()
1764 .ok_or_else(|| anyhow!("`{key}` must be a non-negative integer"))?
1765 as usize;
1766 if n < min {
1767 return Err(anyhow!("`{key}` must be at least {min}"));
1768 }
1769 Ok(n.min(max))
1770 }
1771 Some(_) => Err(anyhow!("`{key}` must be a non-negative integer")),
1772 }
1773}
1774
1775fn string_arg(args: &Value, key: &str) -> Result<Option<String>> {
1776 match args.get(key) {
1777 None | Some(Value::Null) => Ok(None),
1778 Some(Value::String(s)) if s.trim().is_empty() => Ok(None),
1779 Some(Value::String(s)) => Ok(Some(s.clone())),
1780 Some(_) => Err(anyhow!("`{key}` must be a string")),
1781 }
1782}
1783
1784fn wants_json(args: &Value) -> Result<bool> {
1786 match args.get("format") {
1787 None | Some(Value::Null) => Ok(false),
1788 Some(Value::String(s)) if s == "text" => Ok(false),
1789 Some(Value::String(s)) if s == "json" => Ok(true),
1790 Some(_) => Err(anyhow!("`format` must be \"text\" or \"json\"")),
1791 }
1792}
1793
1794fn capture_common_schema() -> Value {
1795 json!({
1796 "limit": {
1797 "type": "integer",
1798 "minimum": 0,
1799 "description": "Return the most recent N matching entries. Default 100. `limit: 0` with `clear: true` just clears."
1800 },
1801 "clear": {
1802 "type": "boolean",
1803 "description": "Clear the tab's buffer after reading. Use before an action to isolate its effects."
1804 },
1805 "format": {
1806 "type": "string",
1807 "enum": ["text", "json"],
1808 "description": "Output format. Default text (one line per entry)."
1809 }
1810 })
1811}
1812
1813fn make_console_messages() -> RegisteredTool {
1814 use crate::session::capture::{format_console_text, ConsoleQuery, CONSOLE_CAP};
1815 let mut props = capture_common_schema();
1816 props["pattern"] = json!({
1817 "type": "string",
1818 "description": "Unanchored regex applied to the rendered line (level, source URL, message, page URL). Always pass one on busy pages."
1819 });
1820 props["only_errors"] = json!({
1821 "type": "boolean",
1822 "description": "Only error-level entries (console.error, uncaught exceptions, failed resources). Default false."
1823 });
1824 RegisteredTool {
1825 name: "browser_console_messages".into(),
1826 description: format!(
1827 "Read console messages (console.*, uncaught exceptions, browser log entries such as \
1828 failed resource loads and CSP violations) captured for a tab. Capture starts when \
1829 the MCP server first touches a tab (browser_navigate, browser_tab_select, …) and \
1830 keeps the last {CONSOLE_CAP} entries across navigations until `clear`. Pass \
1831 `pattern` or `only_errors` to keep output small. Native protocol events on \
1832 Chromium (CDP) and Firefox (BiDi); no Node."
1833 ),
1834 input_schema: json!({
1835 "type": "object",
1836 "properties": tab_args_properties(props),
1837 }),
1838 handler: handler(|state, args| {
1839 Box::pin(async move {
1840 let q = ConsoleQuery {
1841 pattern: regex_arg(&args, "pattern")?,
1842 only_errors: bool_arg(&args, "only_errors", false)?,
1843 limit: count_arg(&args, "limit", 100, 0, CONSOLE_CAP)?,
1844 clear: bool_arg(&args, "clear", false)?,
1845 };
1846 let json_out = wants_json(&args)?;
1847 let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1848 let report = state.capture.read_console(&target_id, &q).await?;
1849 if json_out {
1850 Ok(text_content(serde_json::to_string_pretty(&report)?))
1851 } else {
1852 Ok(text_content(format_console_text(&report)))
1853 }
1854 })
1855 }),
1856 }
1857}
1858
1859fn make_network_requests() -> RegisteredTool {
1860 use crate::session::capture::{format_network_text, NetworkQuery, StatusFilter, NETWORK_CAP};
1861 let mut props = capture_common_schema();
1862 props["url_pattern"] = json!({
1863 "type": "string",
1864 "description": "Unanchored regex applied to the request URL."
1865 });
1866 props["method"] = json!({
1867 "type": "string",
1868 "description": "Exact HTTP method (case-insensitive)."
1869 });
1870 props["status"] = json!({
1871 "type": "string",
1872 "description": "Exact code (\"404\"), class (\"2xx\"…\"5xx\"), \"failed\", or \"pending\"."
1873 });
1874 props["resource_type"] = json!({
1875 "type": "string",
1876 "description": "Resource type: Document, XHR, Fetch, Script, Stylesheet, Image, Font, WebSocket, … On Firefox the type is derived from the request destination or MIME type and may be absent."
1877 });
1878 RegisteredTool {
1879 name: "browser_network_requests".into(),
1880 description: format!(
1881 "List network requests captured for a tab: method, URL, status, MIME type, size, \
1882 duration, failure reason, and the request id to pass to browser_network_body. \
1883 Capture starts when the MCP server first touches a tab and keeps the last \
1884 {NETWORK_CAP} requests across navigations until `clear`. Native protocol events \
1885 on Chromium (CDP) and Firefox (BiDi); no Node."
1886 ),
1887 input_schema: json!({
1888 "type": "object",
1889 "properties": tab_args_properties(props),
1890 }),
1891 handler: handler(|state, args| {
1892 Box::pin(async move {
1893 let q = NetworkQuery {
1894 url_pattern: regex_arg(&args, "url_pattern")?,
1895 method: string_arg(&args, "method")?,
1896 status: string_arg(&args, "status")?
1897 .map(|s| StatusFilter::parse(&s))
1898 .transpose()?,
1899 resource_type: string_arg(&args, "resource_type")?,
1900 limit: count_arg(&args, "limit", 100, 0, NETWORK_CAP)?,
1901 clear: bool_arg(&args, "clear", false)?,
1902 };
1903 let json_out = wants_json(&args)?;
1904 let (_backend, target_id) = state.resolve_target_for_args(&args).await?;
1905 let report = state.capture.read_network(&target_id, &q).await?;
1906 if json_out {
1907 Ok(text_content(serde_json::to_string_pretty(&report)?))
1908 } else {
1909 Ok(text_content(format_network_text(&report)))
1910 }
1911 })
1912 }),
1913 }
1914}
1915
1916fn make_network_body() -> RegisteredTool {
1917 use crate::session::capture::{BODY_DEFAULT_MAX, BODY_HARD_MAX};
1918 RegisteredTool {
1919 name: "browser_network_body".into(),
1920 description: "Fetch the response body of a captured request by the request id printed by \
1921 browser_network_requests. Text bodies come back as text, binary as an \
1922 embedded base64 resource, followed by a JSON metadata block. Default cap \
1923 256 KiB, hard max 8 MiB. Bodies are evicted by the browser after \
1924 navigation, so fetch promptly. Chromium-only: Firefox does not expose \
1925 captured bodies; use browser_fetch there."
1926 .into(),
1927 input_schema: json!({
1928 "type": "object",
1929 "properties": tab_args_properties(json!({
1930 "request_id": {
1931 "type": "string",
1932 "description": "Request id from browser_network_requests (e.g. \"1234.56\")."
1933 },
1934 "max_bytes": {
1935 "type": "integer",
1936 "minimum": 1,
1937 "maximum": BODY_HARD_MAX,
1938 "description": "Truncate the body after this many bytes. Default 262144."
1939 }
1940 })),
1941 "required": ["request_id"],
1942 }),
1943 handler: handler(|state, args| {
1944 Box::pin(async move {
1945 let request_id = string_arg(&args, "request_id")?
1946 .ok_or_else(|| anyhow!("missing 'request_id'"))?;
1947 let max_bytes = count_arg(&args, "max_bytes", BODY_DEFAULT_MAX, 1, BODY_HARD_MAX)?;
1948 state
1949 .ensure_body_capture_supported("browser_network_body")
1950 .await?;
1951 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
1952 let body = state
1953 .capture
1954 .response_body(&backend, &target_id, &request_id, max_bytes, MCP_OP_TIMEOUT)
1955 .await?;
1956 let mut content = Vec::new();
1957 match std::str::from_utf8(&body.bytes) {
1958 Ok(text) => content.push(json!({ "type": "text", "text": text })),
1959 Err(_) => {
1960 use base64::Engine as _;
1961 content.push(json!({
1962 "type": "resource",
1963 "resource": {
1964 "uri": format!("browser-control://network/{}", body.request_id),
1965 "mimeType": body.mime_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
1966 "blob": base64::engine::general_purpose::STANDARD.encode(&body.bytes),
1967 }
1968 }))
1969 }
1970 }
1971 content.push(json!({
1972 "type": "text",
1973 "text": serde_json::to_string_pretty(&json!({
1974 "request_id": body.request_id,
1975 "url": body.url,
1976 "status": body.status,
1977 "mime_type": body.mime_type,
1978 "bytes": body.bytes.len(),
1979 "total_bytes": body.total_bytes,
1980 "truncated": body.truncated,
1981 }))?
1982 }));
1983 Ok(json!({ "content": content }))
1984 })
1985 }),
1986 }
1987}
1988
1989fn snapshot_opts(args: &Value) -> Result<SnapshotOptions> {
1996 let interactive_only = match args.get("interactive_only") {
1997 None | Some(Value::Null) => false,
1998 Some(Value::Bool(b)) => *b,
1999 Some(_) => return Err(anyhow!("`interactive_only` must be a boolean")),
2000 };
2001 let max_chars = match args.get("max_chars") {
2002 None | Some(Value::Null) => a11y::DEFAULT_MAX_CHARS,
2003 Some(Value::Number(n)) => {
2004 let n = n
2005 .as_u64()
2006 .ok_or_else(|| anyhow!("`max_chars` must be a positive integer"))?;
2007 if n < 1000 {
2008 return Err(anyhow!("`max_chars` must be at least 1000"));
2009 }
2010 n as usize
2011 }
2012 Some(_) => return Err(anyhow!("`max_chars` must be a positive integer")),
2013 };
2014 let depth = match args.get("depth") {
2015 None | Some(Value::Null) => None,
2016 Some(Value::Number(n)) => Some(
2017 n.as_u64()
2018 .ok_or_else(|| anyhow!("`depth` must be a non-negative integer"))?
2019 as usize,
2020 ),
2021 Some(_) => return Err(anyhow!("`depth` must be a non-negative integer")),
2022 };
2023 if let Some(v) = args.get("ref") {
2024 if !v.is_null() && !v.is_string() {
2025 return Err(anyhow!("`ref` must be a string such as \"e12\""));
2026 }
2027 }
2028 Ok(SnapshotOptions {
2029 interactive_only,
2030 max_chars,
2031 root_backend_id: None,
2032 depth,
2033 })
2034}
2035
2036async fn fetch_ax_tree(backend: &TabBackend, target_id: &str) -> Result<a11y::AxTree> {
2038 let raw = backend
2039 .accessibility_tree(target_id, None, MCP_SNAPSHOT_TIMEOUT)
2040 .await?;
2041 a11y::parse_full_ax_tree(&raw)
2042}
2043
2044async fn with_ref_table<T>(
2047 state: &ServerState,
2048 target_id: &str,
2049 tree: &a11y::AxTree,
2050 f: impl FnOnce(&mut a11y::RefTable) -> Result<T>,
2051) -> Result<T> {
2052 let token = a11y::document_token(tree).unwrap_or(0);
2053 let mut refs = state.refs.lock().await;
2054 let table = refs
2055 .entry(target_id.to_string())
2056 .or_insert_with(|| a11y::RefTable::new(token));
2057 if table.doc_token != token {
2058 *table = a11y::RefTable::new(token);
2059 }
2060 f(table)
2061}
2062
2063async fn resolve_ref(
2068 state: &ServerState,
2069 backend: &TabBackend,
2070 target_id: &str,
2071 r: &str,
2072) -> Result<RefEntry> {
2073 let unknown = || SessionError::RefUnknown {
2074 element: r.to_string(),
2075 target_id: target_id.to_string(),
2076 };
2077 let (entry, doc_token) = {
2078 let refs = state.refs.lock().await;
2079 let table = refs.get(target_id).ok_or_else(unknown)?;
2080 let entry = table.lookup(r).ok_or_else(unknown)?.clone();
2081 (entry, table.doc_token)
2082 };
2083 let current = backend.document_token(target_id, MCP_OP_TIMEOUT).await?;
2084 if current != doc_token {
2085 state.refs.lock().await.remove(target_id);
2086 return Err(SessionError::StaleRef {
2087 element: r.to_string(),
2088 target_id: target_id.to_string(),
2089 reason: "document changed",
2090 }
2091 .into());
2092 }
2093 Ok(entry)
2094}
2095
2096fn stale_on_node_gone(err: anyhow::Error, r: &str, target_id: &str) -> anyhow::Error {
2099 if matches!(
2100 err.downcast_ref::<SessionError>(),
2101 Some(SessionError::NodeGone { .. })
2102 ) {
2103 return SessionError::StaleRef {
2104 element: r.to_string(),
2105 target_id: target_id.to_string(),
2106 reason: "node no longer exists",
2107 }
2108 .into();
2109 }
2110 err
2111}
2112
2113fn quote(s: &str) -> String {
2114 serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
2115}
2116
2117fn describe_ref(entry: &RefEntry) -> String {
2118 if entry.name.is_empty() {
2119 entry.role.clone()
2120 } else {
2121 format!("{} {}", entry.role, quote(&entry.name))
2122 }
2123}
2124
2125async fn page_header(backend: &TabBackend, target_id: &str, title_hint: &str) -> String {
2128 match backend.live_targets().await {
2129 Ok(targets) => targets
2130 .iter()
2131 .find(|t| t.id == target_id)
2132 .map(|t| {
2133 let title = if t.title.is_empty() {
2136 title_hint
2137 } else {
2138 &t.title
2139 };
2140 if title.is_empty() {
2141 format!("# {}\n", t.url)
2142 } else {
2143 format!("# {} ({})\n", title, t.url)
2144 }
2145 })
2146 .unwrap_or_default(),
2147 Err(_) => String::new(),
2148 }
2149}
2150
2151fn make_snapshot() -> RegisteredTool {
2152 RegisteredTool {
2153 name: "browser_snapshot".into(),
2154 description: "Accessibility snapshot of the page with stable element refs (`[ref=eN]`) \
2155 usable by browser_click / browser_type / browser_hover / browser_drag / \
2156 browser_take_screenshot. Prefer this over screenshots for reading page \
2157 structure. `interactive_only` keeps only actionable elements and their \
2158 ancestors (good for forms); `ref` renders one subtree; `depth` limits \
2159 nesting; `max_chars` caps output (default 50000, cut at a line boundary \
2160 with a note). Refs stay valid until the page navigates. Native on \
2161 Chromium (accessibility tree) and Firefox (injected DOM walker: names and \
2162 roles are approximate, closed shadow roots are not visible); iframe \
2163 contents are not included."
2164 .into(),
2165 input_schema: json!({
2166 "type": "object",
2167 "properties": tab_args_properties(json!({
2168 "interactive_only": {
2169 "type": "boolean",
2170 "description": "Only interactive elements (buttons, links, inputs, …) and their ancestors. Default false."
2171 },
2172 "max_chars": {
2173 "type": "integer",
2174 "minimum": 1000,
2175 "description": "Truncate output at a line boundary before this many characters. Default 50000."
2176 },
2177 "ref": {
2178 "type": "string",
2179 "description": "Render only the subtree rooted at this ref from a previous snapshot or find."
2180 },
2181 "depth": {
2182 "type": "integer",
2183 "minimum": 0,
2184 "description": "Levels below the root to include; deeper content collapses to `… (N more)`."
2185 }
2186 })),
2187 }),
2188 handler: handler(|state, args| {
2189 Box::pin(async move {
2190 let mut opts = snapshot_opts(&args)?;
2191 state.ensure_native_ready("browser_snapshot").await?;
2192 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
2193 if let Some(r) = args.get("ref").and_then(Value::as_str) {
2194 let entry = resolve_ref(&state, &backend, &target_id, r).await?;
2195 opts.root_backend_id = Some(entry.backend_node_id);
2196 }
2197 let tree = fetch_ax_tree(&backend, &target_id).await?;
2198 let root_title = tree
2199 .nodes
2200 .get(&tree.root)
2201 .map(|n| n.name.clone())
2202 .unwrap_or_default();
2203 let header = page_header(&backend, &target_id, &root_title).await;
2204 let snap = with_ref_table(&state, &target_id, &tree, |table| {
2205 a11y::render_snapshot(&tree, table, &opts)
2206 })
2207 .await?;
2208 Ok(text_content(format!("{header}{}", snap.text)))
2209 })
2210 }),
2211 }
2212}
2213
2214fn make_find() -> RegisteredTool {
2215 RegisteredTool {
2216 name: "browser_find".into(),
2217 description:
2218 "Find elements by a short description (\"search box\", \"add to cart button\", \
2219 \"Sign in\") and return their refs for browser_click / browser_type / etc. \
2220 Plain text matching against accessible name, value, description, and role; \
2221 no model call. Cheaper than a full browser_snapshot when you know what you \
2222 are looking for. Returns up to 20 matches, best first. Native on \
2223 Chromium and Firefox."
2224 .into(),
2225 input_schema: json!({
2226 "type": "object",
2227 "properties": tab_args_properties(json!({
2228 "query": {
2229 "type": "string",
2230 "description": "Words describing the element: visible text, label, placeholder, or role."
2231 },
2232 "interactive_only": {
2233 "type": "boolean",
2234 "description": "Only interactive elements. Default true; set false to find headings, images, text regions."
2235 },
2236 "limit": {
2237 "type": "integer",
2238 "minimum": 1,
2239 "maximum": 20,
2240 "description": "Maximum matches to return. Default 20."
2241 }
2242 })),
2243 "required": ["query"],
2244 }),
2245 handler: handler(|state, args| {
2246 Box::pin(async move {
2247 let query = args
2248 .get("query")
2249 .and_then(Value::as_str)
2250 .map(str::trim)
2251 .filter(|q| !q.is_empty())
2252 .ok_or_else(|| anyhow!("missing 'query'"))?
2253 .to_string();
2254 let interactive_only = match args.get("interactive_only") {
2255 None | Some(Value::Null) => true,
2256 Some(Value::Bool(b)) => *b,
2257 Some(_) => return Err(anyhow!("`interactive_only` must be a boolean")),
2258 };
2259 let limit = match args.get("limit") {
2260 None | Some(Value::Null) => a11y::DEFAULT_FIND_LIMIT,
2261 Some(Value::Number(n)) => {
2262 n.as_u64()
2263 .filter(|n| *n >= 1)
2264 .ok_or_else(|| anyhow!("`limit` must be a positive integer"))?
2265 .min(a11y::DEFAULT_FIND_LIMIT as u64) as usize
2266 }
2267 Some(_) => return Err(anyhow!("`limit` must be a positive integer")),
2268 };
2269 state.ensure_native_ready("browser_find").await?;
2270 let (backend, target_id) = state.resolve_target_for_args(&args).await?;
2271 let tree = fetch_ax_tree(&backend, &target_id).await?;
2272 let opts = FindOptions {
2273 interactive_only,
2274 limit,
2275 };
2276 let hits = with_ref_table(&state, &target_id, &tree, |table| {
2277 Ok(a11y::find(&tree, table, &query, &opts))
2278 })
2279 .await?;
2280 if hits.is_empty() {
2281 return Ok(text_content(format!(
2282 "no matches for {}; try fewer words, interactive_only: false, or browser_snapshot",
2283 quote(&query)
2284 )));
2285 }
2286 let mut out = format!(
2287 "{} match{} for {}:\n",
2288 hits.len(),
2289 if hits.len() == 1 { "" } else { "es" },
2290 quote(&query)
2291 );
2292 for m in &hits {
2293 out.push_str(&m.r#ref);
2294 out.push(' ');
2295 out.push_str(&m.role);
2296 if !m.name.is_empty() {
2297 out.push(' ');
2298 out.push_str("e(&m.name));
2299 }
2300 if let Some(v) = &m.value {
2301 out.push_str(&format!(" [value={}]", quote(v)));
2302 }
2303 if let Some(ctx) = &m.context {
2304 out.push_str(&format!(" — in {ctx}"));
2305 }
2306 out.push('\n');
2307 }
2308 Ok(text_content(out))
2309 })
2310 }),
2311 }
2312}
2313
2314#[derive(Clone, Copy, Debug)]
2316enum NativeAction {
2317 Click,
2318 Type,
2319 Hover,
2320 Drag,
2321}
2322
2323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2325enum Route {
2326 Native,
2327 Sidecar,
2328}
2329
2330fn route_mode(args: &Value, ref_pairs: &[(&str, &str)]) -> Result<Route> {
2334 if ref_pairs.is_empty() {
2335 return Ok(Route::Sidecar);
2336 }
2337 let mut native = 0;
2338 let mut sidecar = 0;
2339 for (sel, r) in ref_pairs {
2340 let has_sel = args.get(*sel).is_some_and(Value::is_string);
2341 let has_ref = args.get(*r).is_some_and(Value::is_string);
2342 match (has_sel, has_ref) {
2343 (true, true) => {
2344 return Err(anyhow!("`{sel}` and `{r}` are mutually exclusive; pass one of them"))
2345 }
2346 (false, false) => {
2347 return Err(anyhow!(
2348 "exactly one of `{sel}` (CSS selector) or `{r}` (ref from browser_snapshot/browser_find) is required"
2349 ))
2350 }
2351 (true, false) => sidecar += 1,
2352 (false, true) => native += 1,
2353 }
2354 }
2355 if native > 0 && sidecar > 0 {
2356 return Err(anyhow!(
2357 "use refs for every element or selectors for every element, not a mix"
2358 ));
2359 }
2360 Ok(if native > 0 {
2361 Route::Native
2362 } else {
2363 Route::Sidecar
2364 })
2365}
2366
2367async fn run_native(
2369 state: &ServerState,
2370 tool_name: &str,
2371 action: NativeAction,
2372 args: &Value,
2373) -> Result<Value> {
2374 state.ensure_native_ready(tool_name).await?;
2375 let (backend, target_id) = state.resolve_target_for_args(args).await?;
2376 let timeout = timeout_ms_arg(args, "timeout_ms", MCP_OP_TIMEOUT)?;
2377 let ref_arg = |key: &str| -> Result<String> {
2378 args.get(key)
2379 .and_then(Value::as_str)
2380 .map(String::from)
2381 .ok_or_else(|| anyhow!("missing '{key}'"))
2382 };
2383 match action {
2384 NativeAction::Click => {
2385 let r = ref_arg("ref")?;
2386 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2387 backend
2388 .click_node(&target_id, entry.backend_node_id, timeout)
2389 .await
2390 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2391 Ok(text_content(format!(
2392 "clicked {r} ({})",
2393 describe_ref(&entry)
2394 )))
2395 }
2396 NativeAction::Hover => {
2397 let r = ref_arg("ref")?;
2398 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2399 backend
2400 .hover_node(&target_id, entry.backend_node_id, timeout)
2401 .await
2402 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2403 Ok(text_content(format!(
2404 "hovered {r} ({})",
2405 describe_ref(&entry)
2406 )))
2407 }
2408 NativeAction::Type => {
2409 let r = ref_arg("ref")?;
2410 let text = args
2411 .get("text")
2412 .and_then(Value::as_str)
2413 .ok_or_else(|| anyhow!("missing 'text'"))?
2414 .to_string();
2415 let press_sequentially = args
2416 .get("press_sequentially")
2417 .and_then(Value::as_bool)
2418 .unwrap_or(false);
2419 let submit = args.get("submit").and_then(Value::as_bool).unwrap_or(false);
2420 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2421 backend
2422 .type_into_node(
2423 &target_id,
2424 entry.backend_node_id,
2425 &text,
2426 press_sequentially,
2427 submit,
2428 timeout,
2429 )
2430 .await
2431 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2432 Ok(text_content(format!(
2433 "typed into {r} ({}){}",
2434 describe_ref(&entry),
2435 if submit { " and pressed Enter" } else { "" }
2436 )))
2437 }
2438 NativeAction::Drag => {
2439 let a = ref_arg("source_ref")?;
2440 let b = ref_arg("target_ref")?;
2441 let from = resolve_ref(state, &backend, &target_id, &a).await?;
2442 let to = resolve_ref(state, &backend, &target_id, &b).await?;
2443 backend
2444 .drag_nodes(
2445 &target_id,
2446 from.backend_node_id,
2447 to.backend_node_id,
2448 timeout,
2449 )
2450 .await
2451 .map_err(|e| stale_on_node_gone(e, &a, &target_id))?;
2452 Ok(text_content(format!(
2453 "dragged {a} ({}) to {b} ({})",
2454 describe_ref(&from),
2455 describe_ref(&to)
2456 )))
2457 }
2458 }
2459}
2460
2461struct SidecarParam {
2480 name: &'static str,
2481 schema: Value,
2482 required: bool,
2483}
2484
2485struct SidecarTool {
2492 name: &'static str,
2493 description: &'static str,
2494 method: &'static str,
2496 params: Vec<SidecarParam>,
2497 success: &'static str,
2499 native: Option<NativeAction>,
2501 ref_pairs: &'static [(&'static str, &'static str)],
2503}
2504
2505const REF_PARAM_DESC: &str = "Element ref from browser_snapshot or browser_find (e.g. \"e12\"); \
2506 handled natively on Chromium and Firefox, no Node needed. Mutually exclusive with \
2507 the CSS selector; exactly one is required.";
2508
2509impl SidecarTool {
2510 fn build(self) -> RegisteredTool {
2511 let SidecarTool {
2512 name,
2513 description,
2514 method,
2515 params,
2516 success,
2517 native,
2518 ref_pairs,
2519 } = self;
2520
2521 let extra = Value::Object(
2524 params
2525 .iter()
2526 .map(|p| (p.name.to_string(), p.schema.clone()))
2527 .collect(),
2528 );
2529 let required: Vec<&str> = params
2530 .iter()
2531 .filter(|p| p.required)
2532 .map(|p| p.name)
2533 .collect();
2534 let mut input_schema = json!({
2535 "type": "object",
2536 "properties": tab_args_properties(extra),
2537 });
2538 if !required.is_empty() {
2539 input_schema["required"] = json!(required);
2540 }
2541
2542 let param_names: Vec<&'static str> = params
2545 .iter()
2546 .map(|p| p.name)
2547 .filter(|n| !ref_pairs.iter().any(|(_, r)| r == n))
2548 .collect();
2549 RegisteredTool {
2550 name: name.into(),
2551 description: description.into(),
2552 input_schema,
2553 handler: handler(move |state, args| {
2554 let param_names = param_names.clone();
2555 Box::pin(async move {
2556 match (route_mode(&args, ref_pairs)?, native) {
2557 (Route::Native, Some(action)) => {
2558 run_native(&state, name, action, &args).await
2559 }
2560 (Route::Native, None) => Err(anyhow!("{name} has no native path")),
2561 (Route::Sidecar, _) => {
2562 let mut params = serde_json::Map::new();
2563 for key in ¶m_names {
2564 copy_arg(&args, key, &mut params);
2565 }
2566 forward_to_sidecar(&state, name, &args, method, params).await?;
2567 Ok(text_content(success))
2568 }
2569 }
2570 })
2571 }),
2572 }
2573 }
2574}
2575
2576fn make_click() -> RegisteredTool {
2577 SidecarTool {
2578 name: "browser_click",
2579 description: "Click an element by `ref` (from browser_snapshot/browser_find; native, \
2580 Chromium and Firefox) or by CSS `selector` (Playwright sidecar, Chromium).",
2581 method: "click",
2582 params: vec![
2583 SidecarParam {
2584 name: "selector",
2585 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2586 required: false,
2587 },
2588 SidecarParam {
2589 name: "ref",
2590 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2591 required: false,
2592 },
2593 SidecarParam {
2594 name: "timeout_ms",
2595 schema: json!({"type": "integer"}),
2596 required: false,
2597 },
2598 ],
2599 success: "clicked",
2600 native: Some(NativeAction::Click),
2601 ref_pairs: &[("selector", "ref")],
2602 }
2603 .build()
2604}
2605
2606fn make_type() -> RegisteredTool {
2607 SidecarTool {
2608 name: "browser_type",
2609 description: "Replace the content of an input with `text`, addressed by `ref` (native, \
2610 Chromium and Firefox) or CSS `selector` (Playwright sidecar, Chromium). \
2611 `press_sequentially=true` sends one character at a time; `submit=true` \
2612 presses Enter afterwards.",
2613 method: "type",
2614 params: vec![
2615 SidecarParam {
2616 name: "selector",
2617 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2618 required: false,
2619 },
2620 SidecarParam {
2621 name: "ref",
2622 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2623 required: false,
2624 },
2625 SidecarParam {
2626 name: "text",
2627 schema: json!({"type": "string"}),
2628 required: true,
2629 },
2630 SidecarParam {
2631 name: "press_sequentially",
2632 schema: json!({"type": "boolean", "description": "Send the text one character at a time. On Firefox this dispatches real key events; on Chromium it inserts one character per event without keydown/keyup."}),
2633 required: false,
2634 },
2635 SidecarParam {
2636 name: "submit",
2637 schema: json!({"type": "boolean", "description": "Press Enter after typing."}),
2638 required: false,
2639 },
2640 SidecarParam {
2641 name: "timeout_ms",
2642 schema: json!({"type": "integer"}),
2643 required: false,
2644 },
2645 ],
2646 success: "typed",
2647 native: Some(NativeAction::Type),
2648 ref_pairs: &[("selector", "ref")],
2649 }
2650 .build()
2651}
2652
2653fn make_hover() -> RegisteredTool {
2654 SidecarTool {
2655 name: "browser_hover",
2656 description: "Hover an element by `ref` (native, Chromium and Firefox) or CSS `selector` \
2657 (Playwright sidecar, Chromium).",
2658 method: "hover",
2659 params: vec![
2660 SidecarParam {
2661 name: "selector",
2662 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2663 required: false,
2664 },
2665 SidecarParam {
2666 name: "ref",
2667 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2668 required: false,
2669 },
2670 SidecarParam {
2671 name: "timeout_ms",
2672 schema: json!({"type": "integer"}),
2673 required: false,
2674 },
2675 ],
2676 success: "hovered",
2677 native: Some(NativeAction::Hover),
2678 ref_pairs: &[("selector", "ref")],
2679 }
2680 .build()
2681}
2682
2683fn make_drag() -> RegisteredTool {
2684 SidecarTool {
2685 name: "browser_drag",
2686 description: "Drag one element onto another, by refs (`source_ref`/`target_ref`, native \
2687 pointer events on Chromium and Firefox) or CSS selectors \
2688 (`source_selector`/`target_selector`, Playwright sidecar, Chromium).",
2689 method: "drag",
2690 params: vec![
2691 SidecarParam {
2692 name: "source_selector",
2693 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `source_ref`."}),
2694 required: false,
2695 },
2696 SidecarParam {
2697 name: "target_selector",
2698 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `target_ref`."}),
2699 required: false,
2700 },
2701 SidecarParam {
2702 name: "source_ref",
2703 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2704 required: false,
2705 },
2706 SidecarParam {
2707 name: "target_ref",
2708 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2709 required: false,
2710 },
2711 ],
2712 success: "dragged",
2713 native: Some(NativeAction::Drag),
2714 ref_pairs: &[
2715 ("source_selector", "source_ref"),
2716 ("target_selector", "target_ref"),
2717 ],
2718 }
2719 .build()
2720}
2721
2722fn make_press_key() -> RegisteredTool {
2723 SidecarTool {
2724 name: "browser_press_key",
2725 description: "Press a keyboard key (Playwright key name, e.g. 'Enter', 'Control+A'). \
2726 Chromium-only.",
2727 method: "press_key",
2728 params: vec![SidecarParam {
2729 name: "key",
2730 schema: json!({"type": "string"}),
2731 required: true,
2732 }],
2733 success: "pressed",
2734 native: None,
2735 ref_pairs: &[],
2736 }
2737 .build()
2738}
2739
2740fn make_wait_for() -> RegisteredTool {
2741 SidecarTool {
2742 name: "browser_wait_for",
2743 description: "Wait for a condition: a selector reaching `state`, a URL matching \
2744 `url_regex`, or the page reaching `load_state` (`load` / \
2745 `domcontentloaded` / `networkidle`). Chromium-only.",
2746 method: "wait_for",
2747 params: vec![
2748 SidecarParam { name: "selector", schema: json!({"type": "string"}), required: false },
2749 SidecarParam { name: "state", schema: json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), required: false },
2750 SidecarParam { name: "url_regex", schema: json!({"type": "string"}), required: false },
2751 SidecarParam { name: "load_state", schema: json!({"type": "string", "enum": ["load", "domcontentloaded", "networkidle"]}), required: false },
2752 SidecarParam { name: "timeout_ms", schema: json!({"type": "integer"}), required: false },
2753 ],
2754 success: "ok",
2755 native: None,
2756 ref_pairs: &[],
2757 }
2758 .build()
2759}
2760
2761fn make_pdf_save() -> RegisteredTool {
2762 RegisteredTool {
2763 name: "browser_pdf_save".into(),
2764 description: "Render the active page to PDF (base64 in `pdf_base64`). Chromium-only."
2765 .into(),
2766 input_schema: json!({
2767 "type": "object",
2768 "properties": tab_args_schema(),
2769 }),
2770 handler: handler(|state, args| {
2771 Box::pin(async move {
2772 let v = forward_to_sidecar(
2773 &state,
2774 "browser_pdf_save",
2775 &args,
2776 "pdf",
2777 serde_json::Map::new(),
2778 )
2779 .await?;
2780 let b64 = v
2781 .get("pdf_base64")
2782 .and_then(|s| s.as_str())
2783 .unwrap_or_default();
2784 Ok(json!({
2785 "content": [{
2786 "type": "resource",
2787 "resource": { "mimeType": "application/pdf", "blob": b64 }
2788 }]
2789 }))
2790 })
2791 }),
2792 }
2793}
2794
2795fn copy_arg(args: &Value, key: &str, dst: &mut serde_json::Map<String, Value>) {
2797 if let Some(v) = args.get(key) {
2798 dst.insert(key.into(), v.clone());
2799 }
2800}
2801
2802#[cfg(test)]
2803mod tests {
2804 use super::*;
2805 use futures_util::{SinkExt, StreamExt};
2806 use tokio::sync::Mutex;
2807 use tokio_tungstenite::tungstenite::Message;
2808
2809 const EXPECTED_TOOLS: &[&str] = &[
2812 "browser_navigate",
2813 "browser_eval",
2814 "browser_get_html",
2815 "browser_get_page_text",
2816 "browser_take_screenshot",
2817 "browser_fetch",
2818 "browser_curl",
2819 "browser_select_element",
2820 "browser_cookies",
2821 "browser_storage_get",
2822 "browser_storage_set",
2823 "browser_wait_for_cookie",
2824 "browser_console_messages",
2825 "browser_network_requests",
2826 "browser_network_body",
2827 "list_targets",
2828 "browser_tab_list",
2829 "browser_tab_new",
2830 "browser_tab_select",
2831 "browser_tab_close",
2832 "browser_tab_foreground",
2833 "browser_start",
2834 "browser_select",
2835 "browser_list",
2836 "browser_show",
2837 "browser_snapshot",
2838 "browser_find",
2839 "browser_click",
2840 "browser_type",
2841 "browser_hover",
2842 "browser_drag",
2843 "browser_press_key",
2844 "browser_wait_for",
2845 "browser_pdf_save",
2846 ];
2847
2848 fn schema_for(name: &str) -> Value {
2849 let registry = ToolRegistry::new();
2850 register_all(®istry);
2851 registry
2852 .list()
2853 .into_iter()
2854 .find(|t| t["name"] == name)
2855 .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
2856 .clone()
2857 }
2858
2859 fn tool_description(name: &str) -> String {
2860 let registry = ToolRegistry::new();
2861 register_all(®istry);
2862 registry
2863 .list()
2864 .into_iter()
2865 .find(|t| t["name"] == name)
2866 .unwrap_or_else(|| panic!("tool {name} not registered"))["description"]
2867 .as_str()
2868 .unwrap_or("")
2869 .to_string()
2870 }
2871
2872 struct ScreenshotMock {
2873 endpoint: String,
2874 capture_params: Arc<Mutex<Vec<Value>>>,
2875 }
2876
2877 async fn spawn_screenshot_mock(selector_rect: Value) -> ScreenshotMock {
2878 spawn_screenshot_mock_with_data(selector_rect, "PNGDATA".into()).await
2879 }
2880
2881 fn fake_png_1280x720() -> Vec<u8> {
2884 let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2885 png.extend_from_slice(&[0, 0, 0, 13]);
2886 png.extend_from_slice(b"IHDR");
2887 png.extend_from_slice(&1280u32.to_be_bytes());
2888 png.extend_from_slice(&720u32.to_be_bytes());
2889 png
2890 }
2891
2892 async fn spawn_screenshot_mock_with_data(selector_rect: Value, data: String) -> ScreenshotMock {
2896 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2897 let addr = listener.local_addr().unwrap();
2898 let capture_params = Arc::new(Mutex::new(Vec::new()));
2899 tokio::spawn({
2900 let capture_params = capture_params.clone();
2901 async move {
2902 let (stream, _) = listener.accept().await.unwrap();
2903 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2904 let mut next_session = 0u32;
2905 let mut eval_count = 0u32;
2906 while let Some(Ok(Message::Text(t))) = ws.next().await {
2907 let req: Value = serde_json::from_str(&t).unwrap();
2908 let id = req["id"].as_u64().unwrap();
2909 let method = req["method"].as_str().unwrap_or("");
2910 let result = match method {
2911 "Target.getTargets" => json!({
2912 "targetInfos": [{
2913 "targetId": "T1",
2914 "type": "page",
2915 "url": "https://example.com/",
2916 "title": "Example",
2917 }]
2918 }),
2919 "Target.attachToTarget" => {
2920 next_session += 1;
2921 json!({"sessionId": format!("S{next_session}")})
2922 }
2923 "Target.detachFromTarget" => json!({}),
2924 "Inspector.enable" => json!({}),
2925 "Runtime.evaluate" => {
2926 eval_count += 1;
2927 if eval_count == 1 {
2928 json!({"result": {"value": 1}})
2929 } else {
2930 json!({"result": {"value": selector_rect.clone()}})
2931 }
2932 }
2933 "Page.captureScreenshot" => {
2934 capture_params.lock().await.push(req["params"].clone());
2935 json!({"data": data})
2936 }
2937 "Page.getLayoutMetrics" => json!({
2938 "cssLayoutViewport": {"pageX": 0, "pageY": 100, "clientWidth": 1000, "clientHeight": 500},
2939 "cssContentSize": {"width": 1000, "height": 3000},
2940 }),
2941 _ => json!({}),
2942 };
2943 let resp = json!({"id": id, "result": result});
2944 ws.send(Message::Text(resp.to_string())).await.unwrap();
2945 }
2946 }
2947 });
2948 ScreenshotMock {
2949 endpoint: format!("ws://{addr}"),
2950 capture_params,
2951 }
2952 }
2953
2954 #[test]
2955 fn register_all_includes_expected_set() {
2956 let registry = ToolRegistry::new();
2957 register_all(®istry);
2958 let list = registry.list();
2959 let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
2960 for expected in EXPECTED_TOOLS {
2961 assert!(
2962 names.contains(expected),
2963 "missing tool {expected} in {names:?}"
2964 );
2965 }
2966 assert_eq!(
2967 list.len(),
2968 EXPECTED_TOOLS.len(),
2969 "extra tools present: {names:?}"
2970 );
2971 }
2972
2973 #[test]
2974 fn every_tool_has_object_input_schema() {
2975 let registry = ToolRegistry::new();
2976 register_all(®istry);
2977 for t in registry.list() {
2978 let schema = &t["inputSchema"];
2979 assert!(schema.is_object(), "schema not object: {schema}");
2980 assert_eq!(
2981 schema["type"], "object",
2982 "schema type != object for {}: {schema}",
2983 t["name"]
2984 );
2985 }
2986 }
2987
2988 #[test]
2989 fn list_targets_schema_has_optional_filter() {
2990 let schema = schema_for("list_targets");
2991 assert_eq!(schema["properties"]["filter"]["type"], "string");
2992 assert!(
2993 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
2994 );
2995 }
2996
2997 #[test]
2998 fn browser_cookies_schema_has_optional_filters() {
2999 let schema = schema_for("browser_cookies");
3000 assert_eq!(schema["properties"]["domain"]["type"], "string");
3001 assert_eq!(schema["properties"]["name"]["type"], "string");
3002 assert!(
3003 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3004 );
3005 }
3006
3007 #[test]
3008 fn browser_eval_requires_expression_and_supports_routing() {
3009 let schema = schema_for("browser_eval");
3010 let required = schema["required"].as_array().expect("required array");
3011 assert!(required.iter().any(|v| v == "expression"));
3012 assert_eq!(schema["properties"]["expression"]["type"], "string");
3013 assert_eq!(schema["properties"]["await_promise"]["type"], "boolean");
3014 assert_eq!(schema["properties"]["timeout_ms"]["type"], "number");
3015 assert_eq!(schema["properties"]["tab"]["type"], "string");
3016 assert_eq!(schema["properties"]["target"]["type"], "string");
3017 }
3018
3019 #[test]
3020 fn browser_storage_get_requires_key() {
3021 let schema = schema_for("browser_storage_get");
3022 let required = schema["required"].as_array().expect("required array");
3023 assert!(required.iter().any(|v| v == "key"));
3024 assert_eq!(schema["properties"]["key"]["type"], "string");
3025 assert_eq!(schema["properties"]["namespace"]["type"], "string");
3026 }
3027
3028 #[test]
3029 fn browser_storage_set_requires_key_and_value() {
3030 let schema = schema_for("browser_storage_set");
3031 let required: Vec<&str> = schema["required"]
3032 .as_array()
3033 .unwrap()
3034 .iter()
3035 .map(|v| v.as_str().unwrap())
3036 .collect();
3037 assert!(required.contains(&"key"));
3038 assert!(required.contains(&"value"));
3039 assert_eq!(schema["properties"]["value"]["type"], "string");
3040 }
3041
3042 #[test]
3043 fn browser_wait_for_cookie_requires_domain_and_name() {
3044 let schema = schema_for("browser_wait_for_cookie");
3045 let required: Vec<&str> = schema["required"]
3046 .as_array()
3047 .unwrap()
3048 .iter()
3049 .map(|v| v.as_str().unwrap())
3050 .collect();
3051 assert!(required.contains(&"domain"));
3052 assert!(required.contains(&"name"));
3053 assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
3054 assert_eq!(
3055 schema["properties"]["poll_interval_seconds"]["type"],
3056 "number"
3057 );
3058 }
3059
3060 #[test]
3061 fn browser_navigate_schema_has_tab_and_target() {
3062 let schema = schema_for("browser_navigate");
3064 assert_eq!(schema["properties"]["tab"]["type"], "string");
3065 assert_eq!(schema["properties"]["target"]["type"], "string");
3066 let required: Vec<&str> = schema["required"]
3067 .as_array()
3068 .unwrap()
3069 .iter()
3070 .map(|v| v.as_str().unwrap())
3071 .collect();
3072 assert!(required.contains(&"url"));
3073 assert!(!required.contains(&"tab"));
3074 assert!(!required.contains(&"target"));
3075 }
3076
3077 #[test]
3078 fn browser_tab_select_requires_target_id() {
3079 let schema = schema_for("browser_tab_select");
3080 let required: Vec<&str> = schema["required"]
3081 .as_array()
3082 .unwrap()
3083 .iter()
3084 .map(|v| v.as_str().unwrap())
3085 .collect();
3086 assert!(required.contains(&"target_id"));
3087 }
3088
3089 #[test]
3090 fn browser_tab_close_target_id_is_optional() {
3091 let schema = schema_for("browser_tab_close");
3093 assert!(
3094 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3095 );
3096 assert_eq!(schema["properties"]["target_id"]["type"], "string");
3097 }
3098
3099 #[test]
3100 fn browser_select_requires_name() {
3101 let schema = schema_for("browser_select");
3102 let required: Vec<&str> = schema["required"]
3103 .as_array()
3104 .unwrap()
3105 .iter()
3106 .map(|v| v.as_str().unwrap())
3107 .collect();
3108 assert!(required.contains(&"name"));
3109 }
3110
3111 #[test]
3112 fn browser_select_description_documents_failed_lock_contract() {
3113 let desc = tool_description("browser_select");
3114 assert!(desc.contains("committed before"));
3115 assert!(desc.contains("new browser remains active"));
3116 assert!(desc.contains("switch back"));
3117 }
3118
3119 #[test]
3120 fn browser_list_has_no_args() {
3121 let schema = schema_for("browser_list");
3122 assert_eq!(schema["properties"], json!({}));
3123 }
3124
3125 #[test]
3126 fn browser_cookies_schema_has_no_tab_arg() {
3127 let schema = schema_for("browser_cookies");
3129 assert!(schema["properties"].get("tab").is_none());
3130 assert!(schema["properties"].get("target").is_none());
3131 }
3132
3133 #[test]
3136 fn sidecar_tools_expose_tab_and_target() {
3137 for name in &[
3138 "browser_snapshot",
3139 "browser_click",
3140 "browser_type",
3141 "browser_hover",
3142 "browser_drag",
3143 "browser_press_key",
3144 "browser_wait_for",
3145 "browser_pdf_save",
3146 ] {
3147 let schema = schema_for(name);
3148 assert_eq!(
3149 schema["properties"]["tab"]["type"], "string",
3150 "{name} missing tab arg"
3151 );
3152 assert_eq!(
3153 schema["properties"]["target"]["type"], "string",
3154 "{name} missing target arg"
3155 );
3156 }
3157 }
3158
3159 #[test]
3164 fn sidecar_tools_required_args() {
3165 let click = schema_for("browser_click");
3166 assert!(
3167 click.get("required").is_none() || click["required"].as_array().unwrap().is_empty()
3168 );
3169 assert_eq!(click["properties"]["selector"]["type"], "string");
3170 assert_eq!(click["properties"]["ref"]["type"], "string");
3171
3172 let t = schema_for("browser_type");
3173 let req: Vec<&str> = t["required"]
3174 .as_array()
3175 .unwrap()
3176 .iter()
3177 .map(|v| v.as_str().unwrap())
3178 .collect();
3179 assert_eq!(req, vec!["text"]);
3180 assert_eq!(t["properties"]["ref"]["type"], "string");
3181 assert_eq!(t["properties"]["submit"]["type"], "boolean");
3182
3183 let hover = schema_for("browser_hover");
3184 assert_eq!(hover["properties"]["ref"]["type"], "string");
3185 let drag = schema_for("browser_drag");
3186 assert_eq!(drag["properties"]["source_ref"]["type"], "string");
3187 assert_eq!(drag["properties"]["target_ref"]["type"], "string");
3188 assert!(drag.get("required").is_none());
3189
3190 let snap = schema_for("browser_snapshot");
3192 assert!(snap.get("required").is_none() || snap["required"].as_array().unwrap().is_empty());
3193 for key in ["interactive_only", "max_chars", "ref", "depth"] {
3194 assert!(
3195 snap["properties"][key].is_object(),
3196 "snapshot missing {key}"
3197 );
3198 }
3199 let pdf = schema_for("browser_pdf_save");
3200 assert!(pdf.get("required").is_none() || pdf["required"].as_array().unwrap().is_empty());
3201
3202 let find = schema_for("browser_find");
3203 assert_eq!(find["required"], json!(["query"]));
3204 assert_eq!(find["properties"]["tab"]["type"], "string");
3205 }
3206
3207 #[test]
3208 fn route_mode_validates_selector_ref_pairs() {
3209 let pairs = &[("selector", "ref")];
3210 assert_eq!(
3211 route_mode(&json!({"ref": "e1"}), pairs).unwrap(),
3212 Route::Native
3213 );
3214 assert_eq!(
3215 route_mode(&json!({"selector": "#x"}), pairs).unwrap(),
3216 Route::Sidecar
3217 );
3218 let err = route_mode(&json!({}), pairs).unwrap_err().to_string();
3219 assert!(err.contains("exactly one of `selector`"), "{err}");
3220 let err = route_mode(&json!({"selector": "#x", "ref": "e1"}), pairs)
3221 .unwrap_err()
3222 .to_string();
3223 assert!(err.contains("mutually exclusive"), "{err}");
3224
3225 let drag = &[
3226 ("source_selector", "source_ref"),
3227 ("target_selector", "target_ref"),
3228 ];
3229 let err = route_mode(&json!({"source_ref": "e1", "target_selector": "#y"}), drag)
3230 .unwrap_err()
3231 .to_string();
3232 assert!(err.contains("not a mix"), "{err}");
3233 assert_eq!(
3234 route_mode(&json!({"source_ref": "e1", "target_ref": "e2"}), drag).unwrap(),
3235 Route::Native
3236 );
3237 assert_eq!(route_mode(&json!({}), &[]).unwrap(), Route::Sidecar);
3239 }
3240
3241 #[tokio::test]
3242 async fn click_without_selector_or_ref_errors_before_backend() {
3243 let h = handler_for("browser_click");
3244 let err = h(unreached_state(), json!({}))
3245 .await
3246 .expect_err("must error");
3247 assert!(err.to_string().contains("exactly one of"), "got: {err:#}");
3248 let h = handler_for("browser_drag");
3249 let err = h(
3250 unreached_state(),
3251 json!({"source_ref": "e1", "target_selector": "#a"}),
3252 )
3253 .await
3254 .expect_err("must error");
3255 assert!(err.to_string().contains("not a mix"), "got: {err:#}");
3256 }
3257
3258 #[tokio::test]
3259 async fn snapshot_rejects_bad_options_before_backend() {
3260 let h = handler_for("browser_snapshot");
3261 let err = h(unreached_state(), json!({"max_chars": 10}))
3262 .await
3263 .expect_err("must error");
3264 assert!(err.to_string().contains("at least 1000"), "got: {err:#}");
3265 let h = handler_for("browser_find");
3266 let err = h(unreached_state(), json!({"query": " "}))
3267 .await
3268 .expect_err("must error");
3269 assert!(err.to_string().contains("missing 'query'"), "got: {err:#}");
3270 }
3271
3272 struct BidiA11yMock {
3276 endpoint: String,
3277 doc_token: Arc<std::sync::atomic::AtomicU64>,
3278 requests: Arc<Mutex<Vec<Value>>>,
3279 }
3280
3281 async fn spawn_bidi_a11y_mock() -> BidiA11yMock {
3282 use std::sync::atomic::{AtomicU64, Ordering};
3283 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3284 let addr = listener.local_addr().unwrap();
3285 let doc_token = Arc::new(AtomicU64::new(4294967296));
3286 let requests = Arc::new(Mutex::new(Vec::new()));
3287 tokio::spawn({
3288 let doc_token = doc_token.clone();
3289 let requests = requests.clone();
3290 async move {
3291 let (stream, _) = listener.accept().await.unwrap();
3292 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3293 while let Some(Ok(Message::Text(t))) = ws.next().await {
3294 let req: Value = serde_json::from_str(&t).unwrap();
3295 requests.lock().await.push(req.clone());
3296 let id = req["id"].as_u64().unwrap();
3297 let method = req["method"].as_str().unwrap_or("");
3298 let decl = req["params"]["functionDeclaration"].as_str().unwrap_or("");
3299 let expr = req["params"]["expression"].as_str().unwrap_or("");
3300 let string = |s: String| json!({"type": "success", "result": {"type": "string", "value": s}, "realm": "R1"});
3301 let result = match method {
3302 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3303 "browsingContext.getTree" => json!({"contexts": [
3304 {"context": "CTX1", "url": "https://example.com/", "children": []}
3305 ]}),
3306 "browsingContext.captureScreenshot" => json!({"data": "PNGDATA"}),
3307 "script.callFunction" if decl.contains("bc:snapshot") => string(
3308 json!({"nodes": [
3309 {"nodeId": "root", "backendDOMNodeId": 4294967296u64,
3310 "role": {"value": "RootWebArea"}, "name": {"value": "Example"}, "childIds": ["n1", "n2"]},
3311 {"nodeId": "n1", "parentId": "root", "backendDOMNodeId": 1,
3312 "role": {"value": "button"}, "name": {"value": "Submit"}, "childIds": [],
3313 "properties": [{"name": "focusable", "value": {"value": true}}]},
3314 {"nodeId": "n2", "parentId": "root", "backendDOMNodeId": 2,
3315 "role": {"value": "link"}, "name": {"value": "Docs"}, "childIds": [],
3316 "properties": [{"name": "focusable", "value": {"value": true}}]}
3317 ], "truncated": false})
3318 .to_string(),
3319 ),
3320 "script.callFunction" if decl.contains("bc:center") => {
3321 string("{\"x\":30,\"y\":20}".into())
3322 }
3323 "script.callFunction" if decl.contains("bc:clip") => {
3324 string("{\"x\":10,\"y\":30,\"width\":100,\"height\":20}".into())
3325 }
3326 "script.callFunction" if decl.contains("bc:type") => {
3327 string("{\"kind\":\"field\",\"method\":\"execCommand\"}".into())
3328 }
3329 "script.evaluate" if expr.contains("__bcDocToken") => {
3330 string(doc_token.load(Ordering::SeqCst).to_string())
3331 }
3332 "script.evaluate" => {
3333 json!({"type": "success", "result": {"type": "number", "value": 1}, "realm": "R1"})
3334 }
3335 _ => json!({}),
3336 };
3337 ws.send(Message::Text(
3338 json!({"type": "success", "id": id, "result": result}).to_string(),
3339 ))
3340 .await
3341 .unwrap();
3342 }
3343 }
3344 });
3345 BidiA11yMock {
3346 endpoint: format!("ws://{addr}"),
3347 doc_token,
3348 requests,
3349 }
3350 }
3351
3352 fn bidi_state(endpoint: &str) -> ServerState {
3353 ServerState::new(ResolvedBrowser {
3354 engine: Engine::Bidi,
3355 endpoint: endpoint.to_string(),
3356 source: Source::External,
3357 })
3358 }
3359
3360 #[tokio::test]
3361 async fn bidi_snapshot_then_click_by_ref_performs_actions() {
3362 use std::sync::atomic::Ordering;
3363 let mock = spawn_bidi_a11y_mock().await;
3364 let state = bidi_state(&mock.endpoint);
3365 let route = json!({"target": "example\\.com"});
3366
3367 let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3368 .await
3369 .unwrap();
3370 assert_eq!(
3371 snap["content"][0]["text"],
3372 "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3373 );
3374
3375 let mut args = route.clone();
3376 args["ref"] = json!("e1");
3377 let out = handler_for("browser_click")(state.clone(), args.clone())
3378 .await
3379 .unwrap();
3380 assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3381 {
3382 let reqs = mock.requests.lock().await;
3383 let perform = reqs
3384 .iter()
3385 .find(|r| r["method"] == "input.performActions")
3386 .expect("performActions");
3387 assert_eq!(perform["params"]["context"], "CTX1");
3388 let acts = &perform["params"]["actions"][0]["actions"];
3389 assert_eq!(acts[0]["type"], "pointerMove");
3390 assert_eq!(acts[0]["x"], 30);
3391 assert_eq!(acts[1]["type"], "pointerDown");
3392 assert_eq!(acts[2]["type"], "pointerUp");
3393 assert!(reqs.iter().any(|r| r["method"] == "script.callFunction"
3394 && r["params"]["arguments"][0]["value"] == 1));
3395 }
3396 assert!(state.sidecar.lock().await.is_none());
3397
3398 let mut find_args = route.clone();
3399 find_args["query"] = json!("docs");
3400 let out = handler_for("browser_find")(state.clone(), find_args)
3401 .await
3402 .unwrap();
3403 assert_eq!(
3404 out["content"][0]["text"],
3405 "1 match for \"docs\":\ne2 link \"Docs\"\n"
3406 );
3407
3408 mock.doc_token.store(4294967297, Ordering::SeqCst);
3409 let err = handler_for("browser_click")(state.clone(), args.clone())
3410 .await
3411 .unwrap_err();
3412 assert!(matches!(
3413 err.downcast_ref::<SessionError>(),
3414 Some(SessionError::StaleRef {
3415 reason: "document changed",
3416 ..
3417 })
3418 ));
3419 let err = handler_for("browser_click")(state.clone(), args)
3420 .await
3421 .unwrap_err();
3422 assert!(matches!(
3423 err.downcast_ref::<SessionError>(),
3424 Some(SessionError::RefUnknown { .. })
3425 ));
3426 }
3427
3428 #[tokio::test]
3429 async fn bidi_type_by_ref_fills_and_submits() {
3430 let mock = spawn_bidi_a11y_mock().await;
3431 let state = bidi_state(&mock.endpoint);
3432 let route = json!({"target": "example\\.com"});
3433 handler_for("browser_snapshot")(state.clone(), route.clone())
3434 .await
3435 .unwrap();
3436 let mut args = route.clone();
3437 args["ref"] = json!("e1");
3438 args["text"] = json!("hello");
3439 args["submit"] = json!(true);
3440 let out = handler_for("browser_type")(state.clone(), args)
3441 .await
3442 .unwrap();
3443 assert_eq!(
3444 out["content"][0]["text"],
3445 "typed into e1 (button \"Submit\") and pressed Enter"
3446 );
3447 let reqs = mock.requests.lock().await;
3448 let typed = reqs
3449 .iter()
3450 .find(|r| {
3451 r["params"]["functionDeclaration"]
3452 .as_str()
3453 .is_some_and(|d| d.contains("bc:type"))
3454 })
3455 .expect("type helper");
3456 assert_eq!(typed["params"]["arguments"][1]["value"], "hello");
3457 assert_eq!(typed["params"]["arguments"][2]["value"], "fill");
3458 let keys = reqs
3459 .iter()
3460 .find(|r| r["method"] == "input.performActions")
3461 .expect("enter");
3462 assert_eq!(keys["params"]["actions"][0]["type"], "key");
3463 assert_eq!(
3464 keys["params"]["actions"][0]["actions"][0]["value"],
3465 "\u{e007}"
3466 );
3467 }
3468
3469 #[tokio::test]
3470 async fn bidi_screenshot_by_ref_and_full_page_clip_to_document() {
3471 let mock = spawn_bidi_a11y_mock().await;
3472 let state = bidi_state(&mock.endpoint);
3473 let route = json!({"target": "example\\.com"});
3474 handler_for("browser_snapshot")(state.clone(), route.clone())
3475 .await
3476 .unwrap();
3477 let mut args = route.clone();
3478 args["ref"] = json!("e2");
3479 let out = handler_for("browser_take_screenshot")(state.clone(), args)
3480 .await
3481 .unwrap();
3482 assert_eq!(out["content"][0]["type"], "image");
3483 let reqs = mock.requests.lock().await;
3484 let cap = reqs
3485 .iter()
3486 .find(|r| r["method"] == "browsingContext.captureScreenshot")
3487 .expect("capture");
3488 assert_eq!(cap["params"]["origin"], "document");
3489 assert_eq!(cap["params"]["clip"]["type"], "box");
3490 assert_eq!(cap["params"]["clip"]["x"], 10);
3491 assert_eq!(cap["params"]["clip"]["width"], 100);
3492 }
3493
3494 struct A11yMock {
3497 endpoint: String,
3498 doc_token: Arc<std::sync::atomic::AtomicU64>,
3499 requests: Arc<Mutex<Vec<Value>>>,
3500 }
3501
3502 async fn spawn_a11y_mock() -> A11yMock {
3503 use std::sync::atomic::{AtomicU64, Ordering};
3504 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3505 let addr = listener.local_addr().unwrap();
3506 let doc_token = Arc::new(AtomicU64::new(101));
3507 let requests = Arc::new(Mutex::new(Vec::new()));
3508 tokio::spawn({
3509 let doc_token = doc_token.clone();
3510 let requests = requests.clone();
3511 async move {
3512 let (stream, _) = listener.accept().await.unwrap();
3513 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3514 let mut next_session = 0u32;
3515 while let Some(Ok(Message::Text(t))) = ws.next().await {
3516 let req: Value = serde_json::from_str(&t).unwrap();
3517 requests.lock().await.push(req.clone());
3518 let id = req["id"].as_u64().unwrap();
3519 let method = req["method"].as_str().unwrap_or("");
3520 let result = match method {
3521 "Target.getTargets" => json!({"targetInfos": [{
3522 "targetId": "T1", "type": "page",
3523 "url": "https://example.com/", "title": "Example",
3524 }]}),
3525 "Target.attachToTarget" => {
3526 next_session += 1;
3527 json!({"sessionId": format!("S{next_session}")})
3528 }
3529 "Runtime.evaluate" => json!({"result": {"value": 1}}),
3530 "Accessibility.getFullAXTree" => json!({"nodes": [
3531 {"nodeId": "1", "backendDOMNodeId": 101,
3532 "role": {"value": "RootWebArea"}, "name": {"value": "Example"},
3533 "childIds": ["2", "3"]},
3534 {"nodeId": "2", "parentId": "1", "backendDOMNodeId": 106,
3535 "role": {"value": "button"}, "name": {"value": "Submit"},
3536 "properties": [{"name": "focusable", "value": {"value": true}}],
3537 "childIds": []},
3538 {"nodeId": "3", "parentId": "1", "backendDOMNodeId": 108,
3539 "role": {"value": "link"}, "name": {"value": "Docs"},
3540 "properties": [{"name": "focusable", "value": {"value": true}}],
3541 "childIds": []},
3542 ]}),
3543 "DOM.getDocument" => {
3544 json!({"root": {"backendNodeId": doc_token.load(Ordering::SeqCst)}})
3545 }
3546 "DOM.getContentQuads" => {
3547 json!({"quads": [[10, 10, 50, 10, 50, 30, 10, 30]]})
3548 }
3549 "DOM.resolveNode" => json!({"object": {"objectId": "obj-1"}}),
3550 "DOM.getBoxModel" => {
3551 json!({"model": {"border": [10, 30, 110, 30, 110, 50, 10, 50]}})
3552 }
3553 "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
3554 "Page.getLayoutMetrics" => json!({"cssLayoutViewport": {
3555 "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600
3556 }}),
3557 _ => json!({}),
3558 };
3559 let resp = json!({"id": id, "result": result});
3560 ws.send(Message::Text(resp.to_string())).await.unwrap();
3561 }
3562 }
3563 });
3564 A11yMock {
3565 endpoint: format!("ws://{addr}"),
3566 doc_token,
3567 requests,
3568 }
3569 }
3570
3571 #[tokio::test]
3572 async fn snapshot_then_click_by_ref_dispatches_native_input() {
3573 use std::sync::atomic::Ordering;
3574 let mock = spawn_a11y_mock().await;
3575 let state = ServerState::new(ResolvedBrowser {
3576 engine: Engine::Cdp,
3577 endpoint: mock.endpoint.clone(),
3578 source: Source::External,
3579 });
3580 let route = json!({"target": "example\\.com"});
3581
3582 let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3583 .await
3584 .unwrap();
3585 let text = snap["content"][0]["text"].as_str().unwrap();
3586 assert_eq!(
3587 text,
3588 "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3589 );
3590
3591 let again = handler_for("browser_snapshot")(state.clone(), route.clone())
3593 .await
3594 .unwrap();
3595 assert_eq!(again["content"][0]["text"], snap["content"][0]["text"]);
3596
3597 let mut args = route.clone();
3598 args["ref"] = json!("e1");
3599 let out = handler_for("browser_click")(state.clone(), args.clone())
3600 .await
3601 .unwrap();
3602 assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3603 {
3604 let reqs = mock.requests.lock().await;
3605 let mouse: Vec<&Value> = reqs
3606 .iter()
3607 .filter(|r| r["method"] == "Input.dispatchMouseEvent")
3608 .collect();
3609 assert_eq!(mouse.len(), 3);
3610 assert_eq!(mouse[1]["params"]["type"], "mousePressed");
3611 assert_eq!(mouse[1]["params"]["x"], 30.0);
3612 assert_eq!(mouse[1]["params"]["y"], 20.0);
3613 assert!(reqs
3614 .iter()
3615 .any(|r| r["method"] == "DOM.scrollIntoViewIfNeeded"
3616 && r["params"]["backendNodeId"] == 106));
3617 }
3618
3619 let mut find_args = route.clone();
3621 find_args["query"] = json!("docs");
3622 let out = handler_for("browser_find")(state.clone(), find_args)
3623 .await
3624 .unwrap();
3625 assert_eq!(
3626 out["content"][0]["text"],
3627 "1 match for \"docs\":\ne2 link \"Docs\"\n"
3628 );
3629
3630 let mut bad = route.clone();
3632 bad["ref"] = json!("e9");
3633 let err = handler_for("browser_click")(state.clone(), bad)
3634 .await
3635 .unwrap_err();
3636 assert!(matches!(
3637 err.downcast_ref::<SessionError>(),
3638 Some(SessionError::RefUnknown { .. })
3639 ));
3640
3641 mock.doc_token.store(202, Ordering::SeqCst);
3643 let err = handler_for("browser_click")(state.clone(), args.clone())
3644 .await
3645 .unwrap_err();
3646 match err.downcast_ref::<SessionError>() {
3647 Some(SessionError::StaleRef { reason, .. }) => assert_eq!(*reason, "document changed"),
3648 other => panic!("expected StaleRef, got {other:?}"),
3649 }
3650 assert!(err.to_string().contains("browser_snapshot"));
3651 let err = handler_for("browser_click")(state.clone(), args)
3653 .await
3654 .unwrap_err();
3655 assert!(matches!(
3656 err.downcast_ref::<SessionError>(),
3657 Some(SessionError::RefUnknown { .. })
3658 ));
3659 }
3660
3661 #[tokio::test]
3662 async fn type_by_ref_inserts_text_and_submits() {
3663 let mock = spawn_a11y_mock().await;
3664 let state = ServerState::new(ResolvedBrowser {
3665 engine: Engine::Cdp,
3666 endpoint: mock.endpoint.clone(),
3667 source: Source::External,
3668 });
3669 let route = json!({"target": "example\\.com"});
3670 handler_for("browser_snapshot")(state.clone(), route.clone())
3671 .await
3672 .unwrap();
3673 let mut args = route.clone();
3674 args["ref"] = json!("e1");
3675 args["text"] = json!("hello");
3676 args["submit"] = json!(true);
3677 let out = handler_for("browser_type")(state.clone(), args)
3678 .await
3679 .unwrap();
3680 assert_eq!(
3681 out["content"][0]["text"],
3682 "typed into e1 (button \"Submit\") and pressed Enter"
3683 );
3684 let reqs = mock.requests.lock().await;
3685 let insert = reqs
3686 .iter()
3687 .find(|r| r["method"] == "Input.insertText")
3688 .expect("insertText");
3689 assert_eq!(insert["params"]["text"], "hello");
3690 assert!(reqs
3691 .iter()
3692 .any(|r| r["method"] == "Input.dispatchKeyEvent" && r["params"]["key"] == "Enter"));
3693 assert!(state.sidecar.lock().await.is_none());
3695 }
3696
3697 async fn spawn_capture_mock() -> String {
3701 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3702 let addr = listener.local_addr().unwrap();
3703 tokio::spawn(async move {
3704 let (stream, _) = listener.accept().await.unwrap();
3705 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3706 let mut next_session = 0u32;
3707 while let Some(Ok(Message::Text(t))) = ws.next().await {
3708 let req: Value = serde_json::from_str(&t).unwrap();
3709 let id = req["id"].as_u64().unwrap();
3710 let method = req["method"].as_str().unwrap_or("").to_string();
3711 let sid = req["sessionId"].as_str().unwrap_or("").to_string();
3712 let result = match method.as_str() {
3713 "Target.getTargets" => json!({"targetInfos": [{
3714 "targetId": "T1", "type": "page",
3715 "url": "https://app.test/", "title": "App",
3716 }]}),
3717 "Target.attachToTarget" => {
3718 next_session += 1;
3719 json!({"sessionId": format!("S{next_session}")})
3720 }
3721 "Runtime.evaluate" => json!({"result": {"value": 1}}),
3722 "Page.getNavigationHistory" => json!({
3723 "currentIndex": 0, "entries": [{"url": "https://app.test/"}]
3724 }),
3725 "Network.getResponseBody" => {
3726 json!({"body": "{\"ok\":true}", "base64Encoded": false})
3727 }
3728 _ => json!({}),
3729 };
3730 ws.send(Message::Text(
3731 json!({"id": id, "result": result}).to_string(),
3732 ))
3733 .await
3734 .unwrap();
3735 if method == "Network.enable" {
3736 for ev in [
3737 json!({"method": "Runtime.consoleAPICalled", "sessionId": sid,
3738 "params": {"type": "error", "timestamp": 1756816496120.0,
3739 "args": [{"type": "string", "value": "boom"}],
3740 "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3741 json!({"method": "Network.requestWillBeSent", "sessionId": sid,
3742 "params": {"requestId": "9.1", "timestamp": 10.0, "wallTime": 1756816496.0, "type": "XHR",
3743 "request": {"method": "GET", "url": "https://app.test/api/me"}}}),
3744 json!({"method": "Network.responseReceived", "sessionId": sid,
3745 "params": {"requestId": "9.1", "type": "XHR",
3746 "response": {"status": 401, "mimeType": "application/json"}}}),
3747 json!({"method": "Network.loadingFinished", "sessionId": sid,
3748 "params": {"requestId": "9.1", "timestamp": 10.084, "encodedDataLength": 11}}),
3749 ] {
3750 ws.send(Message::Text(ev.to_string())).await.unwrap();
3751 }
3752 }
3753 }
3754 });
3755 format!("ws://{addr}")
3756 }
3757
3758 #[tokio::test]
3759 async fn capture_tools_read_buffered_console_and_network_after_navigate() {
3760 let endpoint = spawn_capture_mock().await;
3761 let state = ServerState::new(ResolvedBrowser {
3762 engine: Engine::Cdp,
3763 endpoint,
3764 source: Source::External,
3765 });
3766 let route = json!({"target": "app\\.test"});
3767 let mut nav = route.clone();
3768 nav["url"] = json!("https://app.test/x");
3769 handler_for("browser_navigate")(state.clone(), nav)
3770 .await
3771 .unwrap();
3772
3773 let mut text = String::new();
3776 for _ in 0..100 {
3777 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3778 .await
3779 .unwrap();
3780 text = out["content"][0]["text"].as_str().unwrap().to_string();
3781 if text.contains("boom") {
3782 break;
3783 }
3784 tokio::time::sleep(Duration::from_millis(10)).await;
3785 }
3786 assert!(
3787 text.starts_with("console tab=T1 page=https://app.test/ showing 1 of 1 matched (1 buffered, 0 evicted, 0 events lost)\n-- page: https://app.test/ --\n[error] 2025-09-02T12:34:56.120Z https://app.test/a.js:4:1 boom"),
3788 "{text}"
3789 );
3790
3791 let mut q = route.clone();
3793 q["pattern"] = json!("nomatch");
3794 let out = handler_for("browser_console_messages")(state.clone(), q)
3795 .await
3796 .unwrap();
3797 assert!(out["content"][0]["text"]
3798 .as_str()
3799 .unwrap()
3800 .contains("showing 0 of 0 matched (1 buffered"));
3801 let mut q = route.clone();
3802 q["pattern"] = json!("[");
3803 let err = handler_for("browser_console_messages")(state.clone(), q)
3804 .await
3805 .unwrap_err();
3806 assert!(err.to_string().contains("invalid `pattern` regex"));
3807 let mut q = route.clone();
3808 q["limit"] = json!(0);
3809 q["clear"] = json!(true);
3810 handler_for("browser_console_messages")(state.clone(), q)
3811 .await
3812 .unwrap();
3813 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3814 .await
3815 .unwrap();
3816 assert!(out["content"][0]["text"]
3817 .as_str()
3818 .unwrap()
3819 .contains("(0 buffered"));
3820
3821 let mut q = route.clone();
3823 q["status"] = json!("4xx");
3824 q["url_pattern"] = json!("/api/");
3825 let out = handler_for("browser_network_requests")(state.clone(), q)
3826 .await
3827 .unwrap();
3828 let text = out["content"][0]["text"].as_str().unwrap();
3829 assert!(
3830 text.contains(
3831 "9.1 GET https://app.test/api/me → 401 application/json 11B 84ms [XHR]"
3832 ),
3833 "{text}"
3834 );
3835 let mut q = route.clone();
3836 q["status"] = json!("2xx");
3837 let out = handler_for("browser_network_requests")(state.clone(), q)
3838 .await
3839 .unwrap();
3840 assert!(out["content"][0]["text"]
3841 .as_str()
3842 .unwrap()
3843 .contains("showing 0 of 0 matched (1 buffered"));
3844 let mut q = route.clone();
3845 q["format"] = json!("json");
3846 let out = handler_for("browser_network_requests")(state.clone(), q)
3847 .await
3848 .unwrap();
3849 let parsed: Value =
3850 serde_json::from_str(out["content"][0]["text"].as_str().unwrap()).unwrap();
3851 assert_eq!(parsed["entries"][0]["request_id"], "9.1");
3852 assert_eq!(parsed["entries"][0]["state"], "finished");
3853
3854 let mut q = route.clone();
3856 q["request_id"] = json!("9.1");
3857 let out = handler_for("browser_network_body")(state.clone(), q)
3858 .await
3859 .unwrap();
3860 assert_eq!(out["content"][0]["text"], "{\"ok\":true}");
3861 let meta: Value =
3862 serde_json::from_str(out["content"][1]["text"].as_str().unwrap()).unwrap();
3863 assert_eq!(meta["status"], 401);
3864 assert_eq!(meta["truncated"], false);
3865
3866 assert_eq!(state.capture.captured_tabs(), vec!["T1".to_string()]);
3869 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
3870 .await
3871 .unwrap();
3872 assert!(state.capture.captured_tabs().is_empty());
3873 }
3874
3875 #[tokio::test]
3876 async fn capture_tools_validate_before_backend_and_gate_on_engine() {
3877 let h = handler_for("browser_network_requests");
3878 let err = h(unreached_state(), json!({"status": "lots"}))
3879 .await
3880 .expect_err("must error");
3881 assert!(err.to_string().contains("`status` must be"), "{err:#}");
3882 let h = handler_for("browser_network_body");
3883 let err = h(unreached_state(), json!({}))
3884 .await
3885 .expect_err("must error");
3886 assert!(err.to_string().contains("missing 'request_id'"), "{err:#}");
3887
3888 let state = ServerState::new(ResolvedBrowser {
3891 engine: Engine::Bidi,
3892 endpoint: "ws://127.0.0.1:0".into(),
3893 source: Source::External,
3894 });
3895 let err = handler_for("browser_network_body")(state.clone(), json!({"request_id": "1"}))
3896 .await
3897 .expect_err("BiDi must error");
3898 match err.downcast_ref::<SessionError>() {
3899 Some(SessionError::EngineUnsupported { tool, hint, .. }) => {
3900 assert_eq!(tool, "browser_network_body");
3901 assert!(hint.contains("browser_fetch") && hint.contains("browser_select"));
3902 }
3903 other => panic!("expected EngineUnsupported, got {other:?}"),
3904 }
3905 for tool in ["browser_console_messages", "browser_network_requests"] {
3906 let err = handler_for(tool)(state.clone(), json!({}))
3907 .await
3908 .expect_err("unreachable endpoint must error");
3909 assert!(
3910 !matches!(
3911 err.downcast_ref::<SessionError>(),
3912 Some(SessionError::EngineUnsupported { .. })
3913 ),
3914 "{tool} must not be engine-gated on BiDi: {err:#}"
3915 );
3916 }
3917 }
3918
3919 async fn spawn_bidi_capture_mock() -> String {
3923 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3924 let addr = listener.local_addr().unwrap();
3925 tokio::spawn(async move {
3926 let (stream, _) = listener.accept().await.unwrap();
3927 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3928 while let Some(Ok(Message::Text(t))) = ws.next().await {
3929 let req: Value = serde_json::from_str(&t).unwrap();
3930 let id = req["id"].as_u64().unwrap();
3931 let method = req["method"].as_str().unwrap_or("").to_string();
3932 let result = match method.as_str() {
3933 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3934 "browsingContext.getTree" => json!({"contexts": [
3935 {"context": "C1", "url": "https://app.test/", "children": []}
3936 ]}),
3937 "browsingContext.navigate" => {
3938 json!({"navigation": "N1", "url": "https://app.test/x"})
3939 }
3940 "script.evaluate" => {
3941 json!({"type": "success", "result": {"type": "number", "value": 1}})
3942 }
3943 _ => json!({}),
3944 };
3945 ws.send(Message::Text(
3946 json!({"type": "success", "id": id, "result": result}).to_string(),
3947 ))
3948 .await
3949 .unwrap();
3950 let first_event = req["params"]["events"][0].as_str().unwrap_or("");
3951 if method == "session.subscribe" && first_event.starts_with("network.") {
3952 for ev in [
3953 json!({"type": "event", "method": "log.entryAdded", "params": {
3954 "type": "console", "level": "error", "method": "error", "text": "boom",
3955 "timestamp": 1756816496120.0, "source": {"context": "C1"},
3956 "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3957 json!({"type": "event", "method": "network.beforeRequestSent", "params": {
3958 "context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1756816496000.0,
3959 "initiator": {"type": "other"},
3960 "request": {"request": "9.1", "url": "https://app.test/api/me", "method": "GET", "bodySize": 0, "initiatorType": "xmlhttprequest"}}}),
3961 json!({"type": "event", "method": "network.responseCompleted", "params": {
3962 "context": "C1", "timestamp": 1756816496084.0, "redirectCount": 0,
3963 "request": {"request": "9.1"},
3964 "response": {"status": 401, "mimeType": "application/json", "bytesReceived": 11}}}),
3965 ] {
3966 ws.send(Message::Text(ev.to_string())).await.unwrap();
3967 }
3968 }
3969 }
3970 });
3971 format!("ws://{addr}")
3972 }
3973
3974 #[tokio::test]
3975 async fn capture_tools_work_on_bidi_and_body_is_cdp_only() {
3976 let endpoint = spawn_bidi_capture_mock().await;
3977 let state = ServerState::new(ResolvedBrowser {
3978 engine: Engine::Bidi,
3979 endpoint,
3980 source: Source::External,
3981 });
3982 let route = json!({"target": "app\\.test"});
3983 let mut nav = route.clone();
3984 nav["url"] = json!("https://app.test/x");
3985 handler_for("browser_navigate")(state.clone(), nav)
3986 .await
3987 .unwrap();
3988 let mut text = String::new();
3989 for _ in 0..100 {
3990 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3991 .await
3992 .unwrap();
3993 text = out["content"][0]["text"].as_str().unwrap().to_string();
3994 if text.contains("boom") {
3995 break;
3996 }
3997 tokio::time::sleep(Duration::from_millis(10)).await;
3998 }
3999 assert!(
4000 text.starts_with("console tab=C1 page=https://app.test/ showing 1 of 1 matched (1 buffered, 0 evicted, 0 events lost)\n-- page: https://app.test/ --\n[error] 2025-09-02T12:34:56.120Z https://app.test/a.js:4:1 boom"),
4001 "{text}"
4002 );
4003 let mut q = route.clone();
4004 q["status"] = json!("4xx");
4005 let out = handler_for("browser_network_requests")(state.clone(), q)
4006 .await
4007 .unwrap();
4008 let net = out["content"][0]["text"].as_str().unwrap();
4009 assert!(
4010 net.contains(
4011 "9.1 GET https://app.test/api/me → 401 application/json 11B 84ms [XHR]"
4012 ),
4013 "{net}"
4014 );
4015 let mut q = route.clone();
4016 q["request_id"] = json!("9.1");
4017 let err = handler_for("browser_network_body")(state.clone(), q)
4018 .await
4019 .unwrap_err();
4020 match err.downcast_ref::<SessionError>() {
4021 Some(SessionError::EngineUnsupported { hint, .. }) => {
4022 assert!(hint.contains("browser_fetch"))
4023 }
4024 other => panic!("expected EngineUnsupported, got {other:?}"),
4025 }
4026 assert_eq!(state.capture.captured_tabs(), vec!["C1".to_string()]);
4027 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "C1"}))
4028 .await
4029 .unwrap();
4030 assert!(state.capture.captured_tabs().is_empty());
4031 }
4032
4033 #[tokio::test]
4034 async fn tab_foreground_requires_registered_chromium_browser() {
4035 let state = ServerState::new(ResolvedBrowser {
4036 engine: Engine::Bidi,
4037 endpoint: "ws://127.0.0.1:0".into(),
4038 source: Source::External,
4039 });
4040 let err = handler_for("browser_tab_foreground")(state, json!({}))
4041 .await
4042 .expect_err("BiDi must error");
4043 match err.downcast_ref::<SessionError>() {
4044 Some(SessionError::EngineUnsupported { hint, .. }) => {
4045 assert!(hint.contains("setFocusEmulationEnabled"))
4046 }
4047 other => panic!("expected EngineUnsupported, got {other:?}"),
4048 }
4049 let mock = spawn_a11y_mock().await;
4050 let state = ServerState::new(ResolvedBrowser {
4051 engine: Engine::Cdp,
4052 endpoint: mock.endpoint.clone(),
4053 source: Source::External,
4054 });
4055 let err = handler_for("browser_tab_foreground")(state.clone(), json!({}))
4056 .await
4057 .expect_err("external endpoint must error");
4058 assert!(err.to_string().contains("registered browser"), "{err:#}");
4059 let err = handler_for("browser_tab_foreground")(
4060 state.clone(),
4061 json!({"enabled": true, "all": true}),
4062 )
4063 .await
4064 .expect_err("all requires enabled false");
4065 assert!(err.to_string().contains("`all` only applies"), "{err:#}");
4066 let list = handler_for("browser_tab_list")(state, json!({}))
4067 .await
4068 .unwrap();
4069 let rows: Value =
4070 serde_json::from_str(list["content"][0]["text"].as_str().unwrap()).unwrap();
4071 assert_eq!(rows[0]["foreground"], false);
4072 }
4073
4074 #[tokio::test]
4075 async fn tab_close_drops_refs() {
4076 let mock = spawn_a11y_mock().await;
4077 let state = ServerState::new(ResolvedBrowser {
4078 engine: Engine::Cdp,
4079 endpoint: mock.endpoint.clone(),
4080 source: Source::External,
4081 });
4082 handler_for("browser_snapshot")(state.clone(), json!({"target": "example\\.com"}))
4083 .await
4084 .unwrap();
4085 assert!(state.refs.lock().await.contains_key("T1"));
4086 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
4087 .await
4088 .unwrap();
4089 assert!(!state.refs.lock().await.contains_key("T1"));
4090 }
4091
4092 #[tokio::test]
4096 async fn sidecar_tool_on_bidi_returns_engine_unsupported() {
4097 use crate::cli::env_resolver::{ResolvedBrowser, Source};
4098 use crate::detect::Engine;
4099 use crate::errors::SessionError;
4100
4101 let resolved = ResolvedBrowser {
4104 engine: Engine::Bidi,
4105 endpoint: "ws://127.0.0.1:0".into(),
4106 source: Source::External,
4107 };
4108 let state = ServerState::new(resolved);
4109
4110 let err = match state.ensure_sidecar("browser_snapshot").await {
4111 Ok(_) => panic!("BiDi must error"),
4112 Err(e) => e,
4113 };
4114 let typed = err.downcast_ref::<SessionError>().expect("typed error");
4115 match typed {
4116 SessionError::EngineUnsupported { tool, hint, .. } => {
4117 assert_eq!(tool, "browser_snapshot");
4118 assert!(!hint.contains(concat!("browser_", "evaluate")));
4119 assert!(hint.contains("browser_get_html"));
4120 assert!(hint.contains("browser_select"));
4121 }
4122 other => panic!("expected EngineUnsupported, got {other:?}"),
4123 }
4124 }
4125
4126 #[test]
4127 fn sidecar_cdp_attach_failure_classifier_matches_connect_layer_errors() {
4128 let err = anyhow::anyhow!(
4129 "browserType.connectOverCDP: Timeout 5000ms exceeded while <ws connecting> to ws://127.0.0.1:64767/devtools/browser/x"
4130 );
4131 assert!(looks_like_sidecar_cdp_attach_failure(&err));
4132
4133 let err = anyhow::anyhow!("page.waitForLoadState: Timeout 30000ms exceeded");
4134 assert!(
4135 !looks_like_sidecar_cdp_attach_failure(&err),
4136 "normal page wait timeouts must not be reclassified as sidecar attach failures"
4137 );
4138 }
4139
4140 #[test]
4141 fn sidecar_connection_failed_message_discourages_page_hang_inference() {
4142 let err = SessionError::SidecarConnectionFailed {
4143 tool: "browser_snapshot".into(),
4144 method: "snapshot".into(),
4145 target_id: "T1".into(),
4146 url: Some("http://localhost:5173/404".into()),
4147 details: "browserType.connectOverCDP: Timeout 5000ms exceeded".into(),
4148 hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
4149 };
4150 let msg = err.to_string();
4151 assert!(msg.contains("Playwright sidecar connection failed"));
4152 assert!(msg.contains("not evidence that the page is hung"));
4153 assert!(msg.contains("browser_get_html"));
4154 }
4155
4156 #[tokio::test]
4157 async fn screenshot_selector_sends_cdp_clip() {
4158 let mock = spawn_screenshot_mock(json!({
4159 "x": 12.5,
4160 "y": 34.0,
4161 "width": 56.0,
4162 "height": 78.0,
4163 }))
4164 .await;
4165 let state = ServerState::new(ResolvedBrowser {
4166 engine: Engine::Cdp,
4167 endpoint: mock.endpoint,
4168 source: Source::External,
4169 });
4170 let h = handler_for("browser_take_screenshot");
4171 let out = h(
4172 state,
4173 json!({
4174 "target": "example\\.com",
4175 "selector": "#main",
4176 }),
4177 )
4178 .await
4179 .unwrap();
4180 assert_eq!(out["content"][0]["type"], "image");
4181 assert_eq!(out["content"][0]["data"], "PNGDATA");
4182
4183 let captures = mock.capture_params.lock().await;
4184 assert_eq!(captures.len(), 1);
4185 assert_eq!(captures[0]["format"], "png");
4186 assert_eq!(captures[0]["captureBeyondViewport"], true);
4187 assert_eq!(captures[0]["clip"]["x"], json!(12.5));
4188 assert_eq!(captures[0]["clip"]["y"], json!(34.0));
4189 assert_eq!(captures[0]["clip"]["width"], json!(56.0));
4190 assert_eq!(captures[0]["clip"]["height"], json!(78.0));
4191 assert_eq!(captures[0]["clip"]["scale"], json!(1));
4192 }
4193
4194 #[tokio::test]
4195 async fn screenshot_jpeg_quality_and_max_width_scale_clip() {
4196 let mock = spawn_screenshot_mock(json!(2.0)).await;
4198 let state = ServerState::new(ResolvedBrowser {
4199 engine: Engine::Cdp,
4200 endpoint: mock.endpoint,
4201 source: Source::External,
4202 });
4203 let out = handler_for("browser_take_screenshot")(
4204 state.clone(),
4205 json!({
4206 "target": "example\\.com",
4207 "format": "jpeg",
4208 "quality": 60,
4209 "max_width": 500,
4210 }),
4211 )
4212 .await
4213 .unwrap();
4214 assert_eq!(out["content"][0]["mimeType"], "image/jpeg");
4215 let captures = mock.capture_params.lock().await;
4216 assert_eq!(captures.len(), 1);
4217 assert_eq!(captures[0]["format"], "jpeg");
4218 assert_eq!(captures[0]["quality"], 60);
4219 assert_eq!(captures[0]["captureBeyondViewport"], true);
4221 assert_eq!(captures[0]["clip"]["x"], json!(0.0));
4222 assert_eq!(captures[0]["clip"]["y"], json!(100.0));
4223 assert_eq!(captures[0]["clip"]["width"], json!(1000.0));
4224 assert_eq!(captures[0]["clip"]["height"], json!(500.0));
4225 assert_eq!(captures[0]["clip"]["scale"], json!(0.25));
4226 }
4227
4228 #[tokio::test]
4229 async fn screenshot_max_width_larger_than_page_keeps_default_params() {
4230 let mock = spawn_screenshot_mock(json!(1.0)).await;
4231 let state = ServerState::new(ResolvedBrowser {
4232 engine: Engine::Cdp,
4233 endpoint: mock.endpoint,
4234 source: Source::External,
4235 });
4236 handler_for("browser_take_screenshot")(
4237 state,
4238 json!({"target": "example\\.com", "max_width": 4000, "full_page": true}),
4239 )
4240 .await
4241 .unwrap();
4242 let captures = mock.capture_params.lock().await;
4243 assert_eq!(captures[0]["format"], "png");
4244 assert!(captures[0].get("clip").is_none());
4245 assert!(captures[0].get("quality").is_none());
4246 assert_eq!(captures[0]["captureBeyondViewport"], true);
4247 }
4248
4249 #[tokio::test]
4250 async fn screenshot_save_to_writes_private_file_and_reports_dimensions() {
4251 use base64::Engine as _;
4252 let data = base64::engine::general_purpose::STANDARD.encode(fake_png_1280x720());
4253 let mock = spawn_screenshot_mock_with_data(Value::Null, data).await;
4254 let state = ServerState::new(ResolvedBrowser {
4255 engine: Engine::Cdp,
4256 endpoint: mock.endpoint,
4257 source: Source::External,
4258 });
4259 let dir = tempfile::TempDir::new().unwrap();
4260 let path = dir.path().join("shot.png");
4261 let out = handler_for("browser_take_screenshot")(
4262 state,
4263 json!({"target": "example\\.com", "save_to": path.to_str().unwrap()}),
4264 )
4265 .await
4266 .unwrap();
4267 assert_eq!(out["content"][0]["type"], "text");
4268 let text = out["content"][0]["text"].as_str().unwrap();
4269 assert!(
4270 text.starts_with(&format!(
4271 "Saved screenshot to {} (1280x720, image/png, 1 KiB)",
4272 path.display()
4273 )),
4274 "{text}"
4275 );
4276 let bytes = std::fs::read(&path).unwrap();
4277 assert_eq!(bytes, fake_png_1280x720());
4278 #[cfg(unix)]
4279 {
4280 use std::os::unix::fs::PermissionsExt;
4281 assert_eq!(
4282 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4283 0o600
4284 );
4285 }
4286 }
4287
4288 #[tokio::test]
4289 async fn screenshot_option_validation_fires_before_backend() {
4290 let h = handler_for("browser_take_screenshot");
4291 for (args, needle) in [
4292 (json!({"quality": 50}), "only applies to"),
4293 (json!({"format": "gif"}), "`format` must be"),
4294 (json!({"max_width": 10}), "at least 64"),
4295 (json!({"save_to": "relative.png"}), "absolute path"),
4296 (
4297 json!({"save_to": std::env::temp_dir().join("definitely/missing/dir/x.png")}),
4299 "parent directory",
4300 ),
4301 (json!({"selector": "#a", "ref": "e1"}), "mutually exclusive"),
4302 ] {
4303 let err = h(unreached_state(), args.clone())
4304 .await
4305 .expect_err("must error");
4306 assert!(err.to_string().contains(needle), "{args}: {err:#}");
4307 }
4308 }
4309
4310 #[tokio::test]
4311 async fn screenshot_by_ref_clips_to_node_box() {
4312 let mock = spawn_a11y_mock().await;
4313 let state = ServerState::new(ResolvedBrowser {
4314 engine: Engine::Cdp,
4315 endpoint: mock.endpoint.clone(),
4316 source: Source::External,
4317 });
4318 let route = json!({"target": "example\\.com"});
4319 handler_for("browser_snapshot")(state.clone(), route.clone())
4320 .await
4321 .unwrap();
4322 let mut args = route.clone();
4323 args["ref"] = json!("e1");
4324 let out = handler_for("browser_take_screenshot")(state.clone(), args)
4325 .await
4326 .unwrap();
4327 assert_eq!(out["content"][0]["type"], "image");
4328 let reqs = mock.requests.lock().await;
4329 let cap = reqs
4330 .iter()
4331 .find(|r| r["method"] == "Page.captureScreenshot")
4332 .expect("capture");
4333 assert_eq!(cap["params"]["clip"]["x"], json!(10.0));
4334 assert_eq!(cap["params"]["clip"]["y"], json!(30.0));
4335 assert_eq!(cap["params"]["clip"]["width"], json!(100.0));
4336 assert_eq!(cap["params"]["clip"]["height"], json!(20.0));
4337 assert!(reqs
4338 .iter()
4339 .any(|r| r["method"] == "DOM.getBoxModel" && r["params"]["backendNodeId"] == 106));
4340 }
4341
4342 #[tokio::test]
4343 async fn get_page_text_formats_result_and_truncation() {
4344 let payload = json!({
4345 "title": "Docs",
4346 "url": "https://example.com/docs",
4347 "source": "main",
4348 "text": "# Welcome\nHello world",
4349 "truncated": true,
4350 "total_chars": 12345,
4351 })
4352 .to_string();
4353 let mock = spawn_screenshot_mock(Value::String(payload)).await;
4354 let state = ServerState::new(ResolvedBrowser {
4355 engine: Engine::Cdp,
4356 endpoint: mock.endpoint,
4357 source: Source::External,
4358 });
4359 let out = handler_for("browser_get_page_text")(
4360 state,
4361 json!({"target": "example\\.com", "max_chars": 1000}),
4362 )
4363 .await
4364 .unwrap();
4365 assert_eq!(
4366 out["content"][0]["text"],
4367 "Docs\nhttps://example.com/docs\n\n# Welcome\nHello world\n… [truncated at 1000 of 12345 chars; pass max_chars or selector to narrow]"
4368 );
4369
4370 let mock = spawn_screenshot_mock(Value::String(
4371 json!({"error": "selector matched no element: #x"}).to_string(),
4372 ))
4373 .await;
4374 let state = ServerState::new(ResolvedBrowser {
4375 engine: Engine::Cdp,
4376 endpoint: mock.endpoint,
4377 source: Source::External,
4378 });
4379 let err = handler_for("browser_get_page_text")(
4380 state,
4381 json!({"target": "example\\.com", "selector": "#x"}),
4382 )
4383 .await
4384 .unwrap_err();
4385 assert!(err.to_string().contains("selector matched no element"));
4386
4387 let err = handler_for("browser_get_page_text")(unreached_state(), json!({"max_chars": 10}))
4388 .await
4389 .unwrap_err();
4390 assert!(err.to_string().contains("at least 500"));
4391 }
4392
4393 #[tokio::test]
4394 async fn screenshot_selector_null_rect_errors_clearly() {
4395 let mock = spawn_screenshot_mock(Value::Null).await;
4396 let state = ServerState::new(ResolvedBrowser {
4397 engine: Engine::Cdp,
4398 endpoint: mock.endpoint,
4399 source: Source::External,
4400 });
4401 let h = handler_for("browser_take_screenshot");
4402 let err = h(
4403 state,
4404 json!({
4405 "target": "example\\.com",
4406 "selector": "#missing",
4407 }),
4408 )
4409 .await
4410 .expect_err("null selector rect must error");
4411 assert!(
4412 err.to_string()
4413 .contains("selector matched no visible element: #missing"),
4414 "got: {err:#}"
4415 );
4416 assert!(mock.capture_params.lock().await.is_empty());
4417 }
4418
4419 use crate::cli::env_resolver::{ResolvedBrowser, Source};
4427 use crate::detect::Engine;
4428
4429 fn handler_for(name: &str) -> ToolHandler {
4431 let registry = ToolRegistry::new();
4432 register_all(®istry);
4433 registry
4434 .handler(name)
4435 .unwrap_or_else(|| panic!("tool {name} not registered"))
4436 }
4437
4438 fn unreached_state() -> ServerState {
4442 ServerState::new(ResolvedBrowser {
4443 engine: Engine::Cdp,
4444 endpoint: "ws://127.0.0.1:0".into(),
4448 source: Source::External,
4449 })
4450 }
4451
4452 #[tokio::test]
4453 async fn navigate_missing_url_errors_before_backend() {
4454 let h = handler_for("browser_navigate");
4455 let err = h(unreached_state(), json!({}))
4456 .await
4457 .expect_err("missing url must error");
4458 assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4459 }
4460
4461 #[tokio::test]
4462 async fn fetch_missing_url_errors_before_backend() {
4463 let h = handler_for("browser_fetch");
4464 let err = h(unreached_state(), json!({"method": "GET"}))
4465 .await
4466 .expect_err("missing url must error");
4467 assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4468 }
4469
4470 #[tokio::test]
4471 async fn curl_missing_args_errors_before_backend() {
4472 let h = handler_for("browser_curl");
4473 let err = h(unreached_state(), json!({}))
4474 .await
4475 .expect_err("missing args must error");
4476 assert!(err.to_string().contains("'args'"), "got: {err:#}");
4477 }
4478
4479 #[tokio::test]
4480 async fn curl_rejects_non_string_args_before_backend() {
4481 let h = handler_for("browser_curl");
4482 let err = h(unreached_state(), json!({"args": ["-L", 7]}))
4483 .await
4484 .expect_err("non-string args must error");
4485 assert!(
4486 err.to_string()
4487 .contains("every curl argument must be a string"),
4488 "got: {err:#}"
4489 );
4490 }
4491
4492 #[tokio::test]
4493 async fn storage_set_missing_value_errors_before_backend() {
4494 let h = handler_for("browser_storage_set");
4495 let err = h(unreached_state(), json!({"key": "k"}))
4496 .await
4497 .expect_err("missing value must error");
4498 assert!(err.to_string().contains("missing 'value'"), "got: {err:#}");
4499 }
4500
4501 #[tokio::test]
4502 async fn storage_get_missing_key_errors_before_backend() {
4503 let h = handler_for("browser_storage_get");
4504 let err = h(unreached_state(), json!({}))
4505 .await
4506 .expect_err("missing key must error");
4507 assert!(err.to_string().contains("missing 'key'"), "got: {err:#}");
4508 }
4509
4510 #[tokio::test]
4513 async fn navigate_tab_and_target_mutually_exclusive() {
4514 let h = handler_for("browser_navigate");
4515 let err = h(
4516 unreached_state(),
4517 json!({"url": "https://e.test/", "tab": "a", "target": "b"}),
4518 )
4519 .await
4520 .expect_err("tab+target must error");
4521 assert!(
4522 err.to_string().contains("mutually exclusive"),
4523 "got: {err:#}"
4524 );
4525 }
4526}