Skip to main content

browser_control/mcp/
tools.rs

1//! MCP tools exposed by the `browser-control mcp` server.
2//!
3//! Ten tools wrap the underlying `session::PageSession` and related helpers:
4//! `navigate`, `get_dom`, `screenshot`, `fetch`, `select_element`,
5//! `list_targets`, `cookies`, `storage_get`, `storage_set`, `wait_for_cookie`.
6
7use 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
22/// Register the standard tool set onto the given registry.
23pub 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
36// ---------------------------------------------------------------------------
37// Engine dispatch helpers.
38// ---------------------------------------------------------------------------
39
40/// Open (or return the cached) page session for the configured browser.
41///
42/// CDP sessions are short-lived: we open and close a fresh WebSocket per
43/// call. BiDi sessions reuse one persistent client, because Firefox limits
44/// a browser instance to a single concurrent BiDi session.
45async 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
83// ---------------------------------------------------------------------------
84// navigate
85// ---------------------------------------------------------------------------
86
87fn 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
112// ---------------------------------------------------------------------------
113// get_dom
114// ---------------------------------------------------------------------------
115
116fn 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
148// ---------------------------------------------------------------------------
149// screenshot
150// ---------------------------------------------------------------------------
151
152fn 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
178// ---------------------------------------------------------------------------
179// fetch
180// ---------------------------------------------------------------------------
181
182fn 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
219// ---------------------------------------------------------------------------
220// select_element
221// ---------------------------------------------------------------------------
222
223fn 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
246// ---------------------------------------------------------------------------
247// list_targets
248// ---------------------------------------------------------------------------
249
250fn 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
282// ---------------------------------------------------------------------------
283// cookies
284// ---------------------------------------------------------------------------
285
286fn 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
327// ---------------------------------------------------------------------------
328// storage_get / storage_set
329// ---------------------------------------------------------------------------
330
331fn 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                // `build_get_expr` wraps the result in JSON.stringify, so the
364                // evaluator returns a JSON string. Unwrap one layer to surface
365                // the raw value (or `null` when the key is absent).
366                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
421// ---------------------------------------------------------------------------
422// wait_for_cookie
423// ---------------------------------------------------------------------------
424
425fn 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(&registry);
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(&registry);
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(&registry);
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}