1mod 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#[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#[derive(Debug, Clone, serde::Deserialize)]
29pub struct McpConfig {
30 #[serde(rename = "mcpServers", default)]
31 pub mcp_servers: HashMap<String, McpServerConfig>,
32}
33
34#[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
42pub const MCP_CONFIG_MAX_BYTES: u64 = 1024 * 1024;
44
45pub 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 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 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 tracing::warn!(
93 file = "mcp.json",
94 reason,
95 "Refusing structurally unsafe MCP config"
96 );
97 return None;
98 }
99 Some(config)
100}
101
102pub 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
118pub 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
154pub 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 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
225pub 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 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 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 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 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 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 let result = load_mcp_config();
467
468 match result {
471 Some(_config) => {
472 }
475 None => {
476 }
479 }
480 }
481}