Skip to main content

synaps_cli/mcp/
mod.rs

1//! MCP (Model Context Protocol) integration — JSON-RPC client, tool bridging, lazy loading.
2mod connection;
3mod tool;
4mod lazy;
5
6use std::collections::HashMap;
7use std::sync::Arc;
8use tokio::sync::Mutex;
9use crate::ToolRegistry;
10
11pub use tool::McpTool;
12pub use lazy::McpConnectTool;
13
14/// MCP server configuration — matches claude-code/gemini-cli format.
15#[derive(Debug, Clone, serde::Deserialize)]
16pub struct McpServerConfig {
17    pub command: String,
18    #[serde(default)]
19    pub args: Vec<String>,
20    #[serde(default)]
21    pub env: HashMap<String, String>,
22}
23
24/// MCP config file format: { "mcpServers": { "name": { command, args, env } } }
25#[derive(Debug, Clone, serde::Deserialize)]
26pub struct McpConfig {
27    #[serde(rename = "mcpServers", default)]
28    pub mcp_servers: HashMap<String, McpServerConfig>,
29}
30
31/// Discovered tool definition from an MCP server.
32#[derive(Debug, Clone)]
33pub(crate) struct McpToolDef {
34    pub(crate) name: String,
35    pub(crate) description: String,
36    pub(crate) input_schema: serde_json::Value,
37}
38
39/// Load MCP config from ~/.synaps-cli/mcp.json (or profile variant).
40pub fn load_mcp_config() -> Option<McpConfig> {
41    let path = crate::config::resolve_read_path("mcp.json");
42    if !path.exists() {
43        return None;
44    }
45    
46    let content = std::fs::read_to_string(&path).ok()?;
47    match serde_json::from_str::<McpConfig>(&content) {
48        Ok(config) => Some(config),
49        Err(e) => {
50            tracing::warn!("Failed to parse MCP config at {}: {}", path.display(), e);
51            None
52        }
53    }
54}
55
56/// Connect to all configured MCP servers and register their tools.
57/// Returns the number of tools registered.
58pub async fn connect_mcp_servers(registry: &mut ToolRegistry) -> usize {
59    let config = match load_mcp_config() {
60        Some(c) => c,
61        None => return 0,
62    };
63    
64    let mut total_tools = 0;
65    
66    for (server_name, server_config) in &config.mcp_servers {
67        tracing::info!(server = %server_name, command = %server_config.command, "Connecting to MCP server");
68        
69        match connection::McpConnection::start(server_config).await {
70            Ok(mut conn) => {
71                match conn.list_tools().await {
72                    Ok(tools) => {
73                        let tool_count = tools.len();
74                        let connection = Arc::new(Mutex::new(conn));
75                        
76                        for tool_def in tools {
77                            // Prefix tool names with server name to avoid collisions
78                            // e.g. "filesystem__read_file" for server "filesystem"
79                            let prefixed_name = format!("ext__{}__{}", server_name, tool_def.name);
80                            
81                            let mcp_tool = McpTool {
82                                tool_name: prefixed_name,
83                                server_tool_name: tool_def.name.clone(),
84                                server_name: server_name.clone(),
85                                description: format!("[MCP:{}] {}", server_name, tool_def.description),
86                                input_schema: tool_def.input_schema,
87                                connection: Arc::clone(&connection),
88                            };
89                            
90                            registry.register(Arc::new(mcp_tool));
91                            total_tools += 1;
92                        }
93                        
94                        tracing::info!(
95                            server = %server_name,
96                            tools = tool_count,
97                            "MCP server connected — {} tools registered",
98                            tool_count
99                        );
100                    }
101                    Err(e) => {
102                        tracing::error!(server = %server_name, error = %e, "Failed to list MCP tools");
103                    }
104                }
105            }
106            Err(e) => {
107                tracing::error!(server = %server_name, error = %e, "Failed to connect to MCP server");
108            }
109        }
110    }
111    
112    total_tools
113}
114
115/// Set up lazy MCP loading: parse config, register the connect_mcp_server gateway tool.
116/// Returns the number of available (but not yet connected) servers.
117pub async fn setup_lazy_mcp(registry: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>) -> usize {
118    let config = match load_mcp_config() {
119        Some(c) => c,
120        None => return 0,
121    };
122
123    let server_count = config.mcp_servers.len();
124    if server_count == 0 {
125        return 0;
126    }
127
128    let server_names: Vec<&str> = config.mcp_servers.keys().map(|s| s.as_str()).collect();
129    tracing::info!(servers = ?server_names, "MCP lazy loading: {} servers available", server_count);
130
131    let connect_tool = McpConnectTool::new(
132        config.mcp_servers,
133    );
134
135    registry.write().await.register(Arc::new(connect_tool));
136
137    server_count
138}
139
140#[cfg(test)]
141mod tests {
142    use serde_json::json;
143    use super::*;
144
145    #[test]
146    fn test_mcp_config_deserialize() {
147        let json_str = r#"{"mcpServers": {"test": {"command": "echo", "args": ["hi"]}}}"#;
148        let config: McpConfig = serde_json::from_str(json_str).unwrap();
149        
150        assert_eq!(config.mcp_servers.len(), 1);
151        assert!(config.mcp_servers.contains_key("test"));
152        
153        let server = &config.mcp_servers["test"];
154        assert_eq!(server.command, "echo");
155        assert_eq!(server.args, vec!["hi"]);
156    }
157
158    #[test]
159    fn test_mcp_config_empty_servers() {
160        let json_str = r#"{"mcpServers": {}}"#;
161        let config: McpConfig = serde_json::from_str(json_str).unwrap();
162        
163        assert_eq!(config.mcp_servers.len(), 0);
164        assert!(config.mcp_servers.is_empty());
165    }
166
167    #[test]
168    fn test_mcp_server_config_defaults() {
169        let json_str = r#"{"command": "echo"}"#;
170        let server_config: McpServerConfig = serde_json::from_str(json_str).unwrap();
171        
172        assert_eq!(server_config.command, "echo");
173        assert_eq!(server_config.args, Vec::<String>::new());
174        assert_eq!(server_config.env, HashMap::new());
175    }
176
177    #[test]
178    fn test_mcp_config_deserialize_from_value() {
179        let json_value = json!({
180            "mcpServers": {
181                "test": {
182                    "command": "echo",
183                    "args": ["hi"]
184                }
185            }
186        });
187        
188        let config: McpConfig = serde_json::from_value(json_value).unwrap();
189        
190        assert_eq!(config.mcp_servers.len(), 1);
191        assert!(config.mcp_servers.contains_key("test"));
192        
193        let server = &config.mcp_servers["test"];
194        assert_eq!(server.command, "echo");
195        assert_eq!(server.args, vec!["hi"]);
196    }
197
198    #[test]
199    fn test_load_mcp_config_returns_some_or_none() {
200        // This test verifies that load_mcp_config() returns either Some or None
201        // depending on whether the config file exists
202        let result = load_mcp_config();
203        
204        // Result can be either Some(config) or None - both are valid
205        // depending on whether ~/.synaps-cli/mcp.json exists
206        match result {
207            Some(_config) => {
208                // If file exists and parses correctly, we get a config
209                // (mcp_servers can be empty — that's valid)
210            }
211            None => {
212                // If file doesn't exist or fails to parse, we get None
213                // This is expected behavior
214            }
215        }
216    }
217}