Skip to main content

sac/mcp/
mod.rs

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}