1use std::collections::{BTreeMap, HashMap};
2use std::env;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::Duration;
6
7use anyhow::{anyhow, bail, Context, Result};
8use reqwest::header::{HeaderName, HeaderValue};
9use rmcp::handler::client::ClientHandler;
10use rmcp::model::{CallToolRequestParams, ClientInfo, Implementation, ListRootsResult, Root, Tool};
11use rmcp::service::{RoleClient, RunningService};
12use rmcp::transport::child_process::TokioChildProcess;
13use rmcp::transport::streamable_http_client::{
14 StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
15};
16use rmcp::ServiceExt;
17use serde::Deserialize;
18use serde_json::Value;
19use tokio::process::Command;
20use tokio::time::timeout;
21use url::Url;
22
23use crate::paths::sac_config_path;
24use crate::sandbox::SandboxSession;
25use crate::tools::ToolResult;
26use crate::types::{FunctionDef, ToolDefinition};
27
28mod config;
29mod naming;
30mod registry;
31mod result;
32mod transport;
33
34pub use registry::McpRegistry;
35
36use config::*;
37use naming::*;
38use registry::*;
39use result::*;
40use transport::*;
41
42type McpService = RunningService<RoleClient, NacMcpClientHandler>;
43const MCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
44const MCP_TOOL_INVENTORY_TIMEOUT: Duration = Duration::from_secs(15);
45const MCP_TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50 use crate::test_env_lock;
51 use std::fs;
52 use std::time::{SystemTime, UNIX_EPOCH};
53
54 #[test]
55 fn sanitize_identifier_collapses_symbols() {
56 assert_eq!(sanitize_identifier("GitHub.com"), "github_com");
57 assert_eq!(sanitize_identifier("search/issues"), "search_issues");
58 }
59
60 #[test]
61 fn env_expansion_replaces_placeholders() {
62 let _guard = test_env_lock();
63 let original = env::var("SAC_MCP_TEST").ok();
64 unsafe {
65 env::set_var("SAC_MCP_TEST", "expanded");
66 }
67
68 let expanded = expand_env("Bearer ${SAC_MCP_TEST}").unwrap();
69 assert_eq!(expanded, "Bearer expanded");
70
71 if let Some(value) = original {
72 unsafe {
73 env::set_var("SAC_MCP_TEST", value);
74 }
75 } else {
76 unsafe {
77 env::remove_var("SAC_MCP_TEST");
78 }
79 }
80 }
81
82 #[test]
83 fn allocate_tool_name_suffixes_collisions() {
84 let mut seen = HashMap::new();
85 assert_eq!(
86 allocate_tool_name("github", "search/issues", &mut seen),
87 "mcp__github__search_issues"
88 );
89 assert_eq!(
90 allocate_tool_name("github", "search-issues", &mut seen),
91 "mcp__github__search_issues__2"
92 );
93 }
94
95 #[test]
96 fn tool_definition_uses_namespaced_name() {
97 let tool = Tool::new(
98 "search_issues",
99 "Search issues",
100 serde_json::Map::<String, Value>::new(),
101 );
102 let definition = tool_definition("mcp__github__search_issues", "github", &tool);
103 assert_eq!(definition.function.name, "mcp__github__search_issues");
104 assert_eq!(definition.function.description, "Search issues");
105 }
106
107 #[tokio::test]
108 async fn invalid_global_config_disables_mcp_instead_of_failing() {
109 let _guard = test_env_lock();
110 let original_sac_home = env::var_os("SAC_HOME");
111 let original_xdg = env::var_os("XDG_CONFIG_HOME");
112 let unique = SystemTime::now()
113 .duration_since(UNIX_EPOCH)
114 .unwrap()
115 .as_nanos();
116 let sac_home = std::env::temp_dir().join(format!("sac-mcp-test-{unique}"));
117 fs::create_dir_all(&sac_home).unwrap();
118 fs::write(sac_home.join("config.toml"), "=\n").unwrap();
119
120 unsafe {
121 env::set_var("SAC_HOME", &sac_home);
122 }
123
124 let cwd = std::env::current_dir().unwrap();
125 let registry = McpRegistry::load(&cwd, None).await.unwrap();
126 assert!(registry.is_none());
127
128 if let Some(value) = original_sac_home {
129 unsafe {
130 env::set_var("SAC_HOME", value);
131 }
132 } else {
133 unsafe {
134 env::remove_var("SAC_HOME");
135 }
136 }
137
138 if let Some(value) = original_xdg {
139 unsafe {
140 env::set_var("XDG_CONFIG_HOME", value);
141 }
142 } else {
143 unsafe {
144 env::remove_var("XDG_CONFIG_HOME");
145 }
146 }
147
148 let _ = fs::remove_dir_all(&sac_home);
149 }
150}