diff --git a/Cargo.lock b/Cargo.lock index 7fc6283d5..b38d46204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6137,6 +6137,7 @@ dependencies = [ "stakpak-api", "stakpak-gateway", "stakpak-mcp-client", + "stakpak-mcp-config", "stakpak-mcp-proxy", "stakpak-mcp-server", "stakpak-server", @@ -6248,6 +6249,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "stakpak-mcp-config" +version = "0.3.71" +dependencies = [ + "serde", + "serde_json", + "stakpak-shared", + "toml 0.8.23", +] + [[package]] name = "stakpak-mcp-proxy" version = "0.3.73" @@ -6261,6 +6272,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "stakpak-mcp-config", "stakpak-shared", "tokio", "toml 0.8.23", diff --git a/Cargo.toml b/Cargo.toml index 6b8b58a4a..a45a9ebad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "libs/server", "libs/gateway", "libs/mcp/client", + "libs/mcp/config", "libs/mcp/server", "libs/mcp/proxy", "libs/shell-tool-approvals", @@ -32,6 +33,7 @@ stakpak-api = { path = "libs/api", version = "0.3.73" } stakpak-mcp-server = { path = "libs/mcp/server", version = "0.3.73" } stakpak-mcp-client = { path = "libs/mcp/client", version = "0.3.73" } stakpak-mcp-proxy = { path = "libs/mcp/proxy", version = "0.3.73" } +stakpak-mcp-config = { path = "libs/mcp/config", version = "0.3.73" } stakpak-agent-core = { path = "libs/agent-core", version = "0.3.73" } stakpak-gateway = { path = "libs/gateway", version = "0.3.73" } stakpak-server = { path = "libs/server", version = "0.3.73" } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6b1c34d73..34b422469 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -18,6 +18,7 @@ libsql-test = [] stakpak-api = { workspace = true } stakpak-mcp-server = { workspace = true } stakpak-mcp-client = { workspace = true } +stakpak-mcp-config = { workspace = true } stakpak-mcp-proxy = { workspace = true } stakpak-tui = { workspace = true } stakpak-shared = { workspace = true, features = ["sqlite"] } diff --git a/cli/src/commands/agent/run/mcp_init.rs b/cli/src/commands/agent/run/mcp_init.rs index d84ec475b..521648502 100644 --- a/cli/src/commands/agent/run/mcp_init.rs +++ b/cli/src/commands/agent/run/mcp_init.rs @@ -205,9 +205,44 @@ fn build_proxy_config( }, ); + // Load external servers from config file (skip mcp_servers with reserved names) + if let Ok(config_path) = stakpak_mcp_config::find_config_file() { + match load_external_servers(&config_path) { + Ok(external_servers) => { + let mut loaded_servers = 0; + for (name, config) in external_servers { + if name == "stakpak" || name == "paks" { + tracing::warn!( + "Skipping external MCP server {} (reserved for stakpak's internal use)", + name + ); + continue; + } + loaded_servers += 1; + servers.insert(name, config); + } + tracing::info!( + "Loaded {} external MCP servers from {}", + loaded_servers, + config_path + ); + } + Err(e) => { + tracing::warn!("Failed to load MCP config from {}: {}", config_path, e); + } + } + } + ClientPoolConfig::with_servers(servers) } +/// Load external MCP servers from a config file (TOML or JSON). +fn load_external_servers(config_path: &str) -> Result, String> { + let config = stakpak_mcp_config::load_config(config_path.as_ref())?; + let pool_config = ClientPoolConfig::from(config); + Ok(pool_config.servers) +} + /// Start the proxy server async fn start_proxy( pool_config: ClientPoolConfig, diff --git a/cli/src/commands/mcp/mod.rs b/cli/src/commands/mcp/mod.rs index 7f956f5c8..6eac9f15e 100644 --- a/cli/src/commands/mcp/mod.rs +++ b/cli/src/commands/mcp/mod.rs @@ -1,60 +1,18 @@ -use std::path::PathBuf; +use std::collections::HashMap; use clap::Subcommand; use stakpak_mcp_server::ToolMode; use crate::config::AppConfig; +use stakpak_mcp_config::{ + McpServerEntry, add_server, find_config_file, load_config, remove_server, resolve_config_path, + save_config, set_server_disabled, +}; + pub mod proxy; pub mod server; -fn find_mcp_proxy_config_file() -> Result { - // Priority 1: ~/.stakpak/mcp.{toml,json} - let config_path = AppConfig::get_config_path::<&str>(None); - if let Some(home_stakpak) = config_path.parent() { - let home_toml = home_stakpak.join("mcp.toml"); - if home_toml.exists() { - return Ok(home_toml.to_string_lossy().to_string()); - } - - let home_json = home_stakpak.join("mcp.json"); - if home_json.exists() { - return Ok(home_json.to_string_lossy().to_string()); - } - } - - // Priority 2: .stakpak/mcp.{toml,json} in current directory - let cwd_stakpak = PathBuf::from(".stakpak"); - - let cwd_stakpak_toml = cwd_stakpak.join("mcp.toml"); - if cwd_stakpak_toml.exists() { - return Ok(cwd_stakpak_toml.to_string_lossy().to_string()); - } - - let cwd_stakpak_json = cwd_stakpak.join("mcp.json"); - if cwd_stakpak_json.exists() { - return Ok(cwd_stakpak_json.to_string_lossy().to_string()); - } - - // Priority 3: mcp.{toml,json} in current directory (fallback) - let cwd_toml = PathBuf::from("mcp.toml"); - if cwd_toml.exists() { - return Ok("mcp.toml".to_string()); - } - - let cwd_json = PathBuf::from("mcp.json"); - if cwd_json.exists() { - return Ok("mcp.json".to_string()); - } - - Err("No MCP proxy config file found. Searched in:\n \ - 1. ~/.stakpak/mcp.toml or ~/.stakpak/mcp.json\n \ - 2. .stakpak/mcp.toml or .stakpak/mcp.json\n \ - 3. mcp.toml or mcp.json\n\n\ - Create a config file with your MCP servers." - .to_string()) -} - #[derive(Subcommand, PartialEq)] pub enum McpCommands { /// Start the MCP server (standalone HTTP/HTTPS server with tools) @@ -89,6 +47,89 @@ pub enum McpCommands { #[arg(long = "privacy-mode", default_value_t = false)] privacy_mode: bool, }, + /// Add an MCP server to config + Add { + /// Server name (unique identifier) + name: String, + + /// Command for stdio transport + #[arg(long)] + command: Option, + + /// Comma-separated arguments + #[arg(long, allow_hyphen_values = true)] + args: Option, + + /// Environment variables (KEY=VALUE, repeatable) + #[arg(long = "env")] + envs: Vec, + + /// HTTP URL for remote transport + #[arg(long)] + url: Option, + + /// HTTP headers (KEY=VALUE, repeatable) + #[arg(long = "headers")] + headers: Vec, + + /// JSON config string (alternative to --command/--url) + #[arg(long)] + json: Option, + + /// Add in disabled state + #[arg(long)] + disabled: bool, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, + /// Remove an MCP server from config + Remove { + /// Server name to remove + name: String, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, + /// List configured MCP servers + List { + /// Output as JSON + #[arg(long)] + json: bool, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, + /// Show details for a specific MCP server + Get { + /// Server name + name: String, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, + /// Enable a MCP server + Enable { + /// Server name to enable + name: String, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, + /// Disable an MCP server without removing it + Disable { + /// Server name to disable + name: String, + + /// Config file path + #[arg(long = "config-file")] + config_file: Option, + }, } impl McpCommands { @@ -116,10 +157,152 @@ impl McpCommands { } => { let config_path = match config_file { Some(path) => path, - None => find_mcp_proxy_config_file()?, + None => find_config_file()?, }; proxy::run_proxy(config_path, disable_secret_redaction, privacy_mode).await } + McpCommands::Add { + name, + command, + args, + envs, + url, + headers, + json, + disabled, + config_file, + } => { + let entry = if let Some(json_str) = json { + serde_json::from_str::(&json_str) + .map_err(|e| format!("Invalid JSON config: {e}"))? + } else if let Some(url) = url { + let headers = parse_key_values(&headers)?; + McpServerEntry::UrlBased { + url, + headers: if headers.is_empty() { + None + } else { + Some(headers) + }, + disabled, + } + } else if let Some(command) = command { + let args = args + .map(|s| s.split(',').map(|a| a.trim().to_string()).collect()) + .unwrap_or_default(); + let env = parse_key_values(&envs)?; + McpServerEntry::CommandBased { + command, + args, + env: if env.is_empty() { None } else { Some(env) }, + disabled, + } + } else { + return Err( + "Must specify --command, --url, or --json. See 'stakpak mcp add --help'." + .to_string(), + ); + }; + + let path = resolve_config_path(config_file.as_deref()); + let mut cfg = load_config(&path)?; + add_server(&mut cfg, &name, entry)?; + save_config(&cfg, &path)?; + + println!("Added MCP server '{name}' to {}", path.display()); + Ok(()) + } + McpCommands::Remove { name, config_file } => { + let path = resolve_config_path(config_file.as_deref()); + let mut cfg = load_config(&path)?; + remove_server(&mut cfg, &name)?; + save_config(&cfg, &path)?; + + println!("Removed MCP server '{name}'."); + Ok(()) + } + McpCommands::List { json, config_file } => { + let path = resolve_config_path(config_file.as_deref()); + let cfg = load_config(&path)?; + + if cfg.servers.is_empty() { + println!("No MCP servers configured."); + return Ok(()); + } + + if json { + let output = serde_json::to_string_pretty(&cfg.servers) + .map_err(|e| format!("Failed to serialize: {e}"))?; + println!("{output}"); + return Ok(()); + } + + let name_header = "NAME"; + let type_header = "TYPE"; + let cmd_header = "COMMAND/URL"; + let status_header = "STATUS"; + println!("{name_header:<20} {type_header:<8} {cmd_header:<50} {status_header}"); + for (name, entry) in &cfg.servers { + let status = if entry.is_disabled() { + "disabled" + } else { + "enabled" + }; + println!( + "{:<20} {:<8} {:<50} {}", + name, + entry.entry_type(), + entry.summary_truncated(50), + status, + ); + } + + Ok(()) + } + McpCommands::Get { name, config_file } => { + let path = resolve_config_path(config_file.as_deref()); + let cfg = load_config(&path)?; + + let entry = cfg + .servers + .get(&name) + .ok_or_else(|| format!("Server '{name}' not found."))?; + + let json = serde_json::to_string_pretty(&entry) + .map_err(|e| format!("Failed to serialize: {e}"))?; + println!("{json}"); + Ok(()) + } + McpCommands::Enable { name, config_file } => { + let path = resolve_config_path(config_file.as_deref()); + let mut cfg = load_config(&path)?; + set_server_disabled(&mut cfg, &name, false)?; + save_config(&cfg, &path)?; + + println!("Enabled MCP server '{name}'."); + Ok(()) + } + McpCommands::Disable { name, config_file } => { + let path = resolve_config_path(config_file.as_deref()); + let mut cfg = load_config(&path)?; + set_server_disabled(&mut cfg, &name, true)?; + save_config(&cfg, &path)?; + + println!("Disabled MCP server '{name}'."); + Ok(()) + } } } } + +/// Parse KEY=VALUE pairs from a vec of strings. +fn parse_key_values(pairs: &[String]) -> Result, String> { + let mut map = HashMap::new(); + for pair in pairs { + let (key, value) = pair + .split_once('=') + .ok_or_else(|| format!("Invalid KEY=VALUE pair: '{pair}'"))?; + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} diff --git a/cli/src/commands/mcp/proxy.rs b/cli/src/commands/mcp/proxy.rs index b6720d381..588c3bb57 100644 --- a/cli/src/commands/mcp/proxy.rs +++ b/cli/src/commands/mcp/proxy.rs @@ -8,9 +8,9 @@ pub async fn run_proxy( disable_secret_redaction: bool, privacy_mode: bool, ) -> Result<(), String> { - let config = match ClientPoolConfig::from_toml_file(&config_path) { + let config = match ClientPoolConfig::from_file(&config_path) { Ok(config) => config, - Err(_) => ClientPoolConfig::from_json_file(&config_path) + Err(_) => ClientPoolConfig::from_file(&config_path) .map_err(|e| format!("Failed to load config from {}: {}", config_path, e))?, }; diff --git a/libs/mcp/config/Cargo.toml b/libs/mcp/config/Cargo.toml new file mode 100644 index 000000000..e27473e18 --- /dev/null +++ b/libs/mcp/config/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stakpak-mcp-config" +version = { workspace = true } +edition = "2024" +description = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +stakpak-shared = { workspace = true } + +[lints] +workspace = true diff --git a/libs/mcp/config/src/lib.rs b/libs/mcp/config/src/lib.rs new file mode 100644 index 000000000..f9febb1f6 --- /dev/null +++ b/libs/mcp/config/src/lib.rs @@ -0,0 +1,499 @@ +use stakpak_shared::paths::stakpak_home_dir; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub enum FileFormat { + #[default] + Toml, + Json, +} + +fn detect_format(path: &Path) -> FileFormat { + match path.extension().and_then(|e| e.to_str()) { + Some("json") => FileFormat::Json, + _ => FileFormat::Toml, // default + } +} + +/// Parsed MCP config file for read/write operations. +/// Uses BTreeMap to maintain stable alphabetical ordering when serializing. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct McpConfigFile { + #[serde(rename = "mcpServers")] + pub servers: BTreeMap, + pub format: Option, +} + +/// A single MCP server entry in the config file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum McpServerEntry { + CommandBased { + command: String, + args: Vec, + #[serde(default)] + env: Option>, + #[serde(default)] + disabled: bool, + }, + UrlBased { + url: String, + #[serde(default)] + headers: Option>, + #[serde(default)] + disabled: bool, + }, +} + +impl McpServerEntry { + pub fn is_disabled(&self) -> bool { + match self { + McpServerEntry::CommandBased { disabled, .. } => *disabled, + McpServerEntry::UrlBased { disabled, .. } => *disabled, + } + } + + pub fn set_disabled(&mut self, disabled: bool) { + match self { + McpServerEntry::CommandBased { disabled: d, .. } => *d = disabled, + McpServerEntry::UrlBased { disabled: d, .. } => *d = disabled, + } + } + + pub fn entry_type(&self) -> &'static str { + match self { + McpServerEntry::CommandBased { .. } => "stdio", + McpServerEntry::UrlBased { .. } => "http", + } + } + + /// Summary string for display (command+args or url) + pub fn summary(&self) -> String { + match self { + McpServerEntry::CommandBased { command, args, .. } => { + if args.is_empty() { + command.clone() + } else { + format!("{} {}", command, args.join(" ")) + } + } + McpServerEntry::UrlBased { url, .. } => url.clone(), + } + } + + /// Truncate summary to max_len characters + pub fn summary_truncated(&self, max_len: usize) -> String { + let s = self.summary(); + if s.len() <= max_len { + s + } else { + let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); + format!("{}...", truncated) + } + } +} + +/// Search for an MCP proxy config file in standard locations. +/// +/// Priority: +/// 1. `/mcp.{toml,json}` +/// 2. `.stakpak/mcp.{toml,json}` (current directory) +/// 3. `mcp.{toml,json}` in current directory +pub fn find_config_file() -> Result { + let stakpak_dir = stakpak_home_dir(); + + // Priority 1: stakpak dir (typically ~/.stakpak/) + let home_toml = stakpak_dir.join("mcp.toml"); + if home_toml.exists() { + return Ok(home_toml.to_string_lossy().to_string()); + } + + let home_json = stakpak_dir.join("mcp.json"); + if home_json.exists() { + return Ok(home_json.to_string_lossy().to_string()); + } + + // Priority 2: .stakpak/mcp.{toml,json} in current directory + let cwd_stakpak = PathBuf::from(".stakpak"); + + let cwd_stakpak_toml = cwd_stakpak.join("mcp.toml"); + if cwd_stakpak_toml.exists() { + return Ok(cwd_stakpak_toml.to_string_lossy().to_string()); + } + + let cwd_stakpak_json = cwd_stakpak.join("mcp.json"); + if cwd_stakpak_json.exists() { + return Ok(cwd_stakpak_json.to_string_lossy().to_string()); + } + + // Priority 3: mcp.{toml,json} in current directory (fallback) + let cwd_toml = PathBuf::from("mcp.toml"); + if cwd_toml.exists() { + return Ok("mcp.toml".to_string()); + } + + let cwd_json = PathBuf::from("mcp.json"); + if cwd_json.exists() { + return Ok("mcp.json".to_string()); + } + + Err(format!( + "No MCP proxy config file found. Searched in:\n \ + 1. {}/mcp.toml or {}/mcp.json\n \ + 2. .stakpak/mcp.toml or .stakpak/mcp.json\n \ + 3. mcp.toml or mcp.json\n\n\ + Create a config file with your MCP servers.", + stakpak_dir.display(), + stakpak_dir.display() + )) +} + +/// Resolve the config file path. If `explicit` is given, use it. +/// Otherwise search standard locations. If nothing found, return the +/// default path (`/mcp.toml`) +pub fn resolve_config_path(explicit: Option<&str>) -> PathBuf { + if let Some(path) = explicit { + return PathBuf::from(path); + } + + if let Ok(found) = find_config_file() { + return PathBuf::from(found); + } + + stakpak_home_dir().join("mcp.toml") +} + +/// Load MCP config from a file. +pub fn load_config(path: &Path) -> Result { + if !path.exists() { + return Ok(McpConfigFile::default()); + } + + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + + let format = detect_format(path); + + if content.trim().is_empty() { + return Ok(McpConfigFile { + servers: BTreeMap::new(), + format: Some(format), + }); + } + + let mut config: McpConfigFile = match format { + FileFormat::Toml => toml::from_str(&content) + .map_err(|e| format!("Failed to parse {} as TOML: {}", path.display(), e))?, + FileFormat::Json => serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse {} as JSON: {}", path.display(), e))?, + }; + + config.format = Some(format); + Ok(config) +} + +pub fn load_config_from_str(content: &str) -> Result { + let toml_result: Result = toml::from_str(content); + if let Ok(cfg) = toml_result { + return Ok(cfg); + } + + let json_result: Result = serde_json::from_str(content); + if let Ok(cfg) = json_result { + return Ok(cfg); + } + + Err("Failed to parse config".to_string()) +} + +/// Save MCP config to a file, creating parent directories if needed. +pub fn save_config(config: &McpConfigFile, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; + } + + let format = config.format.clone().unwrap_or_else(|| detect_format(path)); + + let content = match format { + FileFormat::Toml => { + toml::to_string_pretty(config).map_err(|e| format!("Failed to serialize TOML: {e}"))? + } + FileFormat::Json => serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize JSON: {e}"))?, + }; + + fs::write(path, content).map_err(|e| format!("Failed to write {}: {}", path.display(), e)) +} + +/// Add a server entry. Fails if name already exists. +pub fn add_server( + config: &mut McpConfigFile, + name: &str, + entry: McpServerEntry, +) -> Result<(), String> { + if config.servers.contains_key(name) { + return Err(format!( + "Server '{name}' already exists. Use 'stakpak mcp remove {name}' first." + )); + } + config.servers.insert(name.to_string(), entry); + Ok(()) +} + +/// Remove a server entry. Fails if name not found. +pub fn remove_server(config: &mut McpConfigFile, name: &str) -> Result { + config + .servers + .remove(name) + .ok_or_else(|| format!("Server '{name}' not found.")) +} + +/// Toggle the disabled flag on a server entry. +pub fn set_server_disabled( + config: &mut McpConfigFile, + name: &str, + disabled: bool, +) -> Result<(), String> { + let entry = config + .servers + .get_mut(name) + .ok_or_else(|| format!("Server '{name}' not found."))?; + entry.set_disabled(disabled); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_test_path(filename: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("stakpak-mcp-config-{unique}-{filename}")) + } + + fn sample_config(format: Option) -> McpConfigFile { + let mut servers = BTreeMap::new(); + servers.insert( + "github".to_string(), + McpServerEntry::UrlBased { + url: "https://api.githubcopilot.com/mcp".to_string(), + headers: None, + disabled: false, + }, + ); + + McpConfigFile { servers, format } + } + + #[test] + fn test_parse_toml_config() { + let toml_str = r#" +[mcpServers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp"] + +[mcpServers.github] +url = "https://api.githubcopilot.com/mcp" + +[mcpServers.disabled-server] +command = "some-tool" +args = [] +disabled = true +"#; + let config: McpConfigFile = toml::from_str(toml_str).unwrap(); + assert_eq!(config.servers.len(), 3); + assert!(config.servers.contains_key("context7")); + assert!(config.servers.contains_key("github")); + assert!(config.servers["disabled-server"].is_disabled()); + assert!(!config.servers["context7"].is_disabled()); + } + + #[test] + fn test_add_remove_roundtrip() { + let mut config = McpConfigFile::default(); + let entry = McpServerEntry::CommandBased { + command: "npx".into(), + args: vec!["-y".into(), "server".into()], + env: None, + disabled: false, + }; + add_server(&mut config, "test", entry).unwrap(); + assert!(config.servers.contains_key("test")); + + // Duplicate fails + let entry2 = McpServerEntry::UrlBased { + url: "https://example.com".into(), + headers: None, + disabled: false, + }; + assert!(add_server(&mut config, "test", entry2).is_err()); + + remove_server(&mut config, "test").unwrap(); + assert!(!config.servers.contains_key("test")); + } + + #[test] + fn test_set_disabled() { + let mut config = McpConfigFile::default(); + let entry = McpServerEntry::CommandBased { + command: "npx".into(), + args: vec![], + env: None, + disabled: false, + }; + add_server(&mut config, "test", entry).unwrap(); + assert!(!config.servers["test"].is_disabled()); + + set_server_disabled(&mut config, "test", true).unwrap(); + assert!(config.servers["test"].is_disabled()); + + set_server_disabled(&mut config, "test", false).unwrap(); + assert!(!config.servers["test"].is_disabled()); + } + + #[test] + fn test_detect_format() { + assert!(matches!( + detect_format(Path::new("mcp.toml")), + FileFormat::Toml + )); + assert!(matches!( + detect_format(Path::new("mcp.json")), + FileFormat::Json + )); + assert!(matches!( + detect_format(Path::new("mcp.unknown")), + FileFormat::Toml + )); + } + + #[test] + fn test_entry_type_and_summary() { + let cmd = McpServerEntry::CommandBased { + command: "npx".into(), + args: vec!["-y".into(), "@upstash/context7-mcp".into()], + env: None, + disabled: false, + }; + assert_eq!(cmd.entry_type(), "stdio"); + assert_eq!(cmd.summary(), "npx -y @upstash/context7-mcp"); + + let url = McpServerEntry::UrlBased { + url: "https://example.com/mcp".into(), + headers: None, + disabled: false, + }; + assert_eq!(url.entry_type(), "http"); + assert_eq!(url.summary(), "https://example.com/mcp"); + } + + #[test] + fn test_summary_truncated() { + let entry = McpServerEntry::CommandBased { + command: "long-command".into(), + args: vec!["with".into(), "many".into(), "arguments".into()], + env: None, + disabled: false, + }; + + assert_eq!(entry.summary_truncated(100), entry.summary()); + assert_eq!(entry.summary_truncated(10), "long-co..."); + assert_eq!(entry.summary_truncated(2), "..."); + } + + #[test] + fn test_remove_server_not_found() { + let mut config = McpConfigFile::default(); + let err = remove_server(&mut config, "missing").unwrap_err(); + assert!(err.contains("Server 'missing' not found.")); + } + + #[test] + fn test_set_disabled_not_found() { + let mut config = McpConfigFile::default(); + let err = set_server_disabled(&mut config, "missing", true).unwrap_err(); + assert!(err.contains("Server 'missing' not found.")); + } + + #[test] + fn test_resolve_config_path_explicit() { + let explicit = "./custom/path/mcp.json"; + assert_eq!(resolve_config_path(Some(explicit)), PathBuf::from(explicit)); + } + + #[test] + fn test_load_nonexistent_config_returns_default() { + let path = temp_test_path("does-not-exist.toml"); + let config = load_config(&path).unwrap(); + assert!(config.servers.is_empty()); + assert!(config.format.is_none()); + } + + #[test] + fn test_load_empty_config_returns_default_servers() { + let path = temp_test_path("empty.toml"); + fs::write(&path, "\n").unwrap(); + + let config = load_config(&path).unwrap(); + assert!(config.servers.is_empty()); + assert!(matches!(config.format, Some(FileFormat::Toml))); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_save_and_load_toml_roundtrip() { + let path = temp_test_path("roundtrip.toml"); + let config = sample_config(Some(FileFormat::Toml)); + + save_config(&config, &path).unwrap(); + let loaded = load_config(&path).unwrap(); + + assert!(matches!(loaded.format, Some(FileFormat::Toml))); + assert_eq!(loaded.servers.len(), 1); + assert!(loaded.servers.contains_key("github")); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_save_and_load_json_roundtrip() { + let path = temp_test_path("roundtrip.json"); + let config = sample_config(Some(FileFormat::Json)); + + save_config(&config, &path).unwrap(); + let loaded = load_config(&path).unwrap(); + + assert!(matches!(loaded.format, Some(FileFormat::Json))); + assert_eq!(loaded.servers.len(), 1); + assert!(loaded.servers.contains_key("github")); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_save_uses_path_extension_when_format_missing() { + let path = temp_test_path("by-path-extension.json"); + let config = sample_config(None); + + save_config(&config, &path).unwrap(); + + let raw = fs::read_to_string(&path).unwrap(); + assert!(raw.trim_start().starts_with('{')); + + let loaded = load_config(&path).unwrap(); + assert!(matches!(loaded.format, Some(FileFormat::Json))); + + let _ = fs::remove_file(path); + } +} diff --git a/libs/mcp/proxy/Cargo.toml b/libs/mcp/proxy/Cargo.toml index 3dcd6192d..8bafe6a36 100644 --- a/libs/mcp/proxy/Cargo.toml +++ b/libs/mcp/proxy/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true homepage.workspace = true [dependencies] +stakpak-mcp-config = { workspace = true } stakpak-shared = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } diff --git a/libs/mcp/proxy/src/client/mod.rs b/libs/mcp/proxy/src/client/mod.rs index c8df6ed42..78cf225c9 100644 --- a/libs/mcp/proxy/src/client/mod.rs +++ b/libs/mcp/proxy/src/client/mod.rs @@ -5,10 +5,9 @@ use rmcp::model::{ }; use rmcp::service::{NotificationContext, Peer, RunningService}; use rmcp::{RoleClient, RoleServer}; -use serde::Deserialize; +use stakpak_mcp_config::{McpConfigFile, McpServerEntry, load_config, load_config_from_str}; use stakpak_shared::cert_utils::CertificateChain; use std::collections::HashMap; -use std::fs; use std::ops::Deref; use std::path::Path; use std::sync::Arc; @@ -145,33 +144,46 @@ pub enum ServerConfig { }, } -#[derive(Debug, Deserialize)] -struct McpConfig { - #[serde(rename = "mcpServers")] - mcp_servers: HashMap, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum ServerConfigJson { - CommandBased { - command: String, - args: Vec, - #[serde(default)] - env: Option>, - }, - UrlBased { - url: String, - #[serde(default)] - headers: Option>, - }, -} - #[derive(Debug, Clone, Default)] pub struct ClientPoolConfig { pub servers: HashMap, } +impl From for ClientPoolConfig { + fn from(config: McpConfigFile) -> Self { + let mut servers = HashMap::new(); + + for (name, entry) in config.servers { + if entry.is_disabled() { + continue; + } + + let server_config = match entry { + McpServerEntry::CommandBased { + command, args, env, .. + } => { + let env = env.map(|vars| { + vars.into_iter() + .map(|(k, v)| (k, substitute_env_vars(&v))) + .collect() + }); + ServerConfig::Stdio { command, args, env } + } + McpServerEntry::UrlBased { url, headers, .. } => ServerConfig::Http { + url, + headers, + certificate_chain: Arc::new(None), + client_tls_config: None, + }, + }; + + servers.insert(name, server_config); + } + + Self { servers } + } +} + impl ClientPoolConfig { pub fn new() -> Self { Self::default() @@ -181,44 +193,206 @@ impl ClientPoolConfig { Self { servers } } - pub fn from_json_file>(path: P) -> anyhow::Result { - let content = fs::read_to_string(path)?; - Self::from_json_str(&content) + pub fn from_file>(path: P) -> anyhow::Result { + let config = load_config(path.as_ref()).map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(Self::from(config)) } - pub fn from_json_str(json_str: &str) -> anyhow::Result { - let mcp_config: McpConfig = serde_json::from_str(json_str)?; - Ok(Self::from_mcp_config(mcp_config)) + pub fn from_text(str: &str) -> anyhow::Result { + let config = load_config_from_str(str).map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(Self::from(config)) } +} - pub fn from_toml_file>(path: P) -> anyhow::Result { - let content = fs::read_to_string(path)?; - Self::from_toml_str(&content) +/// Substitute `$VAR` and `${VAR}` patterns in a string with environment variable values. +/// Unknown variables are left as-is. +fn substitute_env_vars(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '$' { + if chars.peek() == Some(&'{') { + // ${VAR} form + chars.next(); // consume '{' + let mut var_name = String::new(); + let mut closed = false; + for c in chars.by_ref() { + if c == '}' { + closed = true; + break; + } + var_name.push(c); + } + if !closed { + // Unterminated ${VAR; leave as-is without env lookup. + result.push_str("${"); + result.push_str(&var_name); + } else { + match std::env::var(&var_name) { + Ok(val) => result.push_str(&val), + Err(_) => { + result.push_str("${"); + result.push_str(&var_name); + result.push('}'); + } + } + } + } else { + // $VAR form — collect alphanumeric + underscore manually + // (take_while would consume the first non-matching char) + let mut var_name = String::new(); + loop { + match chars.peek() { + Some(&c) if c.is_alphanumeric() || c == '_' => { + var_name.push(c); + chars.next(); + } + _ => break, + } + } + if var_name.is_empty() { + result.push('$'); + } else { + match std::env::var(&var_name) { + Ok(val) => result.push_str(&val), + Err(_) => { + result.push('$'); + result.push_str(&var_name); + } + } + } + } + } else { + result.push(c); + } } - pub fn from_toml_str(toml_str: &str) -> anyhow::Result { - let mcp_config: McpConfig = toml::from_str(toml_str)?; - Ok(Self::from_mcp_config(mcp_config)) + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_substitute_no_vars() { + assert_eq!(substitute_env_vars("hello world"), "hello world"); } - fn from_mcp_config(mcp_config: McpConfig) -> Self { - let mut servers = HashMap::new(); + #[test] + fn test_substitute_dollar_sign_only() { + assert_eq!(substitute_env_vars("price is $"), "price is $"); + } - for (name, server_config_json) in mcp_config.mcp_servers { - let server_config = match server_config_json { - ServerConfigJson::CommandBased { command, args, env } => { - ServerConfig::Stdio { command, args, env } - } - ServerConfigJson::UrlBased { url, headers } => ServerConfig::Http { - url, - headers, - certificate_chain: Arc::new(None), - client_tls_config: None, - }, - }; - servers.insert(name, server_config); - } + #[test] + fn test_substitute_unknown_var_preserved() { + assert_eq!(substitute_env_vars("$UNKNOWN_VAR_XYZ"), "$UNKNOWN_VAR_XYZ"); + } - Self { servers } + #[test] + fn test_substitute_unknown_braced_var_preserved() { + assert_eq!( + substitute_env_vars("${UNKNOWN_VAR_XYZ}"), + "${UNKNOWN_VAR_XYZ}" + ); + } + + #[test] + fn test_substitute_known_var() { + unsafe { std::env::set_var("TEST_MCP_SUBSTITUTE", "secret_value") }; + assert_eq!(substitute_env_vars("$TEST_MCP_SUBSTITUTE"), "secret_value"); + assert_eq!( + substitute_env_vars("${TEST_MCP_SUBSTITUTE}"), + "secret_value" + ); + } + + #[test] + fn test_substitute_var_in_middle() { + unsafe { std::env::set_var("TEST_MCP_KEY", "abc123") }; + assert_eq!( + substitute_env_vars("prefix_${TEST_MCP_KEY}_suffix"), + "prefix_abc123_suffix" + ); + } + + #[test] + fn test_substitute_multiple_vars() { + unsafe { std::env::set_var("TEST_MCP_A", "one") }; + unsafe { std::env::set_var("TEST_MCP_B", "two") }; + assert_eq!( + substitute_env_vars("$TEST_MCP_A and $TEST_MCP_B"), + "one and two" + ); + } + + #[test] + fn test_disabled_server_filtered_out() { + let toml_str = r#" +[mcpServers.active] +command = "npx" +args = ["-y", "active-server"] + +[mcpServers.disabled-one] +command = "npx" +args = ["-y", "disabled-server"] +disabled = true + +[mcpServers.active-url] +url = "https://example.com/mcp" + +[mcpServers.disabled-url] +url = "https://disabled.com/mcp" +disabled = true +"#; + let config = ClientPoolConfig::from_text(toml_str).unwrap(); + assert_eq!(config.servers.len(), 2); + assert!(config.servers.contains_key("active")); + assert!(config.servers.contains_key("active-url")); + assert!(!config.servers.contains_key("disabled-one")); + assert!(!config.servers.contains_key("disabled-url")); + } + + #[test] + fn test_disabled_false_not_filtered() { + let toml_str = r#" +[mcpServers.myserver] +command = "npx" +args = ["-y", "my-server"] +disabled = false +"#; + let config = ClientPoolConfig::from_text(toml_str).unwrap(); + assert_eq!(config.servers.len(), 1); + assert!(config.servers.contains_key("myserver")); + } + + #[test] + fn test_default_not_disabled() { + let toml_str = r#" +[mcpServers.myserver] +command = "npx" +args = ["-y", "my-server"] +"#; + let config = ClientPoolConfig::from_text(toml_str).unwrap(); + assert_eq!(config.servers.len(), 1); + } + + #[test] + fn test_env_substitution_in_config() { + unsafe { std::env::set_var("TEST_MCP_TOKEN", "my-token-value") }; + let toml_str = r#" +[mcpServers.github] +command = "npx" +args = ["-y", "server"] +env = { GITHUB_TOKEN = "$TEST_MCP_TOKEN" } +"#; + let config = ClientPoolConfig::from_text(toml_str).unwrap(); + match config.servers.get("github").unwrap() { + ServerConfig::Stdio { env: Some(env), .. } => { + assert_eq!(env.get("GITHUB_TOKEN").unwrap(), "my-token-value"); + } + _ => panic!("Expected Stdio config"), + } } } diff --git a/libs/mcp/proxy/src/server/mod.rs b/libs/mcp/proxy/src/server/mod.rs index aa7059366..ee255241b 100644 --- a/libs/mcp/proxy/src/server/mod.rs +++ b/libs/mcp/proxy/src/server/mod.rs @@ -336,8 +336,11 @@ impl ProxyServer { if let Some(env_vars) = env { cmd.envs(&env_vars); } - let proc = match TokioChildProcess::new(cmd) { - Ok(p) => p, + let (proc, _) = match TokioChildProcess::builder(cmd) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(result) => result, Err(e) => { tracing::error!("Failed to create process for {}: {:?}", name, e); return; diff --git a/libs/shared/src/lib.rs b/libs/shared/src/lib.rs index 771ec480e..37e24cf06 100644 --- a/libs/shared/src/lib.rs +++ b/libs/shared/src/lib.rs @@ -9,6 +9,7 @@ pub mod jwt; pub mod local_store; pub mod models; pub mod oauth; +pub mod paths; pub mod remote_connection; pub mod remote_store; pub mod secret_manager; diff --git a/libs/shared/src/paths.rs b/libs/shared/src/paths.rs new file mode 100644 index 000000000..ebf46737c --- /dev/null +++ b/libs/shared/src/paths.rs @@ -0,0 +1,8 @@ +use std::path::PathBuf; + +/// Returns the Stakpak home directory: `~/.stakpak/` +pub fn stakpak_home_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".stakpak") +}