use std::collections::{BTreeMap, HashMap};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use reqwest::header::{HeaderName, HeaderValue};
use rmcp::handler::client::ClientHandler;
use rmcp::model::{CallToolRequestParams, ClientInfo, Implementation, ListRootsResult, Root, Tool};
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
};
use rmcp::ServiceExt;
use serde::Deserialize;
use serde_json::Value;
use tokio::process::Command;
use tokio::time::timeout;
use url::Url;
use crate::paths::sac_config_path;
use crate::sandbox::SandboxSession;
use crate::tools::ToolResult;
use crate::types::{FunctionDef, ToolDefinition};
mod config;
mod naming;
mod registry;
mod result;
mod transport;
pub use registry::McpRegistry;
use config::*;
use naming::*;
use registry::*;
use result::*;
use transport::*;
type McpService = RunningService<RoleClient, NacMcpClientHandler>;
const MCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const MCP_TOOL_INVENTORY_TIMEOUT: Duration = Duration::from_secs(15);
const MCP_TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env_lock;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn sanitize_identifier_collapses_symbols() {
assert_eq!(sanitize_identifier("GitHub.com"), "github_com");
assert_eq!(sanitize_identifier("search/issues"), "search_issues");
}
#[test]
fn env_expansion_replaces_placeholders() {
let _guard = test_env_lock();
let original = env::var("SAC_MCP_TEST").ok();
unsafe {
env::set_var("SAC_MCP_TEST", "expanded");
}
let expanded = expand_env("Bearer ${SAC_MCP_TEST}").unwrap();
assert_eq!(expanded, "Bearer expanded");
if let Some(value) = original {
unsafe {
env::set_var("SAC_MCP_TEST", value);
}
} else {
unsafe {
env::remove_var("SAC_MCP_TEST");
}
}
}
#[test]
fn allocate_tool_name_suffixes_collisions() {
let mut seen = HashMap::new();
assert_eq!(
allocate_tool_name("github", "search/issues", &mut seen),
"mcp__github__search_issues"
);
assert_eq!(
allocate_tool_name("github", "search-issues", &mut seen),
"mcp__github__search_issues__2"
);
}
#[test]
fn tool_definition_uses_namespaced_name() {
let tool = Tool::new(
"search_issues",
"Search issues",
serde_json::Map::<String, Value>::new(),
);
let definition = tool_definition("mcp__github__search_issues", "github", &tool);
assert_eq!(definition.function.name, "mcp__github__search_issues");
assert_eq!(definition.function.description, "Search issues");
}
#[tokio::test]
async fn invalid_global_config_disables_mcp_instead_of_failing() {
let _guard = test_env_lock();
let original_sac_home = env::var_os("SAC_HOME");
let original_xdg = env::var_os("XDG_CONFIG_HOME");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sac_home = std::env::temp_dir().join(format!("sac-mcp-test-{unique}"));
fs::create_dir_all(&sac_home).unwrap();
fs::write(sac_home.join("config.toml"), "=\n").unwrap();
unsafe {
env::set_var("SAC_HOME", &sac_home);
}
let cwd = std::env::current_dir().unwrap();
let registry = McpRegistry::load(&cwd, None).await.unwrap();
assert!(registry.is_none());
if let Some(value) = original_sac_home {
unsafe {
env::set_var("SAC_HOME", value);
}
} else {
unsafe {
env::remove_var("SAC_HOME");
}
}
if let Some(value) = original_xdg {
unsafe {
env::set_var("XDG_CONFIG_HOME", value);
}
} else {
unsafe {
env::remove_var("XDG_CONFIG_HOME");
}
}
let _ = fs::remove_dir_all(&sac_home);
}
}