Skip to main content

synaptic_browser/
lib.rs

1//! Browser automation tools for the Synaptic AI agent framework.
2//!
3//! This crate provides [`Tool`] implementations for browser automation.
4//! For production use, prefer the MCP browser integration which provides
5//! full CDP support via an MCP server.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use synaptic_core::{SynapticError, Tool};
13
14/// Browser configuration.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BrowserConfig {
17    /// Chrome DevTools debug URL.
18    #[serde(default = "default_debug_url")]
19    pub debug_url: String,
20}
21
22fn default_debug_url() -> String {
23    "http://localhost:9222".to_string()
24}
25
26impl Default for BrowserConfig {
27    fn default() -> Self {
28        Self {
29            debug_url: default_debug_url(),
30        }
31    }
32}
33
34/// Create all browser tools with the given config.
35pub fn browser_tools(config: &BrowserConfig) -> Vec<Arc<dyn Tool>> {
36    vec![
37        Arc::new(NavigateTool::new(config.clone())),
38        Arc::new(ScreenshotTool::new(config.clone())),
39        Arc::new(EvalJsTool::new(config.clone())),
40    ]
41}
42
43// ---------------------------------------------------------------------------
44// Navigate tool
45// ---------------------------------------------------------------------------
46
47pub struct NavigateTool {
48    #[allow(dead_code)]
49    config: BrowserConfig,
50}
51
52impl NavigateTool {
53    pub fn new(config: BrowserConfig) -> Self {
54        Self { config }
55    }
56}
57
58#[async_trait]
59impl Tool for NavigateTool {
60    fn name(&self) -> &'static str {
61        "browser_navigate"
62    }
63
64    fn description(&self) -> &'static str {
65        "Navigate the browser to a URL"
66    }
67
68    fn parameters(&self) -> Option<Value> {
69        Some(json!({
70            "type": "object",
71            "properties": {
72                "url": {
73                    "type": "string",
74                    "description": "The URL to navigate to"
75                }
76            },
77            "required": ["url"]
78        }))
79    }
80
81    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
82        let url = args["url"]
83            .as_str()
84            .ok_or_else(|| SynapticError::Tool("missing 'url' parameter".to_string()))?;
85
86        #[cfg(feature = "cdp")]
87        {
88            let client = reqwest::Client::new();
89            let resp = client
90                .get(format!("{}/json/new?{}", self.config.debug_url, url))
91                .send()
92                .await
93                .map_err(|e| SynapticError::Tool(format!("CDP navigate failed: {}", e)))?;
94
95            if resp.status().is_success() {
96                Ok(json!(format!("Navigated to {}", url)))
97            } else {
98                Err(SynapticError::Tool(format!(
99                    "CDP navigate error: {}",
100                    resp.status()
101                )))
102            }
103        }
104
105        #[cfg(not(feature = "cdp"))]
106        {
107            let _ = url;
108            Err(SynapticError::Tool(
109                "CDP feature not enabled. Enable 'cdp' feature or use MCP browser integration."
110                    .to_string(),
111            ))
112        }
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Screenshot tool
118// ---------------------------------------------------------------------------
119
120pub struct ScreenshotTool {
121    #[allow(dead_code)]
122    config: BrowserConfig,
123}
124
125impl ScreenshotTool {
126    pub fn new(config: BrowserConfig) -> Self {
127        Self { config }
128    }
129}
130
131#[async_trait]
132impl Tool for ScreenshotTool {
133    fn name(&self) -> &'static str {
134        "browser_screenshot"
135    }
136
137    fn description(&self) -> &'static str {
138        "Take a screenshot of the current browser page"
139    }
140
141    fn parameters(&self) -> Option<Value> {
142        Some(json!({
143            "type": "object",
144            "properties": {}
145        }))
146    }
147
148    async fn call(&self, _args: Value) -> Result<Value, SynapticError> {
149        #[cfg(feature = "cdp")]
150        {
151            // CDP screenshot requires WebSocket; simplified stub
152            Ok(json!("Screenshot capture requires full CDP WebSocket connection. Use MCP browser integration for full support."))
153        }
154
155        #[cfg(not(feature = "cdp"))]
156        {
157            Err(SynapticError::Tool(
158                "CDP feature not enabled. Enable 'cdp' feature or use MCP browser integration."
159                    .to_string(),
160            ))
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// EvalJs tool
167// ---------------------------------------------------------------------------
168
169pub struct EvalJsTool {
170    #[allow(dead_code)]
171    config: BrowserConfig,
172}
173
174impl EvalJsTool {
175    pub fn new(config: BrowserConfig) -> Self {
176        Self { config }
177    }
178}
179
180#[async_trait]
181impl Tool for EvalJsTool {
182    fn name(&self) -> &'static str {
183        "browser_eval_js"
184    }
185
186    fn description(&self) -> &'static str {
187        "Evaluate JavaScript in the browser page"
188    }
189
190    fn parameters(&self) -> Option<Value> {
191        Some(json!({
192            "type": "object",
193            "properties": {
194                "expression": {
195                    "type": "string",
196                    "description": "JavaScript expression to evaluate"
197                }
198            },
199            "required": ["expression"]
200        }))
201    }
202
203    async fn call(&self, args: Value) -> Result<Value, SynapticError> {
204        let _expression = args["expression"]
205            .as_str()
206            .ok_or_else(|| SynapticError::Tool("missing 'expression' parameter".to_string()))?;
207
208        #[cfg(feature = "cdp")]
209        {
210            // CDP eval requires WebSocket; simplified stub
211            Ok(json!("JavaScript evaluation requires full CDP WebSocket connection. Use MCP browser integration for full support."))
212        }
213
214        #[cfg(not(feature = "cdp"))]
215        {
216            Err(SynapticError::Tool(
217                "CDP feature not enabled. Enable 'cdp' feature or use MCP browser integration."
218                    .to_string(),
219            ))
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_browser_config_default() {
230        let config = BrowserConfig::default();
231        assert_eq!(config.debug_url, "http://localhost:9222");
232    }
233
234    #[test]
235    fn test_browser_tools_count() {
236        let config = BrowserConfig::default();
237        let tools = browser_tools(&config);
238        assert_eq!(tools.len(), 3);
239    }
240
241    #[test]
242    fn test_tool_names() {
243        let config = BrowserConfig::default();
244        let tools = browser_tools(&config);
245        let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
246        assert!(names.contains(&"browser_navigate"));
247        assert!(names.contains(&"browser_screenshot"));
248        assert!(names.contains(&"browser_eval_js"));
249    }
250
251    #[tokio::test]
252    async fn test_navigate_without_cdp() {
253        let tool = NavigateTool::new(BrowserConfig::default());
254        let result = tool.call(json!({"url": "https://example.com"})).await;
255        // Without CDP feature, should return error
256        #[cfg(not(feature = "cdp"))]
257        assert!(result.is_err());
258        #[cfg(feature = "cdp")]
259        let _ = result; // May fail if no Chrome running
260    }
261}