1use anyhow::{anyhow, Result};
8use regex::Regex;
9use serde_json::{json, Value};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use crate::cli::cookies::fetch_cookies;
14use crate::cli::storage::{build_get_expr, build_set_expr, ns_global};
15use crate::cli::wait_for_cookie::cookie_matches;
16use crate::detect::Engine;
17use crate::dom::scripts::{FETCH_JS, GET_DOM_JS, SELECT_ELEMENT_JS};
18use crate::mcp::server::{RegisteredTool, ServerState, ToolHandler, ToolRegistry};
19use crate::session::attach::PageSession;
20use crate::session::targets::{list as list_targets, open_bidi};
21
22pub fn register_all(registry: &ToolRegistry) {
24 registry.register(make_navigate());
25 registry.register(make_get_dom());
26 registry.register(make_screenshot());
27 registry.register(make_fetch());
28 registry.register(make_select_element());
29 registry.register(make_list_targets());
30 registry.register(make_cookies());
31 registry.register(make_storage_get());
32 registry.register(make_storage_set());
33 registry.register(make_wait_for_cookie());
34}
35
36async fn attach_active(state: &ServerState) -> Result<PageSession> {
46 match state.browser.engine {
47 Engine::Cdp => PageSession::attach(&state.browser.endpoint, Engine::Cdp, None).await,
48 Engine::Bidi => {
49 let mut guard = state.bidi.lock().await;
50 let client = if let Some(c) = guard.as_ref() {
51 c.clone()
52 } else {
53 let c = Arc::new(open_bidi(&state.browser.endpoint).await?);
54 c.session_new().await?;
55 *guard = Some(c.clone());
56 c
57 };
58 PageSession::from_bidi_cache(client, None).await
59 }
60 }
61}
62
63fn text_content(text: impl Into<String>) -> Value {
64 json!({ "content": [ { "type": "text", "text": text.into() } ] })
65}
66
67fn image_content(data: String) -> Value {
68 json!({
69 "content": [ { "type": "image", "data": data, "mimeType": "image/png" } ]
70 })
71}
72
73fn handler<F>(f: F) -> ToolHandler
74where
75 F: Fn(ServerState, Value) -> futures_util::future::BoxFuture<'static, Result<Value>>
76 + Send
77 + Sync
78 + 'static,
79{
80 Arc::new(f)
81}
82
83fn make_navigate() -> RegisteredTool {
88 RegisteredTool {
89 name: "navigate".into(),
90 description: "Navigate the active page to a URL.".into(),
91 input_schema: json!({
92 "type": "object",
93 "properties": { "url": { "type": "string" } },
94 "required": ["url"],
95 }),
96 handler: handler(|state, args| {
97 Box::pin(async move {
98 let url = args
99 .get("url")
100 .and_then(|v| v.as_str())
101 .ok_or_else(|| anyhow!("missing 'url'"))?
102 .to_string();
103 let session = attach_active(&state).await?;
104 session.navigate(&url).await?;
105 session.close().await;
106 Ok(text_content(format!("Navigated to {url}")))
107 })
108 }),
109 }
110}
111
112fn make_get_dom() -> RegisteredTool {
117 RegisteredTool {
118 name: "get_dom".into(),
119 description: "Get the rendered DOM as HTML, with shadow roots serialized when supported."
120 .into(),
121 input_schema: json!({
122 "type": "object",
123 "properties": {
124 "selector": {
125 "type": "string",
126 "description": "Optional CSS selector; defaults to the document element."
127 }
128 },
129 }),
130 handler: handler(|state, args| {
131 Box::pin(async move {
132 let selector_arg = args.get("selector").and_then(|v| v.as_str());
133 let selector_literal = match selector_arg {
134 Some(s) => serde_json::to_string(s)?,
135 None => "null".to_string(),
136 };
137 let expr = format!("({GET_DOM_JS})({selector_literal})");
138 let session = attach_active(&state).await?;
139 let value = session.evaluate(&expr, false).await?;
140 session.close().await;
141 let html = value.as_str().unwrap_or("").to_string();
142 Ok(text_content(html))
143 })
144 }),
145 }
146}
147
148fn make_screenshot() -> RegisteredTool {
153 RegisteredTool {
154 name: "screenshot".into(),
155 description: "Capture a PNG screenshot of the active page.".into(),
156 input_schema: json!({
157 "type": "object",
158 "properties": {
159 "full_page": { "type": "boolean", "default": false },
160 "selector": { "type": "string" }
161 },
162 }),
163 handler: handler(|state, args| {
164 Box::pin(async move {
165 let full_page = args
166 .get("full_page")
167 .and_then(|v| v.as_bool())
168 .unwrap_or(false);
169 let session = attach_active(&state).await?;
170 let b64 = session.screenshot(full_page).await?;
171 session.close().await;
172 Ok(image_content(b64))
173 })
174 }),
175 }
176}
177
178fn make_fetch() -> RegisteredTool {
183 RegisteredTool {
184 name: "fetch".into(),
185 description:
186 "Perform an HTTP request from the page context (preserves cookies, bypasses CORS)."
187 .into(),
188 input_schema: json!({
189 "type": "object",
190 "properties": {
191 "url": { "type": "string" },
192 "method": { "type": "string" },
193 "headers": { "type": "object" },
194 "body": { "type": "string" }
195 },
196 "required": ["url"],
197 }),
198 handler: handler(|state, args| {
199 Box::pin(async move {
200 if args.get("url").and_then(|v| v.as_str()).is_none() {
201 return Err(anyhow!("missing 'url'"));
202 }
203 let args_json = serde_json::to_string(&args)?;
204 let args_literal = serde_json::to_string(&args_json)?;
205 let expr = format!("({FETCH_JS})({args_literal})");
206 let session = attach_active(&state).await?;
207 let value = session.evaluate(&expr, true).await?;
208 session.close().await;
209 let raw = value.as_str().unwrap_or("").to_string();
210 let parsed: Value = serde_json::from_str(&raw)
211 .map_err(|e| anyhow!("invalid fetch response JSON: {e}"))?;
212 let pretty = serde_json::to_string_pretty(&parsed)?;
213 Ok(text_content(pretty))
214 })
215 }),
216 }
217}
218
219fn make_select_element() -> RegisteredTool {
224 RegisteredTool {
225 name: "select_element".into(),
226 description:
227 "Show an interactive overlay; resolve with the CSS selector for the clicked element."
228 .into(),
229 input_schema: json!({
230 "type": "object",
231 "properties": {},
232 }),
233 handler: handler(|state, _args| {
234 Box::pin(async move {
235 let expr = SELECT_ELEMENT_JS.to_string();
236 let session = attach_active(&state).await?;
237 let value = session.evaluate(&expr, true).await?;
238 session.close().await;
239 let selector = value.as_str().unwrap_or("").to_string();
240 Ok(text_content(selector))
241 })
242 }),
243 }
244}
245
246fn make_list_targets() -> RegisteredTool {
251 RegisteredTool {
252 name: "list_targets".into(),
253 description: "List open page targets, optionally filtered by an unanchored URL regex."
254 .into(),
255 input_schema: json!({
256 "type": "object",
257 "properties": {
258 "filter": {
259 "type": "string",
260 "description": "Optional unanchored URL regex."
261 }
262 },
263 }),
264 handler: handler(|state, args| {
265 Box::pin(async move {
266 let filter = args
267 .get("filter")
268 .and_then(|v| v.as_str())
269 .map(|s| s.to_string());
270 let targets = list_targets(
271 &state.browser.endpoint,
272 state.browser.engine,
273 filter.as_deref(),
274 )
275 .await?;
276 Ok(text_content(serde_json::to_string_pretty(&targets)?))
277 })
278 }),
279 }
280}
281
282fn make_cookies() -> RegisteredTool {
287 RegisteredTool {
288 name: "cookies".into(),
289 description: "Fetch cookies from the active browser. Returns full values (MCP is a \
290 trusted local channel). Optional unanchored regex filters."
291 .into(),
292 input_schema: json!({
293 "type": "object",
294 "properties": {
295 "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
296 "name": { "type": "string", "description": "Unanchored regex on cookie name." }
297 },
298 }),
299 handler: handler(|state, args| {
300 Box::pin(async move {
301 let domain_re = args
302 .get("domain")
303 .and_then(|v| v.as_str())
304 .map(Regex::new)
305 .transpose()
306 .map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
307 let name_re = args
308 .get("name")
309 .and_then(|v| v.as_str())
310 .map(Regex::new)
311 .transpose()
312 .map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
313 let all = fetch_cookies(&state.browser).await?;
314 let filtered: Vec<_> = all
315 .into_iter()
316 .filter(|c| {
317 domain_re.as_ref().map_or(true, |re| re.is_match(&c.domain))
318 && name_re.as_ref().map_or(true, |re| re.is_match(&c.name))
319 })
320 .collect();
321 Ok(text_content(serde_json::to_string_pretty(&filtered)?))
322 })
323 }),
324 }
325}
326
327fn make_storage_get() -> RegisteredTool {
332 RegisteredTool {
333 name: "storage_get".into(),
334 description: "Read a value from localStorage or sessionStorage on the active page.".into(),
335 input_schema: json!({
336 "type": "object",
337 "properties": {
338 "key": { "type": "string" },
339 "namespace": {
340 "type": "string",
341 "enum": ["local", "session"],
342 "default": "local"
343 }
344 },
345 "required": ["key"],
346 }),
347 handler: handler(|state, args| {
348 Box::pin(async move {
349 let key = args
350 .get("key")
351 .and_then(|v| v.as_str())
352 .ok_or_else(|| anyhow!("missing 'key'"))?
353 .to_string();
354 let namespace = args
355 .get("namespace")
356 .and_then(|v| v.as_str())
357 .unwrap_or("local");
358 let ns = ns_global(namespace)?;
359 let expr = build_get_expr(ns, &key);
360 let session = attach_active(&state).await?;
361 let value = session.evaluate(&expr, true).await?;
362 session.close().await;
363 let text = match value {
367 Value::String(s) => s,
368 Value::Null => "null".to_string(),
369 other => other.to_string(),
370 };
371 Ok(text_content(text))
372 })
373 }),
374 }
375}
376
377fn make_storage_set() -> RegisteredTool {
378 RegisteredTool {
379 name: "storage_set".into(),
380 description: "Write a value to localStorage or sessionStorage on the active page.".into(),
381 input_schema: json!({
382 "type": "object",
383 "properties": {
384 "key": { "type": "string" },
385 "value": { "type": "string" },
386 "namespace": {
387 "type": "string",
388 "enum": ["local", "session"],
389 "default": "local"
390 }
391 },
392 "required": ["key", "value"],
393 }),
394 handler: handler(|state, args| {
395 Box::pin(async move {
396 let key = args
397 .get("key")
398 .and_then(|v| v.as_str())
399 .ok_or_else(|| anyhow!("missing 'key'"))?
400 .to_string();
401 let value = args
402 .get("value")
403 .and_then(|v| v.as_str())
404 .ok_or_else(|| anyhow!("missing 'value'"))?
405 .to_string();
406 let namespace = args
407 .get("namespace")
408 .and_then(|v| v.as_str())
409 .unwrap_or("local");
410 let ns = ns_global(namespace)?;
411 let expr = build_set_expr(ns, &key, &value);
412 let session = attach_active(&state).await?;
413 let _ = session.evaluate(&expr, true).await?;
414 session.close().await;
415 Ok(text_content("ok"))
416 })
417 }),
418 }
419}
420
421fn make_wait_for_cookie() -> RegisteredTool {
426 RegisteredTool {
427 name: "wait_for_cookie".into(),
428 description: "Poll the browser until a cookie matching the regex filters appears, or \
429 timeout elapses."
430 .into(),
431 input_schema: json!({
432 "type": "object",
433 "properties": {
434 "domain": { "type": "string", "description": "Unanchored regex on cookie domain." },
435 "name": { "type": "string", "description": "Unanchored regex on cookie name." },
436 "timeout_seconds": { "type": "number", "default": 120 },
437 "poll_interval_seconds": { "type": "number", "default": 1 }
438 },
439 "required": ["domain", "name"],
440 }),
441 handler: handler(|state, args| {
442 Box::pin(async move {
443 let domain = args
444 .get("domain")
445 .and_then(|v| v.as_str())
446 .ok_or_else(|| anyhow!("missing 'domain'"))?;
447 let name = args
448 .get("name")
449 .and_then(|v| v.as_str())
450 .ok_or_else(|| anyhow!("missing 'name'"))?;
451 let domain_re =
452 Regex::new(domain).map_err(|e| anyhow!("invalid `domain` regex: {e}"))?;
453 let name_re = Regex::new(name).map_err(|e| anyhow!("invalid `name` regex: {e}"))?;
454 let timeout_s = args
455 .get("timeout_seconds")
456 .and_then(|v| v.as_f64())
457 .unwrap_or(120.0)
458 .max(0.0);
459 let interval_s = args
460 .get("poll_interval_seconds")
461 .and_then(|v| v.as_f64())
462 .unwrap_or(1.0)
463 .max(0.001);
464 let deadline = Instant::now() + Duration::from_secs_f64(timeout_s);
465 let interval = Duration::from_secs_f64(interval_s);
466 loop {
467 let cookies = fetch_cookies(&state.browser).await?;
468 if let Some(c) = cookies
469 .into_iter()
470 .find(|c| cookie_matches(c, &domain_re, &name_re))
471 {
472 return Ok(text_content(c.name));
473 }
474 let now = Instant::now();
475 if now >= deadline {
476 return Err(anyhow!("timed out waiting for cookie"));
477 }
478 let remaining = deadline.saturating_duration_since(now);
479 let nap = std::cmp::min(interval, remaining);
480 if nap.is_zero() {
481 return Err(anyhow!("timed out waiting for cookie"));
482 }
483 tokio::time::sleep(nap).await;
484 }
485 })
486 }),
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 const EXPECTED_TOOLS: &[&str] = &[
495 "navigate",
496 "get_dom",
497 "screenshot",
498 "fetch",
499 "select_element",
500 "list_targets",
501 "cookies",
502 "storage_get",
503 "storage_set",
504 "wait_for_cookie",
505 ];
506
507 fn schema_for(name: &str) -> Value {
508 let registry = ToolRegistry::new();
509 register_all(®istry);
510 registry
511 .list()
512 .into_iter()
513 .find(|t| t["name"] == name)
514 .unwrap_or_else(|| panic!("tool {name} not registered"))["inputSchema"]
515 .clone()
516 }
517
518 #[test]
519 fn register_all_adds_ten_tools() {
520 let registry = ToolRegistry::new();
521 register_all(®istry);
522 let list = registry.list();
523 assert_eq!(list.len(), 10);
524 let names: Vec<&str> = list.iter().map(|t| t["name"].as_str().unwrap()).collect();
525 for expected in EXPECTED_TOOLS {
526 assert!(
527 names.contains(expected),
528 "missing tool {expected} in {names:?}"
529 );
530 }
531 }
532
533 #[test]
534 fn every_tool_has_object_input_schema() {
535 let registry = ToolRegistry::new();
536 register_all(®istry);
537 for t in registry.list() {
538 let schema = &t["inputSchema"];
539 assert!(schema.is_object(), "schema not object: {schema}");
540 assert_eq!(
541 schema["type"], "object",
542 "schema type != object for {}: {schema}",
543 t["name"]
544 );
545 }
546 }
547
548 #[test]
549 fn list_targets_schema_has_optional_filter() {
550 let schema = schema_for("list_targets");
551 assert_eq!(schema["properties"]["filter"]["type"], "string");
552 assert!(
553 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
554 );
555 }
556
557 #[test]
558 fn cookies_schema_has_optional_filters() {
559 let schema = schema_for("cookies");
560 assert_eq!(schema["properties"]["domain"]["type"], "string");
561 assert_eq!(schema["properties"]["name"]["type"], "string");
562 assert!(
563 schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
564 );
565 }
566
567 #[test]
568 fn storage_get_requires_key() {
569 let schema = schema_for("storage_get");
570 let required = schema["required"].as_array().expect("required array");
571 assert!(required.iter().any(|v| v == "key"));
572 assert_eq!(schema["properties"]["key"]["type"], "string");
573 assert_eq!(schema["properties"]["namespace"]["type"], "string");
574 }
575
576 #[test]
577 fn storage_set_requires_key_and_value() {
578 let schema = schema_for("storage_set");
579 let required: Vec<&str> = schema["required"]
580 .as_array()
581 .unwrap()
582 .iter()
583 .map(|v| v.as_str().unwrap())
584 .collect();
585 assert!(required.contains(&"key"));
586 assert!(required.contains(&"value"));
587 assert_eq!(schema["properties"]["value"]["type"], "string");
588 }
589
590 #[test]
591 fn wait_for_cookie_requires_domain_and_name() {
592 let schema = schema_for("wait_for_cookie");
593 let required: Vec<&str> = schema["required"]
594 .as_array()
595 .unwrap()
596 .iter()
597 .map(|v| v.as_str().unwrap())
598 .collect();
599 assert!(required.contains(&"domain"));
600 assert!(required.contains(&"name"));
601 assert_eq!(schema["properties"]["timeout_seconds"]["type"], "number");
602 assert_eq!(
603 schema["properties"]["poll_interval_seconds"]["type"],
604 "number"
605 );
606 }
607}