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 PressKey,
2322}
2323
2324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2326enum Route {
2327 Native,
2328 Sidecar,
2329}
2330
2331fn route_mode(args: &Value, ref_pairs: &[(&str, &str)], has_native: bool) -> Result<Route> {
2335 if ref_pairs.is_empty() {
2336 return Ok(if has_native {
2340 Route::Native
2341 } else {
2342 Route::Sidecar
2343 });
2344 }
2345 let mut native = 0;
2346 let mut sidecar = 0;
2347 for (sel, r) in ref_pairs {
2348 let has_sel = args.get(*sel).is_some_and(Value::is_string);
2349 let has_ref = args.get(*r).is_some_and(Value::is_string);
2350 match (has_sel, has_ref) {
2351 (true, true) => {
2352 return Err(anyhow!("`{sel}` and `{r}` are mutually exclusive; pass one of them"))
2353 }
2354 (false, false) => {
2355 return Err(anyhow!(
2356 "exactly one of `{sel}` (CSS selector) or `{r}` (ref from browser_snapshot/browser_find) is required"
2357 ))
2358 }
2359 (true, false) => sidecar += 1,
2360 (false, true) => native += 1,
2361 }
2362 }
2363 if native > 0 && sidecar > 0 {
2364 return Err(anyhow!(
2365 "use refs for every element or selectors for every element, not a mix"
2366 ));
2367 }
2368 Ok(if native > 0 {
2369 Route::Native
2370 } else {
2371 Route::Sidecar
2372 })
2373}
2374
2375async fn run_native(
2377 state: &ServerState,
2378 tool_name: &str,
2379 action: NativeAction,
2380 args: &Value,
2381) -> Result<Value> {
2382 state.ensure_native_ready(tool_name).await?;
2383 let (backend, target_id) = state.resolve_target_for_args(args).await?;
2384 let timeout = timeout_ms_arg(args, "timeout_ms", MCP_OP_TIMEOUT)?;
2385 let ref_arg = |key: &str| -> Result<String> {
2386 args.get(key)
2387 .and_then(Value::as_str)
2388 .map(String::from)
2389 .ok_or_else(|| anyhow!("missing '{key}'"))
2390 };
2391 match action {
2392 NativeAction::PressKey => {
2393 let spec = args
2394 .get("key")
2395 .and_then(Value::as_str)
2396 .ok_or_else(|| anyhow!("missing 'key'"))?;
2397 let chord = crate::session::keys::parse_chord(spec)?;
2398 backend
2399 .press_key_on_tab(&target_id, &chord, timeout)
2400 .await?;
2401 Ok(text_content(format!("pressed {spec}")))
2402 }
2403 NativeAction::Click => {
2404 let r = ref_arg("ref")?;
2405 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2406 backend
2407 .click_node(&target_id, entry.backend_node_id, timeout)
2408 .await
2409 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2410 Ok(text_content(format!(
2411 "clicked {r} ({})",
2412 describe_ref(&entry)
2413 )))
2414 }
2415 NativeAction::Hover => {
2416 let r = ref_arg("ref")?;
2417 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2418 backend
2419 .hover_node(&target_id, entry.backend_node_id, timeout)
2420 .await
2421 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2422 Ok(text_content(format!(
2423 "hovered {r} ({})",
2424 describe_ref(&entry)
2425 )))
2426 }
2427 NativeAction::Type => {
2428 let r = ref_arg("ref")?;
2429 let text = args
2430 .get("text")
2431 .and_then(Value::as_str)
2432 .ok_or_else(|| anyhow!("missing 'text'"))?
2433 .to_string();
2434 let press_sequentially = args
2435 .get("press_sequentially")
2436 .and_then(Value::as_bool)
2437 .unwrap_or(false);
2438 let submit = args.get("submit").and_then(Value::as_bool).unwrap_or(false);
2439 let entry = resolve_ref(state, &backend, &target_id, &r).await?;
2440 backend
2441 .type_into_node(
2442 &target_id,
2443 entry.backend_node_id,
2444 &text,
2445 press_sequentially,
2446 submit,
2447 timeout,
2448 )
2449 .await
2450 .map_err(|e| stale_on_node_gone(e, &r, &target_id))?;
2451 Ok(text_content(format!(
2452 "typed into {r} ({}){}",
2453 describe_ref(&entry),
2454 if submit { " and pressed Enter" } else { "" }
2455 )))
2456 }
2457 NativeAction::Drag => {
2458 let a = ref_arg("source_ref")?;
2459 let b = ref_arg("target_ref")?;
2460 let from = resolve_ref(state, &backend, &target_id, &a).await?;
2461 let to = resolve_ref(state, &backend, &target_id, &b).await?;
2462 backend
2463 .drag_nodes(
2464 &target_id,
2465 from.backend_node_id,
2466 to.backend_node_id,
2467 timeout,
2468 )
2469 .await
2470 .map_err(|e| stale_on_node_gone(e, &a, &target_id))?;
2471 Ok(text_content(format!(
2472 "dragged {a} ({}) to {b} ({})",
2473 describe_ref(&from),
2474 describe_ref(&to)
2475 )))
2476 }
2477 }
2478}
2479
2480struct SidecarParam {
2499 name: &'static str,
2500 schema: Value,
2501 required: bool,
2502}
2503
2504struct SidecarTool {
2511 name: &'static str,
2512 description: &'static str,
2513 method: &'static str,
2515 params: Vec<SidecarParam>,
2516 success: &'static str,
2518 native: Option<NativeAction>,
2520 ref_pairs: &'static [(&'static str, &'static str)],
2522}
2523
2524const REF_PARAM_DESC: &str = "Element ref from browser_snapshot or browser_find (e.g. \"e12\"); \
2525 handled natively on Chromium and Firefox, no Node needed. Mutually exclusive with \
2526 the CSS selector; exactly one is required.";
2527
2528impl SidecarTool {
2529 fn build(self) -> RegisteredTool {
2530 let SidecarTool {
2531 name,
2532 description,
2533 method,
2534 params,
2535 success,
2536 native,
2537 ref_pairs,
2538 } = self;
2539
2540 let extra = Value::Object(
2543 params
2544 .iter()
2545 .map(|p| (p.name.to_string(), p.schema.clone()))
2546 .collect(),
2547 );
2548 let required: Vec<&str> = params
2549 .iter()
2550 .filter(|p| p.required)
2551 .map(|p| p.name)
2552 .collect();
2553 let mut input_schema = json!({
2554 "type": "object",
2555 "properties": tab_args_properties(extra),
2556 });
2557 if !required.is_empty() {
2558 input_schema["required"] = json!(required);
2559 }
2560
2561 let param_names: Vec<&'static str> = params
2564 .iter()
2565 .map(|p| p.name)
2566 .filter(|n| !ref_pairs.iter().any(|(_, r)| r == n))
2567 .collect();
2568 RegisteredTool {
2569 name: name.into(),
2570 description: description.into(),
2571 input_schema,
2572 handler: handler(move |state, args| {
2573 let param_names = param_names.clone();
2574 Box::pin(async move {
2575 match (route_mode(&args, ref_pairs, native.is_some())?, native) {
2576 (Route::Native, Some(action)) => {
2577 run_native(&state, name, action, &args).await
2578 }
2579 (Route::Native, None) => Err(anyhow!("{name} has no native path")),
2580 (Route::Sidecar, _) => {
2581 let mut params = serde_json::Map::new();
2582 for key in ¶m_names {
2583 copy_arg(&args, key, &mut params);
2584 }
2585 forward_to_sidecar(&state, name, &args, method, params).await?;
2586 Ok(text_content(success))
2587 }
2588 }
2589 })
2590 }),
2591 }
2592 }
2593}
2594
2595fn make_click() -> RegisteredTool {
2596 SidecarTool {
2597 name: "browser_click",
2598 description: "Click an element by `ref` (from browser_snapshot/browser_find; native, \
2599 Chromium and Firefox) or by CSS `selector` (Playwright sidecar, Chromium).",
2600 method: "click",
2601 params: vec![
2602 SidecarParam {
2603 name: "selector",
2604 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2605 required: false,
2606 },
2607 SidecarParam {
2608 name: "ref",
2609 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2610 required: false,
2611 },
2612 SidecarParam {
2613 name: "timeout_ms",
2614 schema: json!({"type": "integer"}),
2615 required: false,
2616 },
2617 ],
2618 success: "clicked",
2619 native: Some(NativeAction::Click),
2620 ref_pairs: &[("selector", "ref")],
2621 }
2622 .build()
2623}
2624
2625fn make_type() -> RegisteredTool {
2626 SidecarTool {
2627 name: "browser_type",
2628 description: "Replace the content of an input with `text`, addressed by `ref` (native, \
2629 Chromium and Firefox) or CSS `selector` (Playwright sidecar, Chromium). \
2630 `press_sequentially=true` sends one character at a time; `submit=true` \
2631 presses Enter afterwards.",
2632 method: "type",
2633 params: vec![
2634 SidecarParam {
2635 name: "selector",
2636 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2637 required: false,
2638 },
2639 SidecarParam {
2640 name: "ref",
2641 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2642 required: false,
2643 },
2644 SidecarParam {
2645 name: "text",
2646 schema: json!({"type": "string"}),
2647 required: true,
2648 },
2649 SidecarParam {
2650 name: "press_sequentially",
2651 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."}),
2652 required: false,
2653 },
2654 SidecarParam {
2655 name: "submit",
2656 schema: json!({"type": "boolean", "description": "Press Enter after typing."}),
2657 required: false,
2658 },
2659 SidecarParam {
2660 name: "timeout_ms",
2661 schema: json!({"type": "integer"}),
2662 required: false,
2663 },
2664 ],
2665 success: "typed",
2666 native: Some(NativeAction::Type),
2667 ref_pairs: &[("selector", "ref")],
2668 }
2669 .build()
2670}
2671
2672fn make_hover() -> RegisteredTool {
2673 SidecarTool {
2674 name: "browser_hover",
2675 description: "Hover an element by `ref` (native, Chromium and Firefox) or CSS `selector` \
2676 (Playwright sidecar, Chromium).",
2677 method: "hover",
2678 params: vec![
2679 SidecarParam {
2680 name: "selector",
2681 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `ref`."}),
2682 required: false,
2683 },
2684 SidecarParam {
2685 name: "ref",
2686 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2687 required: false,
2688 },
2689 SidecarParam {
2690 name: "timeout_ms",
2691 schema: json!({"type": "integer"}),
2692 required: false,
2693 },
2694 ],
2695 success: "hovered",
2696 native: Some(NativeAction::Hover),
2697 ref_pairs: &[("selector", "ref")],
2698 }
2699 .build()
2700}
2701
2702fn make_drag() -> RegisteredTool {
2703 SidecarTool {
2704 name: "browser_drag",
2705 description: "Drag one element onto another, by refs (`source_ref`/`target_ref`, native \
2706 pointer events on Chromium and Firefox) or CSS selectors \
2707 (`source_selector`/`target_selector`, Playwright sidecar, Chromium).",
2708 method: "drag",
2709 params: vec![
2710 SidecarParam {
2711 name: "source_selector",
2712 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `source_ref`."}),
2713 required: false,
2714 },
2715 SidecarParam {
2716 name: "target_selector",
2717 schema: json!({"type": "string", "description": "CSS selector; mutually exclusive with `target_ref`."}),
2718 required: false,
2719 },
2720 SidecarParam {
2721 name: "source_ref",
2722 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2723 required: false,
2724 },
2725 SidecarParam {
2726 name: "target_ref",
2727 schema: json!({"type": "string", "description": REF_PARAM_DESC}),
2728 required: false,
2729 },
2730 ],
2731 success: "dragged",
2732 native: Some(NativeAction::Drag),
2733 ref_pairs: &[
2734 ("source_selector", "source_ref"),
2735 ("target_selector", "target_ref"),
2736 ],
2737 }
2738 .build()
2739}
2740
2741fn make_press_key() -> RegisteredTool {
2742 SidecarTool {
2743 name: "browser_press_key",
2744 description: "Press a keyboard key on the focused element, e.g. 'Enter', 'Tab', \
2745 'Escape', 'ArrowDown', 'Control+A'. Modifiers are Control/Ctrl, Shift, \
2746 Alt/Option and Meta/Cmd, joined with '+'. Native on Chromium and Firefox, \
2747 no Node needed.",
2748 method: "press_key",
2749 params: vec![SidecarParam {
2750 name: "key",
2751 schema: json!({"type": "string"}),
2752 required: true,
2753 }],
2754 success: "pressed",
2755 native: Some(NativeAction::PressKey),
2756 ref_pairs: &[],
2757 }
2758 .build()
2759}
2760
2761fn make_wait_for() -> RegisteredTool {
2762 SidecarTool {
2763 name: "browser_wait_for",
2764 description: "Wait for a condition: a selector reaching `state`, a URL matching \
2765 `url_regex`, or the page reaching `load_state` (`load` / \
2766 `domcontentloaded` / `networkidle`). Chromium-only.",
2767 method: "wait_for",
2768 params: vec![
2769 SidecarParam { name: "selector", schema: json!({"type": "string"}), required: false },
2770 SidecarParam { name: "state", schema: json!({"type": "string", "enum": ["attached", "detached", "visible", "hidden"]}), required: false },
2771 SidecarParam { name: "url_regex", schema: json!({"type": "string"}), required: false },
2772 SidecarParam { name: "load_state", schema: json!({"type": "string", "enum": ["load", "domcontentloaded", "networkidle"]}), required: false },
2773 SidecarParam { name: "timeout_ms", schema: json!({"type": "integer"}), required: false },
2774 ],
2775 success: "ok",
2776 native: None,
2777 ref_pairs: &[],
2778 }
2779 .build()
2780}
2781
2782fn make_pdf_save() -> RegisteredTool {
2783 RegisteredTool {
2784 name: "browser_pdf_save".into(),
2785 description: "Render the active page to PDF (base64 in `pdf_base64`). Chromium-only."
2786 .into(),
2787 input_schema: json!({
2788 "type": "object",
2789 "properties": tab_args_schema(),
2790 }),
2791 handler: handler(|state, args| {
2792 Box::pin(async move {
2793 let v = forward_to_sidecar(
2794 &state,
2795 "browser_pdf_save",
2796 &args,
2797 "pdf",
2798 serde_json::Map::new(),
2799 )
2800 .await?;
2801 let b64 = v
2802 .get("pdf_base64")
2803 .and_then(|s| s.as_str())
2804 .unwrap_or_default();
2805 Ok(json!({
2806 "content": [{
2807 "type": "resource",
2808 "resource": { "mimeType": "application/pdf", "blob": b64 }
2809 }]
2810 }))
2811 })
2812 }),
2813 }
2814}
2815
2816fn copy_arg(args: &Value, key: &str, dst: &mut serde_json::Map<String, Value>) {
2818 if let Some(v) = args.get(key) {
2819 dst.insert(key.into(), v.clone());
2820 }
2821}
2822
2823#[cfg(test)]
2824mod tests {
2825 use super::*;
2826 use futures_util::{SinkExt, StreamExt};
2827 use tokio::sync::Mutex;
2828 use tokio_tungstenite::tungstenite::Message;
2829
2830 const EXPECTED_TOOLS: &[&str] = &[
2833 "browser_navigate",
2834 "browser_eval",
2835 "browser_get_html",
2836 "browser_get_page_text",
2837 "browser_take_screenshot",
2838 "browser_fetch",
2839 "browser_curl",
2840 "browser_select_element",
2841 "browser_cookies",
2842 "browser_storage_get",
2843 "browser_storage_set",
2844 "browser_wait_for_cookie",
2845 "browser_console_messages",
2846 "browser_network_requests",
2847 "browser_network_body",
2848 "list_targets",
2849 "browser_tab_list",
2850 "browser_tab_new",
2851 "browser_tab_select",
2852 "browser_tab_close",
2853 "browser_tab_foreground",
2854 "browser_start",
2855 "browser_select",
2856 "browser_list",
2857 "browser_show",
2858 "browser_snapshot",
2859 "browser_find",
2860 "browser_click",
2861 "browser_type",
2862 "browser_hover",
2863 "browser_drag",
2864 "browser_press_key",
2865 "browser_wait_for",
2866 "browser_pdf_save",
2867 ];
2868
2869 fn schema_for(name: &str) -> Value {
2870 let registry = ToolRegistry::new();
2871 register_all(®istry);
2872 registry
2873 .list()
2874 .into_iter()
2875 .find(|t| t["name"] == name)
2876 .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
2877 .clone()
2878 }
2879
2880 fn tool_description(name: &str) -> String {
2881 let registry = ToolRegistry::new();
2882 register_all(®istry);
2883 registry
2884 .list()
2885 .into_iter()
2886 .find(|t| t["name"] == name)
2887 .unwrap_or_else(|| panic!("tool {name} not registered"))["description"]
2888 .as_str()
2889 .unwrap_or("")
2890 .to_string()
2891 }
2892
2893 struct ScreenshotMock {
2894 endpoint: String,
2895 capture_params: Arc<Mutex<Vec<Value>>>,
2896 }
2897
2898 async fn spawn_screenshot_mock(selector_rect: Value) -> ScreenshotMock {
2899 spawn_screenshot_mock_with_data(selector_rect, "PNGDATA".into()).await
2900 }
2901
2902 fn fake_png_1280x720() -> Vec<u8> {
2905 let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2906 png.extend_from_slice(&[0, 0, 0, 13]);
2907 png.extend_from_slice(b"IHDR");
2908 png.extend_from_slice(&1280u32.to_be_bytes());
2909 png.extend_from_slice(&720u32.to_be_bytes());
2910 png
2911 }
2912
2913 async fn spawn_screenshot_mock_with_data(selector_rect: Value, data: String) -> ScreenshotMock {
2917 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2918 let addr = listener.local_addr().unwrap();
2919 let capture_params = Arc::new(Mutex::new(Vec::new()));
2920 tokio::spawn({
2921 let capture_params = capture_params.clone();
2922 async move {
2923 let (stream, _) = listener.accept().await.unwrap();
2924 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2925 let mut next_session = 0u32;
2926 let mut eval_count = 0u32;
2927 while let Some(Ok(Message::Text(t))) = ws.next().await {
2928 let req: Value = serde_json::from_str(&t).unwrap();
2929 let id = req["id"].as_u64().unwrap();
2930 let method = req["method"].as_str().unwrap_or("");
2931 let result = match method {
2932 "Target.getTargets" => json!({
2933 "targetInfos": [{
2934 "targetId": "T1",
2935 "type": "page",
2936 "url": "https://example.com/",
2937 "title": "Example",
2938 }]
2939 }),
2940 "Target.attachToTarget" => {
2941 next_session += 1;
2942 json!({"sessionId": format!("S{next_session}")})
2943 }
2944 "Target.detachFromTarget" => json!({}),
2945 "Inspector.enable" => json!({}),
2946 "Runtime.evaluate" => {
2947 eval_count += 1;
2948 if eval_count == 1 {
2949 json!({"result": {"value": 1}})
2950 } else {
2951 json!({"result": {"value": selector_rect.clone()}})
2952 }
2953 }
2954 "Page.captureScreenshot" => {
2955 capture_params.lock().await.push(req["params"].clone());
2956 json!({"data": data})
2957 }
2958 "Page.getLayoutMetrics" => json!({
2959 "cssLayoutViewport": {"pageX": 0, "pageY": 100, "clientWidth": 1000, "clientHeight": 500},
2960 "cssContentSize": {"width": 1000, "height": 3000},
2961 }),
2962 _ => json!({}),
2963 };
2964 let resp = json!({"id": id, "result": result});
2965 ws.send(Message::Text(resp.to_string())).await.unwrap();
2966 }
2967 }
2968 });
2969 ScreenshotMock {
2970 endpoint: format!("ws://{addr}"),
2971 capture_params,
2972 }
2973 }
2974
2975 #[test]
2976 fn register_all_includes_expected_set() {
2977 let registry = ToolRegistry::new();
2978 register_all(®istry);
2979 let list = registry.list();
2980 let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
2981 for expected in EXPECTED_TOOLS {
2982 assert!(
2983 names.contains(expected),
2984 "missing tool {expected} in {names:?}"
2985 );
2986 }
2987 assert_eq!(
2988 list.len(),
2989 EXPECTED_TOOLS.len(),
2990 "extra tools present: {names:?}"
2991 );
2992 }
2993
2994 #[test]
2995 fn every_tool_has_object_input_schema() {
2996 let registry = ToolRegistry::new();
2997 register_all(®istry);
2998 for t in registry.list() {
2999 let schema = &t["inputSchema"];
3000 assert!(schema.is_object(), "schema not object: {schema}");
3001 assert_eq!(
3002 schema["type"], "object",
3003 "schema type != object for {}: {schema}",
3004 t["name"]
3005 );
3006 }
3007 }
3008
3009 #[test]
3010 fn list_targets_schema_has_optional_filter() {
3011 let schema = schema_for("list_targets");
3012 assert_eq!(schema["properties"]["filter"]["type"], "string");
3013 assert!(
3014 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3015 );
3016 }
3017
3018 #[test]
3019 fn browser_cookies_schema_has_optional_filters() {
3020 let schema = schema_for("browser_cookies");
3021 assert_eq!(schema["properties"]["domain"]["type"], "string");
3022 assert_eq!(schema["properties"]["name"]["type"], "string");
3023 assert!(
3024 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3025 );
3026 }
3027
3028 #[test]
3029 fn browser_eval_requires_expression_and_supports_routing() {
3030 let schema = schema_for("browser_eval");
3031 let required = schema["required"].as_array().expect("required array");
3032 assert!(required.iter().any(|v| v == "expression"));
3033 assert_eq!(schema["properties"]["expression"]["type"], "string");
3034 assert_eq!(schema["properties"]["await_promise"]["type"], "boolean");
3035 assert_eq!(schema["properties"]["timeout_ms"]["type"], "number");
3036 assert_eq!(schema["properties"]["tab"]["type"], "string");
3037 assert_eq!(schema["properties"]["target"]["type"], "string");
3038 }
3039
3040 #[test]
3041 fn browser_storage_get_requires_key() {
3042 let schema = schema_for("browser_storage_get");
3043 let required = schema["required"].as_array().expect("required array");
3044 assert!(required.iter().any(|v| v == "key"));
3045 assert_eq!(schema["properties"]["key"]["type"], "string");
3046 assert_eq!(schema["properties"]["namespace"]["type"], "string");
3047 }
3048
3049 #[test]
3050 fn browser_storage_set_requires_key_and_value() {
3051 let schema = schema_for("browser_storage_set");
3052 let required: Vec<&str> = schema["required"]
3053 .as_array()
3054 .unwrap()
3055 .iter()
3056 .map(|v| v.as_str().unwrap())
3057 .collect();
3058 assert!(required.contains(&"key"));
3059 assert!(required.contains(&"value"));
3060 assert_eq!(schema["properties"]["value"]["type"], "string");
3061 }
3062
3063 #[test]
3064 fn browser_wait_for_cookie_requires_domain_and_name() {
3065 let schema = schema_for("browser_wait_for_cookie");
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(&"domain"));
3073 assert!(required.contains(&"name"));
3074 assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
3075 assert_eq!(
3076 schema["properties"]["poll_interval_seconds"]["type"],
3077 "number"
3078 );
3079 }
3080
3081 #[test]
3082 fn browser_navigate_schema_has_tab_and_target() {
3083 let schema = schema_for("browser_navigate");
3085 assert_eq!(schema["properties"]["tab"]["type"], "string");
3086 assert_eq!(schema["properties"]["target"]["type"], "string");
3087 let required: Vec<&str> = schema["required"]
3088 .as_array()
3089 .unwrap()
3090 .iter()
3091 .map(|v| v.as_str().unwrap())
3092 .collect();
3093 assert!(required.contains(&"url"));
3094 assert!(!required.contains(&"tab"));
3095 assert!(!required.contains(&"target"));
3096 }
3097
3098 #[test]
3099 fn browser_tab_select_requires_target_id() {
3100 let schema = schema_for("browser_tab_select");
3101 let required: Vec<&str> = schema["required"]
3102 .as_array()
3103 .unwrap()
3104 .iter()
3105 .map(|v| v.as_str().unwrap())
3106 .collect();
3107 assert!(required.contains(&"target_id"));
3108 }
3109
3110 #[test]
3111 fn browser_tab_close_target_id_is_optional() {
3112 let schema = schema_for("browser_tab_close");
3114 assert!(
3115 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
3116 );
3117 assert_eq!(schema["properties"]["target_id"]["type"], "string");
3118 }
3119
3120 #[test]
3121 fn browser_select_requires_name() {
3122 let schema = schema_for("browser_select");
3123 let required: Vec<&str> = schema["required"]
3124 .as_array()
3125 .unwrap()
3126 .iter()
3127 .map(|v| v.as_str().unwrap())
3128 .collect();
3129 assert!(required.contains(&"name"));
3130 }
3131
3132 #[test]
3133 fn browser_select_description_documents_failed_lock_contract() {
3134 let desc = tool_description("browser_select");
3135 assert!(desc.contains("committed before"));
3136 assert!(desc.contains("new browser remains active"));
3137 assert!(desc.contains("switch back"));
3138 }
3139
3140 #[test]
3141 fn browser_list_has_no_args() {
3142 let schema = schema_for("browser_list");
3143 assert_eq!(schema["properties"], json!({}));
3144 }
3145
3146 #[test]
3147 fn browser_cookies_schema_has_no_tab_arg() {
3148 let schema = schema_for("browser_cookies");
3150 assert!(schema["properties"].get("tab").is_none());
3151 assert!(schema["properties"].get("target").is_none());
3152 }
3153
3154 #[test]
3157 fn sidecar_tools_expose_tab_and_target() {
3158 for name in &[
3159 "browser_snapshot",
3160 "browser_click",
3161 "browser_type",
3162 "browser_hover",
3163 "browser_drag",
3164 "browser_press_key",
3165 "browser_wait_for",
3166 "browser_pdf_save",
3167 ] {
3168 let schema = schema_for(name);
3169 assert_eq!(
3170 schema["properties"]["tab"]["type"], "string",
3171 "{name} missing tab arg"
3172 );
3173 assert_eq!(
3174 schema["properties"]["target"]["type"], "string",
3175 "{name} missing target arg"
3176 );
3177 }
3178 }
3179
3180 #[test]
3185 fn sidecar_tools_required_args() {
3186 let click = schema_for("browser_click");
3187 assert!(
3188 click.get("required").is_none() || click["required"].as_array().unwrap().is_empty()
3189 );
3190 assert_eq!(click["properties"]["selector"]["type"], "string");
3191 assert_eq!(click["properties"]["ref"]["type"], "string");
3192
3193 let t = schema_for("browser_type");
3194 let req: Vec<&str> = t["required"]
3195 .as_array()
3196 .unwrap()
3197 .iter()
3198 .map(|v| v.as_str().unwrap())
3199 .collect();
3200 assert_eq!(req, vec!["text"]);
3201 assert_eq!(t["properties"]["ref"]["type"], "string");
3202 assert_eq!(t["properties"]["submit"]["type"], "boolean");
3203
3204 let hover = schema_for("browser_hover");
3205 assert_eq!(hover["properties"]["ref"]["type"], "string");
3206 let drag = schema_for("browser_drag");
3207 assert_eq!(drag["properties"]["source_ref"]["type"], "string");
3208 assert_eq!(drag["properties"]["target_ref"]["type"], "string");
3209 assert!(drag.get("required").is_none());
3210
3211 let snap = schema_for("browser_snapshot");
3213 assert!(snap.get("required").is_none() || snap["required"].as_array().unwrap().is_empty());
3214 for key in ["interactive_only", "max_chars", "ref", "depth"] {
3215 assert!(
3216 snap["properties"][key].is_object(),
3217 "snapshot missing {key}"
3218 );
3219 }
3220 let pdf = schema_for("browser_pdf_save");
3221 assert!(pdf.get("required").is_none() || pdf["required"].as_array().unwrap().is_empty());
3222
3223 let find = schema_for("browser_find");
3224 assert_eq!(find["required"], json!(["query"]));
3225 assert_eq!(find["properties"]["tab"]["type"], "string");
3226 }
3227
3228 #[test]
3229 fn route_mode_validates_selector_ref_pairs() {
3230 let pairs = &[("selector", "ref")];
3231 assert_eq!(
3232 route_mode(&json!({"ref": "e1"}), pairs, true).unwrap(),
3233 Route::Native
3234 );
3235 assert_eq!(
3236 route_mode(&json!({"selector": "#x"}), pairs, true).unwrap(),
3237 Route::Sidecar
3238 );
3239 let err = route_mode(&json!({}), pairs, true).unwrap_err().to_string();
3240 assert!(err.contains("exactly one of `selector`"), "{err}");
3241 let err = route_mode(&json!({"selector": "#x", "ref": "e1"}), pairs, true)
3242 .unwrap_err()
3243 .to_string();
3244 assert!(err.contains("mutually exclusive"), "{err}");
3245
3246 let drag = &[
3247 ("source_selector", "source_ref"),
3248 ("target_selector", "target_ref"),
3249 ];
3250 let err = route_mode(
3251 &json!({"source_ref": "e1", "target_selector": "#y"}),
3252 drag,
3253 true,
3254 )
3255 .unwrap_err()
3256 .to_string();
3257 assert!(err.contains("not a mix"), "{err}");
3258 assert_eq!(
3259 route_mode(&json!({"source_ref": "e1", "target_ref": "e2"}), drag, true).unwrap(),
3260 Route::Native
3261 );
3262 assert_eq!(route_mode(&json!({}), &[], false).unwrap(), Route::Sidecar);
3265 assert_eq!(route_mode(&json!({}), &[], true).unwrap(), Route::Native);
3267 }
3268
3269 #[tokio::test]
3270 async fn click_without_selector_or_ref_errors_before_backend() {
3271 let h = handler_for("browser_click");
3272 let err = h(unreached_state(), json!({}))
3273 .await
3274 .expect_err("must error");
3275 assert!(err.to_string().contains("exactly one of"), "got: {err:#}");
3276 let h = handler_for("browser_drag");
3277 let err = h(
3278 unreached_state(),
3279 json!({"source_ref": "e1", "target_selector": "#a"}),
3280 )
3281 .await
3282 .expect_err("must error");
3283 assert!(err.to_string().contains("not a mix"), "got: {err:#}");
3284 }
3285
3286 #[tokio::test]
3287 async fn snapshot_rejects_bad_options_before_backend() {
3288 let h = handler_for("browser_snapshot");
3289 let err = h(unreached_state(), json!({"max_chars": 10}))
3290 .await
3291 .expect_err("must error");
3292 assert!(err.to_string().contains("at least 1000"), "got: {err:#}");
3293 let h = handler_for("browser_find");
3294 let err = h(unreached_state(), json!({"query": " "}))
3295 .await
3296 .expect_err("must error");
3297 assert!(err.to_string().contains("missing 'query'"), "got: {err:#}");
3298 }
3299
3300 struct BidiA11yMock {
3304 endpoint: String,
3305 doc_token: Arc<std::sync::atomic::AtomicU64>,
3306 requests: Arc<Mutex<Vec<Value>>>,
3307 }
3308
3309 async fn spawn_bidi_a11y_mock() -> BidiA11yMock {
3310 use std::sync::atomic::{AtomicU64, Ordering};
3311 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3312 let addr = listener.local_addr().unwrap();
3313 let doc_token = Arc::new(AtomicU64::new(4294967296));
3314 let requests = Arc::new(Mutex::new(Vec::new()));
3315 tokio::spawn({
3316 let doc_token = doc_token.clone();
3317 let requests = requests.clone();
3318 async move {
3319 let (stream, _) = listener.accept().await.unwrap();
3320 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3321 while let Some(Ok(Message::Text(t))) = ws.next().await {
3322 let req: Value = serde_json::from_str(&t).unwrap();
3323 requests.lock().await.push(req.clone());
3324 let id = req["id"].as_u64().unwrap();
3325 let method = req["method"].as_str().unwrap_or("");
3326 let decl = req["params"]["functionDeclaration"].as_str().unwrap_or("");
3327 let expr = req["params"]["expression"].as_str().unwrap_or("");
3328 let string = |s: String| json!({"type": "success", "result": {"type": "string", "value": s}, "realm": "R1"});
3329 let result = match method {
3330 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3331 "browsingContext.getTree" => json!({"contexts": [
3332 {"context": "CTX1", "url": "https://example.com/", "children": []}
3333 ]}),
3334 "browsingContext.captureScreenshot" => json!({"data": "PNGDATA"}),
3335 "script.callFunction" if decl.contains("bc:snapshot") => string(
3336 json!({"nodes": [
3337 {"nodeId": "root", "backendDOMNodeId": 4294967296u64,
3338 "role": {"value": "RootWebArea"}, "name": {"value": "Example"}, "childIds": ["n1", "n2"]},
3339 {"nodeId": "n1", "parentId": "root", "backendDOMNodeId": 1,
3340 "role": {"value": "button"}, "name": {"value": "Submit"}, "childIds": [],
3341 "properties": [{"name": "focusable", "value": {"value": true}}]},
3342 {"nodeId": "n2", "parentId": "root", "backendDOMNodeId": 2,
3343 "role": {"value": "link"}, "name": {"value": "Docs"}, "childIds": [],
3344 "properties": [{"name": "focusable", "value": {"value": true}}]}
3345 ], "truncated": false})
3346 .to_string(),
3347 ),
3348 "script.callFunction" if decl.contains("bc:center") => {
3349 string("{\"x\":30,\"y\":20}".into())
3350 }
3351 "script.callFunction" if decl.contains("bc:clip") => {
3352 string("{\"x\":10,\"y\":30,\"width\":100,\"height\":20}".into())
3353 }
3354 "script.callFunction" if decl.contains("bc:type") => {
3355 string("{\"kind\":\"field\",\"method\":\"execCommand\"}".into())
3356 }
3357 "script.evaluate" if expr.contains("__bcDocToken") => {
3358 string(doc_token.load(Ordering::SeqCst).to_string())
3359 }
3360 "script.evaluate" => {
3361 json!({"type": "success", "result": {"type": "number", "value": 1}, "realm": "R1"})
3362 }
3363 _ => json!({}),
3364 };
3365 ws.send(Message::Text(
3366 json!({"type": "success", "id": id, "result": result}).to_string(),
3367 ))
3368 .await
3369 .unwrap();
3370 }
3371 }
3372 });
3373 BidiA11yMock {
3374 endpoint: format!("ws://{addr}"),
3375 doc_token,
3376 requests,
3377 }
3378 }
3379
3380 fn bidi_state(endpoint: &str) -> ServerState {
3381 ServerState::new(ResolvedBrowser {
3382 engine: Engine::Bidi,
3383 endpoint: endpoint.to_string(),
3384 source: Source::External,
3385 })
3386 }
3387
3388 #[tokio::test]
3389 async fn bidi_snapshot_then_click_by_ref_performs_actions() {
3390 use std::sync::atomic::Ordering;
3391 let mock = spawn_bidi_a11y_mock().await;
3392 let state = bidi_state(&mock.endpoint);
3393 let route = json!({"target": "example\\.com"});
3394
3395 let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3396 .await
3397 .unwrap();
3398 assert_eq!(
3399 snap["content"][0]["text"],
3400 "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3401 );
3402
3403 let mut args = route.clone();
3404 args["ref"] = json!("e1");
3405 let out = handler_for("browser_click")(state.clone(), args.clone())
3406 .await
3407 .unwrap();
3408 assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3409 {
3410 let reqs = mock.requests.lock().await;
3411 let perform = reqs
3412 .iter()
3413 .find(|r| r["method"] == "input.performActions")
3414 .expect("performActions");
3415 assert_eq!(perform["params"]["context"], "CTX1");
3416 let acts = &perform["params"]["actions"][0]["actions"];
3417 assert_eq!(acts[0]["type"], "pointerMove");
3418 assert_eq!(acts[0]["x"], 30);
3419 assert_eq!(acts[1]["type"], "pointerDown");
3420 assert_eq!(acts[2]["type"], "pointerUp");
3421 assert!(reqs.iter().any(|r| r["method"] == "script.callFunction"
3422 && r["params"]["arguments"][0]["value"] == 1));
3423 }
3424 assert!(state.sidecar.lock().await.is_none());
3425
3426 let mut find_args = route.clone();
3427 find_args["query"] = json!("docs");
3428 let out = handler_for("browser_find")(state.clone(), find_args)
3429 .await
3430 .unwrap();
3431 assert_eq!(
3432 out["content"][0]["text"],
3433 "1 match for \"docs\":\ne2 link \"Docs\"\n"
3434 );
3435
3436 mock.doc_token.store(4294967297, Ordering::SeqCst);
3437 let err = handler_for("browser_click")(state.clone(), args.clone())
3438 .await
3439 .unwrap_err();
3440 assert!(matches!(
3441 err.downcast_ref::<SessionError>(),
3442 Some(SessionError::StaleRef {
3443 reason: "document changed",
3444 ..
3445 })
3446 ));
3447 let err = handler_for("browser_click")(state.clone(), args)
3448 .await
3449 .unwrap_err();
3450 assert!(matches!(
3451 err.downcast_ref::<SessionError>(),
3452 Some(SessionError::RefUnknown { .. })
3453 ));
3454 }
3455
3456 #[tokio::test]
3457 async fn bidi_type_by_ref_fills_and_submits() {
3458 let mock = spawn_bidi_a11y_mock().await;
3459 let state = bidi_state(&mock.endpoint);
3460 let route = json!({"target": "example\\.com"});
3461 handler_for("browser_snapshot")(state.clone(), route.clone())
3462 .await
3463 .unwrap();
3464 let mut args = route.clone();
3465 args["ref"] = json!("e1");
3466 args["text"] = json!("hello");
3467 args["submit"] = json!(true);
3468 let out = handler_for("browser_type")(state.clone(), args)
3469 .await
3470 .unwrap();
3471 assert_eq!(
3472 out["content"][0]["text"],
3473 "typed into e1 (button \"Submit\") and pressed Enter"
3474 );
3475 let reqs = mock.requests.lock().await;
3476 let typed = reqs
3477 .iter()
3478 .find(|r| {
3479 r["params"]["functionDeclaration"]
3480 .as_str()
3481 .is_some_and(|d| d.contains("bc:type"))
3482 })
3483 .expect("type helper");
3484 assert_eq!(typed["params"]["arguments"][1]["value"], "hello");
3485 assert_eq!(typed["params"]["arguments"][2]["value"], "fill");
3486 let keys = reqs
3487 .iter()
3488 .find(|r| r["method"] == "input.performActions")
3489 .expect("enter");
3490 assert_eq!(keys["params"]["actions"][0]["type"], "key");
3491 assert_eq!(
3492 keys["params"]["actions"][0]["actions"][0]["value"],
3493 "\u{e007}"
3494 );
3495 }
3496
3497 #[tokio::test]
3498 async fn bidi_screenshot_by_ref_and_full_page_clip_to_document() {
3499 let mock = spawn_bidi_a11y_mock().await;
3500 let state = bidi_state(&mock.endpoint);
3501 let route = json!({"target": "example\\.com"});
3502 handler_for("browser_snapshot")(state.clone(), route.clone())
3503 .await
3504 .unwrap();
3505 let mut args = route.clone();
3506 args["ref"] = json!("e2");
3507 let out = handler_for("browser_take_screenshot")(state.clone(), args)
3508 .await
3509 .unwrap();
3510 assert_eq!(out["content"][0]["type"], "image");
3511 let reqs = mock.requests.lock().await;
3512 let cap = reqs
3513 .iter()
3514 .find(|r| r["method"] == "browsingContext.captureScreenshot")
3515 .expect("capture");
3516 assert_eq!(cap["params"]["origin"], "document");
3517 assert_eq!(cap["params"]["clip"]["type"], "box");
3518 assert_eq!(cap["params"]["clip"]["x"], 10);
3519 assert_eq!(cap["params"]["clip"]["width"], 100);
3520 }
3521
3522 struct A11yMock {
3525 endpoint: String,
3526 doc_token: Arc<std::sync::atomic::AtomicU64>,
3527 requests: Arc<Mutex<Vec<Value>>>,
3528 }
3529
3530 async fn spawn_a11y_mock() -> A11yMock {
3531 use std::sync::atomic::{AtomicU64, Ordering};
3532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3533 let addr = listener.local_addr().unwrap();
3534 let doc_token = Arc::new(AtomicU64::new(101));
3535 let requests = Arc::new(Mutex::new(Vec::new()));
3536 tokio::spawn({
3537 let doc_token = doc_token.clone();
3538 let requests = requests.clone();
3539 async move {
3540 let (stream, _) = listener.accept().await.unwrap();
3541 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3542 let mut next_session = 0u32;
3543 while let Some(Ok(Message::Text(t))) = ws.next().await {
3544 let req: Value = serde_json::from_str(&t).unwrap();
3545 requests.lock().await.push(req.clone());
3546 let id = req["id"].as_u64().unwrap();
3547 let method = req["method"].as_str().unwrap_or("");
3548 let result = match method {
3549 "Target.getTargets" => json!({"targetInfos": [{
3550 "targetId": "T1", "type": "page",
3551 "url": "https://example.com/", "title": "Example",
3552 }]}),
3553 "Target.attachToTarget" => {
3554 next_session += 1;
3555 json!({"sessionId": format!("S{next_session}")})
3556 }
3557 "Runtime.evaluate" => json!({"result": {"value": 1}}),
3558 "Accessibility.getFullAXTree" => json!({"nodes": [
3559 {"nodeId": "1", "backendDOMNodeId": 101,
3560 "role": {"value": "RootWebArea"}, "name": {"value": "Example"},
3561 "childIds": ["2", "3"]},
3562 {"nodeId": "2", "parentId": "1", "backendDOMNodeId": 106,
3563 "role": {"value": "button"}, "name": {"value": "Submit"},
3564 "properties": [{"name": "focusable", "value": {"value": true}}],
3565 "childIds": []},
3566 {"nodeId": "3", "parentId": "1", "backendDOMNodeId": 108,
3567 "role": {"value": "link"}, "name": {"value": "Docs"},
3568 "properties": [{"name": "focusable", "value": {"value": true}}],
3569 "childIds": []},
3570 ]}),
3571 "DOM.getDocument" => {
3572 json!({"root": {"backendNodeId": doc_token.load(Ordering::SeqCst)}})
3573 }
3574 "DOM.getContentQuads" => {
3575 json!({"quads": [[10, 10, 50, 10, 50, 30, 10, 30]]})
3576 }
3577 "DOM.resolveNode" => json!({"object": {"objectId": "obj-1"}}),
3578 "DOM.getBoxModel" => {
3579 json!({"model": {"border": [10, 30, 110, 30, 110, 50, 10, 50]}})
3580 }
3581 "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
3582 "Page.getLayoutMetrics" => json!({"cssLayoutViewport": {
3583 "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600
3584 }}),
3585 _ => json!({}),
3586 };
3587 let resp = json!({"id": id, "result": result});
3588 ws.send(Message::Text(resp.to_string())).await.unwrap();
3589 }
3590 }
3591 });
3592 A11yMock {
3593 endpoint: format!("ws://{addr}"),
3594 doc_token,
3595 requests,
3596 }
3597 }
3598
3599 #[tokio::test]
3600 async fn snapshot_then_click_by_ref_dispatches_native_input() {
3601 use std::sync::atomic::Ordering;
3602 let mock = spawn_a11y_mock().await;
3603 let state = ServerState::new(ResolvedBrowser {
3604 engine: Engine::Cdp,
3605 endpoint: mock.endpoint.clone(),
3606 source: Source::External,
3607 });
3608 let route = json!({"target": "example\\.com"});
3609
3610 let snap = handler_for("browser_snapshot")(state.clone(), route.clone())
3611 .await
3612 .unwrap();
3613 let text = snap["content"][0]["text"].as_str().unwrap();
3614 assert_eq!(
3615 text,
3616 "# Example (https://example.com/)\n- button \"Submit\" [ref=e1]\n- link \"Docs\" [ref=e2]\n"
3617 );
3618
3619 let again = handler_for("browser_snapshot")(state.clone(), route.clone())
3621 .await
3622 .unwrap();
3623 assert_eq!(again["content"][0]["text"], snap["content"][0]["text"]);
3624
3625 let mut args = route.clone();
3626 args["ref"] = json!("e1");
3627 let out = handler_for("browser_click")(state.clone(), args.clone())
3628 .await
3629 .unwrap();
3630 assert_eq!(out["content"][0]["text"], "clicked e1 (button \"Submit\")");
3631 {
3632 let reqs = mock.requests.lock().await;
3633 let mouse: Vec<&Value> = reqs
3634 .iter()
3635 .filter(|r| r["method"] == "Input.dispatchMouseEvent")
3636 .collect();
3637 assert_eq!(mouse.len(), 3);
3638 assert_eq!(mouse[1]["params"]["type"], "mousePressed");
3639 assert_eq!(mouse[1]["params"]["x"], 30.0);
3640 assert_eq!(mouse[1]["params"]["y"], 20.0);
3641 assert!(reqs
3642 .iter()
3643 .any(|r| r["method"] == "DOM.scrollIntoViewIfNeeded"
3644 && r["params"]["backendNodeId"] == 106));
3645 }
3646
3647 let mut find_args = route.clone();
3649 find_args["query"] = json!("docs");
3650 let out = handler_for("browser_find")(state.clone(), find_args)
3651 .await
3652 .unwrap();
3653 assert_eq!(
3654 out["content"][0]["text"],
3655 "1 match for \"docs\":\ne2 link \"Docs\"\n"
3656 );
3657
3658 let mut bad = route.clone();
3660 bad["ref"] = json!("e9");
3661 let err = handler_for("browser_click")(state.clone(), bad)
3662 .await
3663 .unwrap_err();
3664 assert!(matches!(
3665 err.downcast_ref::<SessionError>(),
3666 Some(SessionError::RefUnknown { .. })
3667 ));
3668
3669 mock.doc_token.store(202, Ordering::SeqCst);
3671 let err = handler_for("browser_click")(state.clone(), args.clone())
3672 .await
3673 .unwrap_err();
3674 match err.downcast_ref::<SessionError>() {
3675 Some(SessionError::StaleRef { reason, .. }) => assert_eq!(*reason, "document changed"),
3676 other => panic!("expected StaleRef, got {other:?}"),
3677 }
3678 assert!(err.to_string().contains("browser_snapshot"));
3679 let err = handler_for("browser_click")(state.clone(), args)
3681 .await
3682 .unwrap_err();
3683 assert!(matches!(
3684 err.downcast_ref::<SessionError>(),
3685 Some(SessionError::RefUnknown { .. })
3686 ));
3687 }
3688
3689 #[tokio::test]
3690 async fn type_by_ref_inserts_text_and_submits() {
3691 let mock = spawn_a11y_mock().await;
3692 let state = ServerState::new(ResolvedBrowser {
3693 engine: Engine::Cdp,
3694 endpoint: mock.endpoint.clone(),
3695 source: Source::External,
3696 });
3697 let route = json!({"target": "example\\.com"});
3698 handler_for("browser_snapshot")(state.clone(), route.clone())
3699 .await
3700 .unwrap();
3701 let mut args = route.clone();
3702 args["ref"] = json!("e1");
3703 args["text"] = json!("hello");
3704 args["submit"] = json!(true);
3705 let out = handler_for("browser_type")(state.clone(), args)
3706 .await
3707 .unwrap();
3708 assert_eq!(
3709 out["content"][0]["text"],
3710 "typed into e1 (button \"Submit\") and pressed Enter"
3711 );
3712 let reqs = mock.requests.lock().await;
3713 let insert = reqs
3714 .iter()
3715 .find(|r| r["method"] == "Input.insertText")
3716 .expect("insertText");
3717 assert_eq!(insert["params"]["text"], "hello");
3718 assert!(reqs
3719 .iter()
3720 .any(|r| r["method"] == "Input.dispatchKeyEvent" && r["params"]["key"] == "Enter"));
3721 assert!(state.sidecar.lock().await.is_none());
3723 }
3724
3725 async fn spawn_capture_mock() -> String {
3729 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3730 let addr = listener.local_addr().unwrap();
3731 tokio::spawn(async move {
3732 let (stream, _) = listener.accept().await.unwrap();
3733 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3734 let mut next_session = 0u32;
3735 while let Some(Ok(Message::Text(t))) = ws.next().await {
3736 let req: Value = serde_json::from_str(&t).unwrap();
3737 let id = req["id"].as_u64().unwrap();
3738 let method = req["method"].as_str().unwrap_or("").to_string();
3739 let sid = req["sessionId"].as_str().unwrap_or("").to_string();
3740 let result = match method.as_str() {
3741 "Target.getTargets" => json!({"targetInfos": [{
3742 "targetId": "T1", "type": "page",
3743 "url": "https://app.test/", "title": "App",
3744 }]}),
3745 "Target.attachToTarget" => {
3746 next_session += 1;
3747 json!({"sessionId": format!("S{next_session}")})
3748 }
3749 "Runtime.evaluate" => json!({"result": {"value": 1}}),
3750 "Page.getNavigationHistory" => json!({
3751 "currentIndex": 0, "entries": [{"url": "https://app.test/"}]
3752 }),
3753 "Network.getResponseBody" => {
3754 json!({"body": "{\"ok\":true}", "base64Encoded": false})
3755 }
3756 _ => json!({}),
3757 };
3758 ws.send(Message::Text(
3759 json!({"id": id, "result": result}).to_string(),
3760 ))
3761 .await
3762 .unwrap();
3763 if method == "Network.enable" {
3764 for ev in [
3765 json!({"method": "Runtime.consoleAPICalled", "sessionId": sid,
3766 "params": {"type": "error", "timestamp": 1756816496120.0,
3767 "args": [{"type": "string", "value": "boom"}],
3768 "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3769 json!({"method": "Network.requestWillBeSent", "sessionId": sid,
3770 "params": {"requestId": "9.1", "timestamp": 10.0, "wallTime": 1756816496.0, "type": "XHR",
3771 "request": {"method": "GET", "url": "https://app.test/api/me"}}}),
3772 json!({"method": "Network.responseReceived", "sessionId": sid,
3773 "params": {"requestId": "9.1", "type": "XHR",
3774 "response": {"status": 401, "mimeType": "application/json"}}}),
3775 json!({"method": "Network.loadingFinished", "sessionId": sid,
3776 "params": {"requestId": "9.1", "timestamp": 10.084, "encodedDataLength": 11}}),
3777 ] {
3778 ws.send(Message::Text(ev.to_string())).await.unwrap();
3779 }
3780 }
3781 }
3782 });
3783 format!("ws://{addr}")
3784 }
3785
3786 #[tokio::test]
3787 async fn capture_tools_read_buffered_console_and_network_after_navigate() {
3788 let endpoint = spawn_capture_mock().await;
3789 let state = ServerState::new(ResolvedBrowser {
3790 engine: Engine::Cdp,
3791 endpoint,
3792 source: Source::External,
3793 });
3794 let route = json!({"target": "app\\.test"});
3795 let mut nav = route.clone();
3796 nav["url"] = json!("https://app.test/x");
3797 handler_for("browser_navigate")(state.clone(), nav)
3798 .await
3799 .unwrap();
3800
3801 let mut text = String::new();
3804 for _ in 0..100 {
3805 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3806 .await
3807 .unwrap();
3808 text = out["content"][0]["text"].as_str().unwrap().to_string();
3809 if text.contains("boom") {
3810 break;
3811 }
3812 tokio::time::sleep(Duration::from_millis(10)).await;
3813 }
3814 assert!(
3815 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"),
3816 "{text}"
3817 );
3818
3819 let mut q = route.clone();
3821 q["pattern"] = json!("nomatch");
3822 let out = handler_for("browser_console_messages")(state.clone(), q)
3823 .await
3824 .unwrap();
3825 assert!(out["content"][0]["text"]
3826 .as_str()
3827 .unwrap()
3828 .contains("showing 0 of 0 matched (1 buffered"));
3829 let mut q = route.clone();
3830 q["pattern"] = json!("[");
3831 let err = handler_for("browser_console_messages")(state.clone(), q)
3832 .await
3833 .unwrap_err();
3834 assert!(err.to_string().contains("invalid `pattern` regex"));
3835 let mut q = route.clone();
3836 q["limit"] = json!(0);
3837 q["clear"] = json!(true);
3838 handler_for("browser_console_messages")(state.clone(), q)
3839 .await
3840 .unwrap();
3841 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
3842 .await
3843 .unwrap();
3844 assert!(out["content"][0]["text"]
3845 .as_str()
3846 .unwrap()
3847 .contains("(0 buffered"));
3848
3849 let mut q = route.clone();
3851 q["status"] = json!("4xx");
3852 q["url_pattern"] = json!("/api/");
3853 let out = handler_for("browser_network_requests")(state.clone(), q)
3854 .await
3855 .unwrap();
3856 let text = out["content"][0]["text"].as_str().unwrap();
3857 assert!(
3858 text.contains(
3859 "9.1 GET https://app.test/api/me → 401 application/json 11B 84ms [XHR]"
3860 ),
3861 "{text}"
3862 );
3863 let mut q = route.clone();
3864 q["status"] = json!("2xx");
3865 let out = handler_for("browser_network_requests")(state.clone(), q)
3866 .await
3867 .unwrap();
3868 assert!(out["content"][0]["text"]
3869 .as_str()
3870 .unwrap()
3871 .contains("showing 0 of 0 matched (1 buffered"));
3872 let mut q = route.clone();
3873 q["format"] = json!("json");
3874 let out = handler_for("browser_network_requests")(state.clone(), q)
3875 .await
3876 .unwrap();
3877 let parsed: Value =
3878 serde_json::from_str(out["content"][0]["text"].as_str().unwrap()).unwrap();
3879 assert_eq!(parsed["entries"][0]["request_id"], "9.1");
3880 assert_eq!(parsed["entries"][0]["state"], "finished");
3881
3882 let mut q = route.clone();
3884 q["request_id"] = json!("9.1");
3885 let out = handler_for("browser_network_body")(state.clone(), q)
3886 .await
3887 .unwrap();
3888 assert_eq!(out["content"][0]["text"], "{\"ok\":true}");
3889 let meta: Value =
3890 serde_json::from_str(out["content"][1]["text"].as_str().unwrap()).unwrap();
3891 assert_eq!(meta["status"], 401);
3892 assert_eq!(meta["truncated"], false);
3893
3894 assert_eq!(state.capture.captured_tabs(), vec!["T1".to_string()]);
3897 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
3898 .await
3899 .unwrap();
3900 assert!(state.capture.captured_tabs().is_empty());
3901 }
3902
3903 #[tokio::test]
3904 async fn capture_tools_validate_before_backend_and_gate_on_engine() {
3905 let h = handler_for("browser_network_requests");
3906 let err = h(unreached_state(), json!({"status": "lots"}))
3907 .await
3908 .expect_err("must error");
3909 assert!(err.to_string().contains("`status` must be"), "{err:#}");
3910 let h = handler_for("browser_network_body");
3911 let err = h(unreached_state(), json!({}))
3912 .await
3913 .expect_err("must error");
3914 assert!(err.to_string().contains("missing 'request_id'"), "{err:#}");
3915
3916 let state = ServerState::new(ResolvedBrowser {
3919 engine: Engine::Bidi,
3920 endpoint: "ws://127.0.0.1:0".into(),
3921 source: Source::External,
3922 });
3923 let err = handler_for("browser_network_body")(state.clone(), json!({"request_id": "1"}))
3924 .await
3925 .expect_err("BiDi must error");
3926 match err.downcast_ref::<SessionError>() {
3927 Some(SessionError::EngineUnsupported { tool, hint, .. }) => {
3928 assert_eq!(tool, "browser_network_body");
3929 assert!(hint.contains("browser_fetch") && hint.contains("browser_select"));
3930 }
3931 other => panic!("expected EngineUnsupported, got {other:?}"),
3932 }
3933 for tool in ["browser_console_messages", "browser_network_requests"] {
3934 let err = handler_for(tool)(state.clone(), json!({}))
3935 .await
3936 .expect_err("unreachable endpoint must error");
3937 assert!(
3938 !matches!(
3939 err.downcast_ref::<SessionError>(),
3940 Some(SessionError::EngineUnsupported { .. })
3941 ),
3942 "{tool} must not be engine-gated on BiDi: {err:#}"
3943 );
3944 }
3945 }
3946
3947 async fn spawn_bidi_capture_mock() -> String {
3951 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3952 let addr = listener.local_addr().unwrap();
3953 tokio::spawn(async move {
3954 let (stream, _) = listener.accept().await.unwrap();
3955 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
3956 while let Some(Ok(Message::Text(t))) = ws.next().await {
3957 let req: Value = serde_json::from_str(&t).unwrap();
3958 let id = req["id"].as_u64().unwrap();
3959 let method = req["method"].as_str().unwrap_or("").to_string();
3960 let result = match method.as_str() {
3961 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
3962 "browsingContext.getTree" => json!({"contexts": [
3963 {"context": "C1", "url": "https://app.test/", "children": []}
3964 ]}),
3965 "browsingContext.navigate" => {
3966 json!({"navigation": "N1", "url": "https://app.test/x"})
3967 }
3968 "script.evaluate" => {
3969 json!({"type": "success", "result": {"type": "number", "value": 1}})
3970 }
3971 _ => json!({}),
3972 };
3973 ws.send(Message::Text(
3974 json!({"type": "success", "id": id, "result": result}).to_string(),
3975 ))
3976 .await
3977 .unwrap();
3978 let first_event = req["params"]["events"][0].as_str().unwrap_or("");
3979 if method == "session.subscribe" && first_event.starts_with("network.") {
3980 for ev in [
3981 json!({"type": "event", "method": "log.entryAdded", "params": {
3982 "type": "console", "level": "error", "method": "error", "text": "boom",
3983 "timestamp": 1756816496120.0, "source": {"context": "C1"},
3984 "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 3, "columnNumber": 0}]}}}),
3985 json!({"type": "event", "method": "network.beforeRequestSent", "params": {
3986 "context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1756816496000.0,
3987 "initiator": {"type": "other"},
3988 "request": {"request": "9.1", "url": "https://app.test/api/me", "method": "GET", "bodySize": 0, "initiatorType": "xmlhttprequest"}}}),
3989 json!({"type": "event", "method": "network.responseCompleted", "params": {
3990 "context": "C1", "timestamp": 1756816496084.0, "redirectCount": 0,
3991 "request": {"request": "9.1"},
3992 "response": {"status": 401, "mimeType": "application/json", "bytesReceived": 11}}}),
3993 ] {
3994 ws.send(Message::Text(ev.to_string())).await.unwrap();
3995 }
3996 }
3997 }
3998 });
3999 format!("ws://{addr}")
4000 }
4001
4002 #[tokio::test]
4003 async fn capture_tools_work_on_bidi_and_body_is_cdp_only() {
4004 let endpoint = spawn_bidi_capture_mock().await;
4005 let state = ServerState::new(ResolvedBrowser {
4006 engine: Engine::Bidi,
4007 endpoint,
4008 source: Source::External,
4009 });
4010 let route = json!({"target": "app\\.test"});
4011 let mut nav = route.clone();
4012 nav["url"] = json!("https://app.test/x");
4013 handler_for("browser_navigate")(state.clone(), nav)
4014 .await
4015 .unwrap();
4016 let mut text = String::new();
4017 for _ in 0..100 {
4018 let out = handler_for("browser_console_messages")(state.clone(), route.clone())
4019 .await
4020 .unwrap();
4021 text = out["content"][0]["text"].as_str().unwrap().to_string();
4022 if text.contains("boom") {
4023 break;
4024 }
4025 tokio::time::sleep(Duration::from_millis(10)).await;
4026 }
4027 assert!(
4028 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"),
4029 "{text}"
4030 );
4031 let mut q = route.clone();
4032 q["status"] = json!("4xx");
4033 let out = handler_for("browser_network_requests")(state.clone(), q)
4034 .await
4035 .unwrap();
4036 let net = out["content"][0]["text"].as_str().unwrap();
4037 assert!(
4038 net.contains(
4039 "9.1 GET https://app.test/api/me → 401 application/json 11B 84ms [XHR]"
4040 ),
4041 "{net}"
4042 );
4043 let mut q = route.clone();
4044 q["request_id"] = json!("9.1");
4045 let err = handler_for("browser_network_body")(state.clone(), q)
4046 .await
4047 .unwrap_err();
4048 match err.downcast_ref::<SessionError>() {
4049 Some(SessionError::EngineUnsupported { hint, .. }) => {
4050 assert!(hint.contains("browser_fetch"))
4051 }
4052 other => panic!("expected EngineUnsupported, got {other:?}"),
4053 }
4054 assert_eq!(state.capture.captured_tabs(), vec!["C1".to_string()]);
4055 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "C1"}))
4056 .await
4057 .unwrap();
4058 assert!(state.capture.captured_tabs().is_empty());
4059 }
4060
4061 #[tokio::test]
4062 async fn tab_foreground_requires_registered_chromium_browser() {
4063 let state = ServerState::new(ResolvedBrowser {
4064 engine: Engine::Bidi,
4065 endpoint: "ws://127.0.0.1:0".into(),
4066 source: Source::External,
4067 });
4068 let err = handler_for("browser_tab_foreground")(state, json!({}))
4069 .await
4070 .expect_err("BiDi must error");
4071 match err.downcast_ref::<SessionError>() {
4072 Some(SessionError::EngineUnsupported { hint, .. }) => {
4073 assert!(hint.contains("setFocusEmulationEnabled"))
4074 }
4075 other => panic!("expected EngineUnsupported, got {other:?}"),
4076 }
4077 let mock = spawn_a11y_mock().await;
4078 let state = ServerState::new(ResolvedBrowser {
4079 engine: Engine::Cdp,
4080 endpoint: mock.endpoint.clone(),
4081 source: Source::External,
4082 });
4083 let err = handler_for("browser_tab_foreground")(state.clone(), json!({}))
4084 .await
4085 .expect_err("external endpoint must error");
4086 assert!(err.to_string().contains("registered browser"), "{err:#}");
4087 let err = handler_for("browser_tab_foreground")(
4088 state.clone(),
4089 json!({"enabled": true, "all": true}),
4090 )
4091 .await
4092 .expect_err("all requires enabled false");
4093 assert!(err.to_string().contains("`all` only applies"), "{err:#}");
4094 let list = handler_for("browser_tab_list")(state, json!({}))
4095 .await
4096 .unwrap();
4097 let rows: Value =
4098 serde_json::from_str(list["content"][0]["text"].as_str().unwrap()).unwrap();
4099 assert_eq!(rows[0]["foreground"], false);
4100 }
4101
4102 #[tokio::test]
4103 async fn tab_close_drops_refs() {
4104 let mock = spawn_a11y_mock().await;
4105 let state = ServerState::new(ResolvedBrowser {
4106 engine: Engine::Cdp,
4107 endpoint: mock.endpoint.clone(),
4108 source: Source::External,
4109 });
4110 handler_for("browser_snapshot")(state.clone(), json!({"target": "example\\.com"}))
4111 .await
4112 .unwrap();
4113 assert!(state.refs.lock().await.contains_key("T1"));
4114 handler_for("browser_tab_close")(state.clone(), json!({"target_id": "T1"}))
4115 .await
4116 .unwrap();
4117 assert!(!state.refs.lock().await.contains_key("T1"));
4118 }
4119
4120 #[tokio::test]
4124 async fn sidecar_tool_on_bidi_returns_engine_unsupported() {
4125 use crate::cli::env_resolver::{ResolvedBrowser, Source};
4126 use crate::detect::Engine;
4127 use crate::errors::SessionError;
4128
4129 let resolved = ResolvedBrowser {
4132 engine: Engine::Bidi,
4133 endpoint: "ws://127.0.0.1:0".into(),
4134 source: Source::External,
4135 };
4136 let state = ServerState::new(resolved);
4137
4138 let err = match state.ensure_sidecar("browser_snapshot").await {
4139 Ok(_) => panic!("BiDi must error"),
4140 Err(e) => e,
4141 };
4142 let typed = err.downcast_ref::<SessionError>().expect("typed error");
4143 match typed {
4144 SessionError::EngineUnsupported { tool, hint, .. } => {
4145 assert_eq!(tool, "browser_snapshot");
4146 assert!(!hint.contains(concat!("browser_", "evaluate")));
4147 assert!(hint.contains("browser_get_html"));
4148 assert!(hint.contains("browser_select"));
4149 }
4150 other => panic!("expected EngineUnsupported, got {other:?}"),
4151 }
4152 }
4153
4154 #[test]
4155 fn sidecar_cdp_attach_failure_classifier_matches_connect_layer_errors() {
4156 let err = anyhow::anyhow!(
4157 "browserType.connectOverCDP: Timeout 5000ms exceeded while <ws connecting> to ws://127.0.0.1:64767/devtools/browser/x"
4158 );
4159 assert!(looks_like_sidecar_cdp_attach_failure(&err));
4160
4161 let err = anyhow::anyhow!("page.waitForLoadState: Timeout 30000ms exceeded");
4162 assert!(
4163 !looks_like_sidecar_cdp_attach_failure(&err),
4164 "normal page wait timeouts must not be reclassified as sidecar attach failures"
4165 );
4166 }
4167
4168 #[test]
4169 fn sidecar_connection_failed_message_discourages_page_hang_inference() {
4170 let err = SessionError::SidecarConnectionFailed {
4171 tool: "browser_snapshot".into(),
4172 method: "snapshot".into(),
4173 target_id: "T1".into(),
4174 url: Some("http://localhost:5173/404".into()),
4175 details: "browserType.connectOverCDP: Timeout 5000ms exceeded".into(),
4176 hint: "retry the Playwright-sidecar tool or inspect with browser_get_html / browser_take_screenshot",
4177 };
4178 let msg = err.to_string();
4179 assert!(msg.contains("Playwright sidecar connection failed"));
4180 assert!(msg.contains("not evidence that the page is hung"));
4181 assert!(msg.contains("browser_get_html"));
4182 }
4183
4184 #[tokio::test]
4185 async fn screenshot_selector_sends_cdp_clip() {
4186 let mock = spawn_screenshot_mock(json!({
4187 "x": 12.5,
4188 "y": 34.0,
4189 "width": 56.0,
4190 "height": 78.0,
4191 }))
4192 .await;
4193 let state = ServerState::new(ResolvedBrowser {
4194 engine: Engine::Cdp,
4195 endpoint: mock.endpoint,
4196 source: Source::External,
4197 });
4198 let h = handler_for("browser_take_screenshot");
4199 let out = h(
4200 state,
4201 json!({
4202 "target": "example\\.com",
4203 "selector": "#main",
4204 }),
4205 )
4206 .await
4207 .unwrap();
4208 assert_eq!(out["content"][0]["type"], "image");
4209 assert_eq!(out["content"][0]["data"], "PNGDATA");
4210
4211 let captures = mock.capture_params.lock().await;
4212 assert_eq!(captures.len(), 1);
4213 assert_eq!(captures[0]["format"], "png");
4214 assert_eq!(captures[0]["captureBeyondViewport"], true);
4215 assert_eq!(captures[0]["clip"]["x"], json!(12.5));
4216 assert_eq!(captures[0]["clip"]["y"], json!(34.0));
4217 assert_eq!(captures[0]["clip"]["width"], json!(56.0));
4218 assert_eq!(captures[0]["clip"]["height"], json!(78.0));
4219 assert_eq!(captures[0]["clip"]["scale"], json!(1));
4220 }
4221
4222 #[tokio::test]
4223 async fn screenshot_jpeg_quality_and_max_width_scale_clip() {
4224 let mock = spawn_screenshot_mock(json!(2.0)).await;
4226 let state = ServerState::new(ResolvedBrowser {
4227 engine: Engine::Cdp,
4228 endpoint: mock.endpoint,
4229 source: Source::External,
4230 });
4231 let out = handler_for("browser_take_screenshot")(
4232 state.clone(),
4233 json!({
4234 "target": "example\\.com",
4235 "format": "jpeg",
4236 "quality": 60,
4237 "max_width": 500,
4238 }),
4239 )
4240 .await
4241 .unwrap();
4242 assert_eq!(out["content"][0]["mimeType"], "image/jpeg");
4243 let captures = mock.capture_params.lock().await;
4244 assert_eq!(captures.len(), 1);
4245 assert_eq!(captures[0]["format"], "jpeg");
4246 assert_eq!(captures[0]["quality"], 60);
4247 assert_eq!(captures[0]["captureBeyondViewport"], true);
4249 assert_eq!(captures[0]["clip"]["x"], json!(0.0));
4250 assert_eq!(captures[0]["clip"]["y"], json!(100.0));
4251 assert_eq!(captures[0]["clip"]["width"], json!(1000.0));
4252 assert_eq!(captures[0]["clip"]["height"], json!(500.0));
4253 assert_eq!(captures[0]["clip"]["scale"], json!(0.25));
4254 }
4255
4256 #[tokio::test]
4257 async fn screenshot_max_width_larger_than_page_keeps_default_params() {
4258 let mock = spawn_screenshot_mock(json!(1.0)).await;
4259 let state = ServerState::new(ResolvedBrowser {
4260 engine: Engine::Cdp,
4261 endpoint: mock.endpoint,
4262 source: Source::External,
4263 });
4264 handler_for("browser_take_screenshot")(
4265 state,
4266 json!({"target": "example\\.com", "max_width": 4000, "full_page": true}),
4267 )
4268 .await
4269 .unwrap();
4270 let captures = mock.capture_params.lock().await;
4271 assert_eq!(captures[0]["format"], "png");
4272 assert!(captures[0].get("clip").is_none());
4273 assert!(captures[0].get("quality").is_none());
4274 assert_eq!(captures[0]["captureBeyondViewport"], true);
4275 }
4276
4277 #[tokio::test]
4278 async fn screenshot_save_to_writes_private_file_and_reports_dimensions() {
4279 use base64::Engine as _;
4280 let data = base64::engine::general_purpose::STANDARD.encode(fake_png_1280x720());
4281 let mock = spawn_screenshot_mock_with_data(Value::Null, data).await;
4282 let state = ServerState::new(ResolvedBrowser {
4283 engine: Engine::Cdp,
4284 endpoint: mock.endpoint,
4285 source: Source::External,
4286 });
4287 let dir = tempfile::TempDir::new().unwrap();
4288 let path = dir.path().join("shot.png");
4289 let out = handler_for("browser_take_screenshot")(
4290 state,
4291 json!({"target": "example\\.com", "save_to": path.to_str().unwrap()}),
4292 )
4293 .await
4294 .unwrap();
4295 assert_eq!(out["content"][0]["type"], "text");
4296 let text = out["content"][0]["text"].as_str().unwrap();
4297 assert!(
4298 text.starts_with(&format!(
4299 "Saved screenshot to {} (1280x720, image/png, 1 KiB)",
4300 path.display()
4301 )),
4302 "{text}"
4303 );
4304 let bytes = std::fs::read(&path).unwrap();
4305 assert_eq!(bytes, fake_png_1280x720());
4306 #[cfg(unix)]
4307 {
4308 use std::os::unix::fs::PermissionsExt;
4309 assert_eq!(
4310 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4311 0o600
4312 );
4313 }
4314 }
4315
4316 #[tokio::test]
4317 async fn screenshot_option_validation_fires_before_backend() {
4318 let h = handler_for("browser_take_screenshot");
4319 for (args, needle) in [
4320 (json!({"quality": 50}), "only applies to"),
4321 (json!({"format": "gif"}), "`format` must be"),
4322 (json!({"max_width": 10}), "at least 64"),
4323 (json!({"save_to": "relative.png"}), "absolute path"),
4324 (
4325 json!({"save_to": std::env::temp_dir().join("definitely/missing/dir/x.png")}),
4327 "parent directory",
4328 ),
4329 (json!({"selector": "#a", "ref": "e1"}), "mutually exclusive"),
4330 ] {
4331 let err = h(unreached_state(), args.clone())
4332 .await
4333 .expect_err("must error");
4334 assert!(err.to_string().contains(needle), "{args}: {err:#}");
4335 }
4336 }
4337
4338 #[tokio::test]
4339 async fn screenshot_by_ref_clips_to_node_box() {
4340 let mock = spawn_a11y_mock().await;
4341 let state = ServerState::new(ResolvedBrowser {
4342 engine: Engine::Cdp,
4343 endpoint: mock.endpoint.clone(),
4344 source: Source::External,
4345 });
4346 let route = json!({"target": "example\\.com"});
4347 handler_for("browser_snapshot")(state.clone(), route.clone())
4348 .await
4349 .unwrap();
4350 let mut args = route.clone();
4351 args["ref"] = json!("e1");
4352 let out = handler_for("browser_take_screenshot")(state.clone(), args)
4353 .await
4354 .unwrap();
4355 assert_eq!(out["content"][0]["type"], "image");
4356 let reqs = mock.requests.lock().await;
4357 let cap = reqs
4358 .iter()
4359 .find(|r| r["method"] == "Page.captureScreenshot")
4360 .expect("capture");
4361 assert_eq!(cap["params"]["clip"]["x"], json!(10.0));
4362 assert_eq!(cap["params"]["clip"]["y"], json!(30.0));
4363 assert_eq!(cap["params"]["clip"]["width"], json!(100.0));
4364 assert_eq!(cap["params"]["clip"]["height"], json!(20.0));
4365 assert!(reqs
4366 .iter()
4367 .any(|r| r["method"] == "DOM.getBoxModel" && r["params"]["backendNodeId"] == 106));
4368 }
4369
4370 #[tokio::test]
4371 async fn get_page_text_formats_result_and_truncation() {
4372 let payload = json!({
4373 "title": "Docs",
4374 "url": "https://example.com/docs",
4375 "source": "main",
4376 "text": "# Welcome\nHello world",
4377 "truncated": true,
4378 "total_chars": 12345,
4379 })
4380 .to_string();
4381 let mock = spawn_screenshot_mock(Value::String(payload)).await;
4382 let state = ServerState::new(ResolvedBrowser {
4383 engine: Engine::Cdp,
4384 endpoint: mock.endpoint,
4385 source: Source::External,
4386 });
4387 let out = handler_for("browser_get_page_text")(
4388 state,
4389 json!({"target": "example\\.com", "max_chars": 1000}),
4390 )
4391 .await
4392 .unwrap();
4393 assert_eq!(
4394 out["content"][0]["text"],
4395 "Docs\nhttps://example.com/docs\n\n# Welcome\nHello world\n… [truncated at 1000 of 12345 chars; pass max_chars or selector to narrow]"
4396 );
4397
4398 let mock = spawn_screenshot_mock(Value::String(
4399 json!({"error": "selector matched no element: #x"}).to_string(),
4400 ))
4401 .await;
4402 let state = ServerState::new(ResolvedBrowser {
4403 engine: Engine::Cdp,
4404 endpoint: mock.endpoint,
4405 source: Source::External,
4406 });
4407 let err = handler_for("browser_get_page_text")(
4408 state,
4409 json!({"target": "example\\.com", "selector": "#x"}),
4410 )
4411 .await
4412 .unwrap_err();
4413 assert!(err.to_string().contains("selector matched no element"));
4414
4415 let err = handler_for("browser_get_page_text")(unreached_state(), json!({"max_chars": 10}))
4416 .await
4417 .unwrap_err();
4418 assert!(err.to_string().contains("at least 500"));
4419 }
4420
4421 #[tokio::test]
4422 async fn screenshot_selector_null_rect_errors_clearly() {
4423 let mock = spawn_screenshot_mock(Value::Null).await;
4424 let state = ServerState::new(ResolvedBrowser {
4425 engine: Engine::Cdp,
4426 endpoint: mock.endpoint,
4427 source: Source::External,
4428 });
4429 let h = handler_for("browser_take_screenshot");
4430 let err = h(
4431 state,
4432 json!({
4433 "target": "example\\.com",
4434 "selector": "#missing",
4435 }),
4436 )
4437 .await
4438 .expect_err("null selector rect must error");
4439 assert!(
4440 err.to_string()
4441 .contains("selector matched no visible element: #missing"),
4442 "got: {err:#}"
4443 );
4444 assert!(mock.capture_params.lock().await.is_empty());
4445 }
4446
4447 use crate::cli::env_resolver::{ResolvedBrowser, Source};
4455 use crate::detect::Engine;
4456
4457 fn handler_for(name: &str) -> ToolHandler {
4459 let registry = ToolRegistry::new();
4460 register_all(®istry);
4461 registry
4462 .handler(name)
4463 .unwrap_or_else(|| panic!("tool {name} not registered"))
4464 }
4465
4466 fn unreached_state() -> ServerState {
4470 ServerState::new(ResolvedBrowser {
4471 engine: Engine::Cdp,
4472 endpoint: "ws://127.0.0.1:0".into(),
4476 source: Source::External,
4477 })
4478 }
4479
4480 #[tokio::test]
4481 async fn navigate_missing_url_errors_before_backend() {
4482 let h = handler_for("browser_navigate");
4483 let err = h(unreached_state(), json!({}))
4484 .await
4485 .expect_err("missing url must error");
4486 assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4487 }
4488
4489 #[tokio::test]
4490 async fn fetch_missing_url_errors_before_backend() {
4491 let h = handler_for("browser_fetch");
4492 let err = h(unreached_state(), json!({"method": "GET"}))
4493 .await
4494 .expect_err("missing url must error");
4495 assert!(err.to_string().contains("missing 'url'"), "got: {err:#}");
4496 }
4497
4498 #[tokio::test]
4499 async fn curl_missing_args_errors_before_backend() {
4500 let h = handler_for("browser_curl");
4501 let err = h(unreached_state(), json!({}))
4502 .await
4503 .expect_err("missing args must error");
4504 assert!(err.to_string().contains("'args'"), "got: {err:#}");
4505 }
4506
4507 #[tokio::test]
4508 async fn curl_rejects_non_string_args_before_backend() {
4509 let h = handler_for("browser_curl");
4510 let err = h(unreached_state(), json!({"args": ["-L", 7]}))
4511 .await
4512 .expect_err("non-string args must error");
4513 assert!(
4514 err.to_string()
4515 .contains("every curl argument must be a string"),
4516 "got: {err:#}"
4517 );
4518 }
4519
4520 #[tokio::test]
4521 async fn storage_set_missing_value_errors_before_backend() {
4522 let h = handler_for("browser_storage_set");
4523 let err = h(unreached_state(), json!({"key": "k"}))
4524 .await
4525 .expect_err("missing value must error");
4526 assert!(err.to_string().contains("missing 'value'"), "got: {err:#}");
4527 }
4528
4529 #[tokio::test]
4530 async fn storage_get_missing_key_errors_before_backend() {
4531 let h = handler_for("browser_storage_get");
4532 let err = h(unreached_state(), json!({}))
4533 .await
4534 .expect_err("missing key must error");
4535 assert!(err.to_string().contains("missing 'key'"), "got: {err:#}");
4536 }
4537
4538 #[tokio::test]
4541 async fn navigate_tab_and_target_mutually_exclusive() {
4542 let h = handler_for("browser_navigate");
4543 let err = h(
4544 unreached_state(),
4545 json!({"url": "https://e.test/", "tab": "a", "target": "b"}),
4546 )
4547 .await
4548 .expect_err("tab+target must error");
4549 assert!(
4550 err.to_string().contains("mutually exclusive"),
4551 "got: {err:#}"
4552 );
4553 }
4554}