Skip to main content

agent_engine/mcp/
mod.rs

1//! MCP (Model Context Protocol) integration — JSON-RPC client, tool bridging, lazy loading.
2mod connection;
3pub mod descriptors;
4mod lazy;
5pub mod lease;
6mod tool;
7
8use crate::ToolRegistry;
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13pub use lazy::McpConnectTool;
14pub use lease::{McpLeaseCapability, McpRuntimeManager, McpSessionEndGuard};
15pub use tool::McpTool;
16
17/// MCP server configuration — matches claude-code/gemini-cli format.
18#[derive(Debug, Clone, serde::Deserialize)]
19pub struct McpServerConfig {
20    pub command: String,
21    #[serde(default)]
22    pub args: Vec<String>,
23    #[serde(default)]
24    pub env: HashMap<String, String>,
25}
26
27/// MCP config file format: { "mcpServers": { "name": { command, args, env } } }
28#[derive(Debug, Clone, serde::Deserialize)]
29pub struct McpConfig {
30    #[serde(rename = "mcpServers", default)]
31    pub mcp_servers: HashMap<String, McpServerConfig>,
32}
33
34/// Discovered tool definition from an MCP server.
35#[derive(Debug, Clone)]
36pub(crate) struct McpToolDef {
37    pub(crate) name: String,
38    pub(crate) description: String,
39    pub(crate) input_schema: serde_json::Value,
40}
41
42/// Hard byte bound on `mcp.json`.
43pub const MCP_CONFIG_MAX_BYTES: u64 = 1024 * 1024;
44
45/// Load MCP config from ~/.synaps-cli/mcp.json (or profile variant).
46///
47/// Bounded local read (Task 19): one `O_NOFOLLOW|O_NONBLOCK` handle,
48/// opened-metadata regular-file check, capped read with growth rejection —
49/// symlinks, FIFOs, and oversized files are refused. Failures log only the
50/// static file name and bounded typed metadata, never raw path or parse
51/// content. Semantics are otherwise unchanged: any rejection yields `None`.
52pub fn load_mcp_config() -> Option<McpConfig> {
53    let path = crate::config::resolve_read_path("mcp.json");
54    let content = match descriptors::read_bounded_regular_file(&path, MCP_CONFIG_MAX_BYTES) {
55        Ok(content) => content,
56        Err(descriptors::DescriptorCacheError::NotFound) => return None,
57        Err(err) => {
58            // Static category only: Io Display strings can embed paths.
59            let category = match err {
60                descriptors::DescriptorCacheError::NotFound => "not_found",
61                descriptors::DescriptorCacheError::NotRegularFile => "not_regular_file",
62                descriptors::DescriptorCacheError::Oversize { .. } => "oversize",
63                descriptors::DescriptorCacheError::Io(_) => "io",
64                descriptors::DescriptorCacheError::Parse(_) => "parse",
65                descriptors::DescriptorCacheError::Version(_) => "version",
66            };
67            tracing::warn!(
68                file = "mcp.json",
69                category,
70                "Refusing unsafe MCP config read"
71            );
72            return None;
73        }
74    };
75    let config = match serde_json::from_str::<McpConfig>(&content) {
76        Ok(config) => config,
77        Err(e) => {
78            // Numeric/class parse metadata ONLY: serde Display can embed
79            // hostile key/value excerpts.
80            tracing::warn!(
81                file = "mcp.json",
82                class = ?e.classify(),
83                line = e.line(),
84                column = e.column(),
85                "Failed to parse MCP config"
86            );
87            return None;
88        }
89    };
90    if let Err(reason) = validate_mcp_config(&config) {
91        // The WHOLE config fails closed on any structural violation.
92        tracing::warn!(
93            file = "mcp.json",
94            reason,
95            "Refusing structurally unsafe MCP config"
96        );
97        return None;
98    }
99    Some(config)
100}
101
102/// Structural bounds on `mcp.json` (Task 19 review): the 1 MiB byte cap
103/// alone leaves server/env counts and string shapes unbounded. Violations
104/// fail the WHOLE config closed with a static reason.
105pub const MCP_CONFIG_MAX_SERVERS: usize = 32;
106pub const MCP_CONFIG_MAX_ARGS: usize = 64;
107pub const MCP_CONFIG_MAX_ENV_ENTRIES: usize = 64;
108pub const MCP_CONFIG_MAX_SERVER_NAME_BYTES: usize = 64;
109pub const MCP_CONFIG_MAX_COMMAND_BYTES: usize = 512;
110pub const MCP_CONFIG_MAX_ARG_BYTES: usize = 4096;
111pub const MCP_CONFIG_MAX_ENV_KEY_BYTES: usize = 128;
112pub const MCP_CONFIG_MAX_ENV_VALUE_BYTES: usize = 4096;
113
114fn safe_str(value: &str, max: usize) -> bool {
115    !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
116}
117
118/// Validate config structure before ANY use. Static reasons only — no
119/// config content is ever echoed.
120pub fn validate_mcp_config(config: &McpConfig) -> Result<(), &'static str> {
121    if config.mcp_servers.len() > MCP_CONFIG_MAX_SERVERS {
122        return Err("too_many_servers");
123    }
124    for (name, server) in &config.mcp_servers {
125        if !safe_str(name, MCP_CONFIG_MAX_SERVER_NAME_BYTES) {
126            return Err("invalid_server_name");
127        }
128        if !safe_str(&server.command, MCP_CONFIG_MAX_COMMAND_BYTES) {
129            return Err("invalid_command");
130        }
131        if server.args.len() > MCP_CONFIG_MAX_ARGS {
132            return Err("too_many_args");
133        }
134        for arg in &server.args {
135            if arg.len() > MCP_CONFIG_MAX_ARG_BYTES || arg.chars().any(char::is_control) {
136                return Err("invalid_arg");
137            }
138        }
139        if server.env.len() > MCP_CONFIG_MAX_ENV_ENTRIES {
140            return Err("too_many_env_entries");
141        }
142        for (key, value) in &server.env {
143            if !safe_str(key, MCP_CONFIG_MAX_ENV_KEY_BYTES) {
144                return Err("invalid_env_key");
145            }
146            if value.len() > MCP_CONFIG_MAX_ENV_VALUE_BYTES || value.chars().any(char::is_control) {
147                return Err("invalid_env_value");
148            }
149        }
150    }
151    Ok(())
152}
153
154/// Connect to all configured MCP servers and register their tools.
155/// Returns the number of tools registered.
156pub async fn connect_mcp_servers(registry: &mut ToolRegistry) -> usize {
157    let config = match load_mcp_config() {
158        Some(c) => c,
159        None => return 0,
160    };
161
162    let mut total_tools = 0;
163
164    for (server_name, server_config) in &config.mcp_servers {
165        tracing::info!(server = %server_name, command = %server_config.command, "Connecting to MCP server");
166
167        match connection::McpConnection::start(server_config).await {
168            Ok(mut conn) => {
169                match conn.list_tools().await {
170                    Ok(tools) => {
171                        let tool_count = tools.len();
172                        let connection = Arc::new(Mutex::new(conn));
173
174                        for tool_def in tools {
175                            // Prefix tool names with server name to avoid collisions
176                            // e.g. "filesystem__read_file" for server "filesystem"
177                            let prefixed_name = format!("ext__{}__{}", server_name, tool_def.name);
178                            let tool_name_for_log = prefixed_name.clone();
179
180                            let mcp_tool = McpTool {
181                                tool_name: prefixed_name,
182                                server_tool_name: tool_def.name.clone(),
183                                server_name: server_name.clone(),
184                                description: format!(
185                                    "[MCP:{}] {}",
186                                    server_name, tool_def.description
187                                ),
188                                input_schema: tool_def.input_schema,
189                                connection: Arc::clone(&connection),
190                            };
191
192                            if let Err(e) = registry.try_register(Arc::new(mcp_tool)) {
193                                tracing::warn!(
194                                    server = %server_name,
195                                    tool = %tool_name_for_log,
196                                    error = %e,
197                                    "Refusing to expose MCP tool the capability catalog could not record"
198                                );
199                                continue;
200                            }
201                            total_tools += 1;
202                        }
203
204                        tracing::info!(
205                            server = %server_name,
206                            tools = tool_count,
207                            "MCP server connected — {} tools registered",
208                            tool_count
209                        );
210                    }
211                    Err(e) => {
212                        tracing::error!(server = %server_name, error = %e, "Failed to list MCP tools");
213                    }
214                }
215            }
216            Err(e) => {
217                tracing::error!(server = %server_name, error = %e, "Failed to connect to MCP server");
218            }
219        }
220    }
221
222    total_tools
223}
224
225/// Set up MCP loading at engine boot, before any session tool set exists.
226///
227/// Flag-off (`progressive_exact_only == false`): the legacy lazy path is
228/// preserved byte-for-byte — register the `connect_mcp_server` gateway and
229/// return the number of available servers.
230///
231/// Flag-on (Task 19, spec §7.4): NO gateway is registered (a server-wide
232/// connect would bypass exact per-tool activation), and cached descriptors
233/// become dormant deferred registry entries instead: searchable, exactly
234/// activatable, execution-gated, spawning nothing until a leased execution.
235/// A server without a fingerprint-matching cache entry contributes no
236/// capabilities — descriptors are never invented from config alone and no
237/// process is started to discover them. Returns the number of dormant tools
238/// registered. The whole dormant batch registers atomically; on failure the
239/// registry is unchanged and MCP capabilities fail closed to zero.
240pub async fn setup_lazy_mcp(
241    registry: &Arc<tokio::sync::RwLock<crate::ToolRegistry>>,
242    progressive_exact_only: bool,
243) -> usize {
244    let config = match load_mcp_config() {
245        Some(c) => c,
246        None => return 0,
247    };
248
249    let server_count = config.mcp_servers.len();
250    if server_count == 0 {
251        return 0;
252    }
253
254    if progressive_exact_only {
255        let cache = match descriptors::load_default_cache() {
256            Ok(cache) => cache,
257            Err(descriptors::DescriptorCacheError::NotFound) => {
258                descriptors::McpDescriptorCache::empty()
259            }
260            Err(err) => {
261                tracing::warn!(error = %err,
262                    "Refusing unsafe MCP descriptor cache; MCP capabilities stay undiscoverable this run");
263                descriptors::McpDescriptorCache::empty()
264            }
265        };
266        let dormant = descriptors::dormant_tools_for_config(&config, &cache);
267        if dormant.is_empty() {
268            tracing::info!(
269                servers = server_count,
270                "MCP exact mode: no valid cached descriptors; MCP tools are not discoverable this run"
271            );
272            return 0;
273        }
274        return match registry.write().await.try_register_batch(dormant) {
275            Ok(count) => {
276                tracing::info!(
277                    tools = count,
278                    servers = server_count,
279                    "MCP exact mode: dormant descriptor-backed tools registered (no process started)"
280                );
281                count
282            }
283            Err(e) => {
284                tracing::warn!(error = %e,
285                    "MCP dormant descriptor batch rejected; failing closed with zero MCP capabilities");
286                0
287            }
288        };
289    }
290
291    let server_names: Vec<&str> = config.mcp_servers.keys().map(|s| s.as_str()).collect();
292    tracing::info!(servers = ?server_names, "MCP lazy loading: {} servers available", server_count);
293
294    let connect_tool = McpConnectTool::new(config.mcp_servers);
295
296    if let Err(e) = registry.write().await.try_register(Arc::new(connect_tool)) {
297        tracing::warn!(error = %e, "Refusing to expose MCP gateway the capability catalog could not record");
298        return 0;
299    }
300
301    server_count
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_json::json;
308
309    #[test]
310    fn test_mcp_config_deserialize() {
311        let json_str = r#"{"mcpServers": {"test": {"command": "echo", "args": ["hi"]}}}"#;
312        let config: McpConfig = serde_json::from_str(json_str).unwrap();
313
314        assert_eq!(config.mcp_servers.len(), 1);
315        assert!(config.mcp_servers.contains_key("test"));
316
317        let server = &config.mcp_servers["test"];
318        assert_eq!(server.command, "echo");
319        assert_eq!(server.args, vec!["hi"]);
320    }
321
322    #[test]
323    fn test_mcp_config_empty_servers() {
324        let json_str = r#"{"mcpServers": {}}"#;
325        let config: McpConfig = serde_json::from_str(json_str).unwrap();
326
327        assert_eq!(config.mcp_servers.len(), 0);
328        assert!(config.mcp_servers.is_empty());
329    }
330
331    #[test]
332    fn test_mcp_server_config_defaults() {
333        let json_str = r#"{"command": "echo"}"#;
334        let server_config: McpServerConfig = serde_json::from_str(json_str).unwrap();
335
336        assert_eq!(server_config.command, "echo");
337        assert_eq!(server_config.args, Vec::<String>::new());
338        assert_eq!(server_config.env, HashMap::new());
339    }
340
341    #[test]
342    fn test_mcp_config_deserialize_from_value() {
343        let json_value = json!({
344            "mcpServers": {
345                "test": {
346                    "command": "echo",
347                    "args": ["hi"]
348                }
349            }
350        });
351
352        let config: McpConfig = serde_json::from_value(json_value).unwrap();
353
354        assert_eq!(config.mcp_servers.len(), 1);
355        assert!(config.mcp_servers.contains_key("test"));
356
357        let server = &config.mcp_servers["test"];
358        assert_eq!(server.command, "echo");
359        assert_eq!(server.args, vec!["hi"]);
360    }
361
362    fn base_server() -> McpServerConfig {
363        McpServerConfig {
364            command: "/bin/true".to_string(),
365            args: vec!["--flag".to_string()],
366            env: HashMap::from([("KEY".to_string(), "value".to_string())]),
367        }
368    }
369
370    fn config_of(servers: Vec<(&str, McpServerConfig)>) -> McpConfig {
371        McpConfig {
372            mcp_servers: servers
373                .into_iter()
374                .map(|(k, v)| (k.to_string(), v))
375                .collect(),
376        }
377    }
378
379    #[test]
380    fn validate_mcp_config_accepts_sane_configs() {
381        assert!(validate_mcp_config(&config_of(vec![("srv", base_server())])).is_ok());
382        assert!(validate_mcp_config(&config_of(vec![])).is_ok());
383    }
384
385    #[test]
386    fn validate_mcp_config_fails_whole_config_closed_on_structural_violations() {
387        // Too many servers.
388        let many: Vec<(String, McpServerConfig)> = (0..=MCP_CONFIG_MAX_SERVERS)
389            .map(|i| (format!("s{i}"), base_server()))
390            .collect();
391        let config = McpConfig {
392            mcp_servers: many.into_iter().collect(),
393        };
394        assert_eq!(validate_mcp_config(&config), Err("too_many_servers"));
395
396        // Hostile server name (control chars) and oversized name.
397        let mut c = base_server();
398        assert_eq!(
399            validate_mcp_config(&config_of(vec![("evil\u{7}", c.clone())])),
400            Err("invalid_server_name")
401        );
402        let long_name = "n".repeat(MCP_CONFIG_MAX_SERVER_NAME_BYTES + 1);
403        assert_eq!(
404            validate_mcp_config(&config_of(vec![(long_name.as_str(), c.clone())])),
405            Err("invalid_server_name")
406        );
407
408        // Command: empty, oversized, control-bearing.
409        c.command = String::new();
410        assert_eq!(
411            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
412            Err("invalid_command")
413        );
414        c.command = "x".repeat(MCP_CONFIG_MAX_COMMAND_BYTES + 1);
415        assert_eq!(
416            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
417            Err("invalid_command")
418        );
419        c.command = "bad\u{0}cmd".to_string();
420        assert_eq!(
421            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
422            Err("invalid_command")
423        );
424
425        // Args: count and shape.
426        let mut c = base_server();
427        c.args = vec!["a".to_string(); MCP_CONFIG_MAX_ARGS + 1];
428        assert_eq!(
429            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
430            Err("too_many_args")
431        );
432        c.args = vec!["ok".to_string(), "bad\u{1b}arg".to_string()];
433        assert_eq!(
434            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
435            Err("invalid_arg")
436        );
437
438        // Env: entry count, key shape, value shape.
439        let mut c = base_server();
440        c.env = (0..=MCP_CONFIG_MAX_ENV_ENTRIES)
441            .map(|i| (format!("K{i}"), "v".to_string()))
442            .collect();
443        assert_eq!(
444            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
445            Err("too_many_env_entries")
446        );
447        c.env = HashMap::from([(String::new(), "v".to_string())]);
448        assert_eq!(
449            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
450            Err("invalid_env_key")
451        );
452        c.env = HashMap::from([(
453            "K".to_string(),
454            "v".repeat(MCP_CONFIG_MAX_ENV_VALUE_BYTES + 1),
455        )]);
456        assert_eq!(
457            validate_mcp_config(&config_of(vec![("srv", c.clone())])),
458            Err("invalid_env_value")
459        );
460    }
461
462    #[test]
463    fn test_load_mcp_config_returns_some_or_none() {
464        // This test verifies that load_mcp_config() returns either Some or None
465        // depending on whether the config file exists
466        let result = load_mcp_config();
467
468        // Result can be either Some(config) or None - both are valid
469        // depending on whether ~/.synaps-cli/mcp.json exists
470        match result {
471            Some(_config) => {
472                // If file exists and parses correctly, we get a config
473                // (mcp_servers can be empty — that's valid)
474            }
475            None => {
476                // If file doesn't exist or fails to parse, we get None
477                // This is expected behavior
478            }
479        }
480    }
481}