use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use super::types::McpConfig;
use crate::error::NikaError;
use crate::serde_yaml;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NikaMcpConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub servers: HashMap<String, NikaMcpServer>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NikaMcpServer {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<NikaMcpSource>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NikaMcpSource {
#[default]
Global,
Project,
Workflow,
}
impl NikaMcpServer {
pub fn to_mcp_config(&self, name: &str) -> McpConfig {
let mut env = FxHashMap::default();
for (k, v) in &self.env {
env.insert(k.clone(), v.clone());
}
McpConfig {
name: name.to_string(),
command: self.command.clone(),
args: self.args.clone(),
env,
cwd: None, }
}
}
#[derive(Debug, Clone)]
pub struct NikaMcpConfigManager {
global_path: PathBuf,
project_root: Option<PathBuf>,
}
impl NikaMcpConfigManager {
pub fn new() -> Self {
Self {
global_path: Self::default_global_path(),
project_root: None,
}
}
pub fn with_project(project_root: PathBuf) -> Self {
Self {
global_path: Self::default_global_path(),
project_root: Some(project_root),
}
}
pub fn with_global_path(global_path: PathBuf) -> Self {
Self {
global_path,
project_root: None,
}
}
pub fn default_global_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".nika")
.join("mcp.yaml")
}
pub fn project_path(&self) -> Option<PathBuf> {
self.project_root
.as_ref()
.map(|root| root.join(".nika").join("mcp.yaml"))
}
pub fn global_exists(&self) -> bool {
self.global_path.exists()
}
pub fn load_global(&self) -> Result<NikaMcpConfig, NikaError> {
Self::load_from_path(&self.global_path)
}
pub fn load_project(&self) -> Result<Option<NikaMcpConfig>, NikaError> {
let Some(path) = self.project_path() else {
return Ok(None);
};
if !path.exists() {
return Ok(None);
}
Self::load_from_path(&path).map(Some)
}
pub fn load_merged(&self) -> Result<NikaMcpConfig, NikaError> {
let mut merged = self.load_global()?;
if let Some(project) = self.load_project()? {
for (name, server) in project.servers {
merged.servers.insert(name, server);
}
}
Ok(merged)
}
fn load_from_path(path: &Path) -> Result<NikaMcpConfig, NikaError> {
if !path.exists() {
return Ok(NikaMcpConfig::default());
}
let content = std::fs::read_to_string(path).map_err(|e| NikaError::ConfigError {
reason: format!("Failed to read MCP config at '{}': {}", path.display(), e),
})?;
serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
details: format!("Invalid MCP config YAML at '{}': {}", path.display(), e),
})
}
}
impl Default for NikaMcpConfigManager {
fn default() -> Self {
Self::new()
}
}
pub fn load_nika_mcp_servers() -> Result<FxHashMap<String, McpConfig>, NikaError> {
let manager = NikaMcpConfigManager::new();
load_nika_mcp_servers_with_manager(&manager)
}
pub fn load_nika_mcp_servers_with_manager(
manager: &NikaMcpConfigManager,
) -> Result<FxHashMap<String, McpConfig>, NikaError> {
let config = manager.load_merged()?;
let mut servers = FxHashMap::default();
for (name, server) in config.servers {
if !server.enabled {
continue;
}
servers.insert(name.clone(), server.to_mcp_config(&name));
}
Ok(servers)
}
pub fn load_nika_mcp_servers_by_name(
names: &[&str],
) -> Result<FxHashMap<String, McpConfig>, NikaError> {
let manager = NikaMcpConfigManager::new();
let config = manager.load_merged()?;
let mut servers = FxHashMap::default();
let mut missing = Vec::new();
for &name in names {
match config.servers.get(name) {
Some(server) if server.enabled => {
servers.insert(name.to_string(), server.to_mcp_config(name));
}
Some(_) => {
missing.push(name);
}
None => {
missing.push(name);
}
}
}
if !missing.is_empty() {
let available: Vec<_> = config
.servers
.keys()
.filter(|k| config.servers.get(*k).map(|s| s.enabled).unwrap_or(false))
.cloned()
.collect();
return Err(NikaError::ConfigError {
reason: format!(
"MCP server(s) not found in config: [{}]. Available: [{}]",
missing.join(", "),
available.join(", ")
),
});
}
Ok(servers)
}
pub fn nika_mcp_config_exists() -> bool {
NikaMcpConfigManager::new().global_exists()
}
pub fn list_nika_mcp_servers() -> Result<Vec<String>, NikaError> {
let manager = NikaMcpConfigManager::new();
let config = manager.load_merged()?;
Ok(config
.servers
.into_iter()
.filter(|(_, s)| s.enabled)
.map(|(name, _)| name)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_config(content: &str) -> (TempDir, NikaMcpConfigManager) {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("mcp.yaml");
std::fs::write(&config_path, content).unwrap();
let manager = NikaMcpConfigManager::with_global_path(config_path);
(temp, manager)
}
#[test]
fn test_load_empty_config() {
let (_temp, manager) = create_test_config("");
let config = manager.load_global().unwrap();
assert!(config.servers.is_empty());
assert_eq!(config.version, 1);
}
#[test]
fn test_load_single_server() {
let yaml = r#"
version: 1
servers:
neo4j:
command: npx
args:
- "-y"
- "@neo4j/mcp-server"
env:
NEO4J_URI: bolt://localhost:7687
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.servers.len(), 1);
let server = config.servers.get("neo4j").unwrap();
assert_eq!(server.command, "npx");
assert_eq!(server.args, vec!["-y", "@neo4j/mcp-server"]);
assert_eq!(
server.env.get("NEO4J_URI"),
Some(&"bolt://localhost:7687".to_string())
);
assert!(server.enabled);
}
#[test]
fn test_load_multiple_servers() {
let yaml = r#"
version: 1
servers:
neo4j:
command: npx
args: ["-y", "@neo4j/mcp-server"]
enabled: true
novanet:
command: novanet-mcp
enabled: true
disabled_server:
command: echo
enabled: false
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
assert_eq!(config.servers.len(), 3);
assert!(config.servers.contains_key("neo4j"));
assert!(config.servers.contains_key("novanet"));
assert!(config.servers.contains_key("disabled_server"));
}
#[test]
fn test_convert_to_mcp_config() {
let server = NikaMcpServer {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@test/server".to_string()],
env: {
let mut env = HashMap::new();
env.insert("API_KEY".to_string(), "secret".to_string());
env
},
description: Some("Test server".to_string()),
enabled: true,
source: Some(NikaMcpSource::Global),
};
let config = server.to_mcp_config("test");
assert_eq!(config.name, "test");
assert_eq!(config.command, "npx");
assert_eq!(config.args, vec!["-y", "@test/server"]);
assert_eq!(config.env.get("API_KEY"), Some(&"secret".to_string()));
assert!(config.cwd.is_none());
}
#[test]
fn test_load_enabled_servers_only() {
let yaml = r#"
version: 1
servers:
enabled_server:
command: echo
enabled: true
disabled_server:
command: echo
enabled: false
"#;
let (_temp, manager) = create_test_config(yaml);
let servers = load_nika_mcp_servers_with_manager(&manager).unwrap();
assert_eq!(servers.len(), 1);
assert!(servers.contains_key("enabled_server"));
assert!(!servers.contains_key("disabled_server"));
}
#[test]
fn test_load_servers_by_name() {
let yaml = r#"
version: 1
servers:
neo4j:
command: npx
enabled: true
novanet:
command: novanet-mcp
enabled: true
other:
command: echo
enabled: true
"#;
let (temp, manager) = create_test_config(yaml);
let config_path = temp.path().join("mcp.yaml");
std::env::set_var("SPN_MCP_CONFIG_PATH", config_path.to_str().unwrap());
let config = manager.load_global().unwrap();
let mut servers = FxHashMap::default();
for name in ["neo4j", "novanet"] {
if let Some(server) = config.servers.get(name) {
if server.enabled {
servers.insert(name.to_string(), server.to_mcp_config(name));
}
}
}
assert_eq!(servers.len(), 2);
assert!(servers.contains_key("neo4j"));
assert!(servers.contains_key("novanet"));
assert!(!servers.contains_key("other"));
}
#[test]
fn test_default_enabled() {
let yaml = r#"
version: 1
servers:
no_enabled_field:
command: echo
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
let server = config.servers.get("no_enabled_field").unwrap();
assert!(server.enabled); }
#[test]
fn test_missing_config_returns_empty() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent.yaml");
let manager = NikaMcpConfigManager::with_global_path(nonexistent);
let config = manager.load_global().unwrap();
assert!(config.servers.is_empty());
}
#[test]
fn test_source_enum() {
let yaml = r#"
version: 1
servers:
test:
command: echo
source: global
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
let server = config.servers.get("test").unwrap();
assert_eq!(server.source, Some(NikaMcpSource::Global));
}
#[test]
fn test_env_variables() {
let yaml = r#"
version: 1
servers:
test:
command: echo
env:
VAR1: value1
VAR2: value2
VAR3: "value with spaces"
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
let server = config.servers.get("test").unwrap();
assert_eq!(server.env.get("VAR1"), Some(&"value1".to_string()));
assert_eq!(server.env.get("VAR2"), Some(&"value2".to_string()));
assert_eq!(
server.env.get("VAR3"),
Some(&"value with spaces".to_string())
);
}
#[test]
fn test_config_manager_paths() {
let manager = NikaMcpConfigManager::new();
let expected_global = dirs::home_dir().unwrap().join(".nika").join("mcp.yaml");
assert_eq!(manager.global_path, expected_global);
assert!(manager.project_root.is_none());
}
#[test]
fn test_config_manager_with_project() {
let project_root = PathBuf::from("/my/project");
let manager = NikaMcpConfigManager::with_project(project_root.clone());
assert_eq!(manager.project_root, Some(project_root));
assert_eq!(
manager.project_path(),
Some(PathBuf::from("/my/project/.nika/mcp.yaml"))
);
}
#[test]
fn test_secrets_env_var_references_preserved() {
let yaml = r#"
version: 1
servers:
perplexity:
command: npx
args: ["-y", "@anthropic/mcp-server-perplexity"]
env:
PERPLEXITY_API_KEY: "${PERPLEXITY_API_KEY}"
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let config = manager.load_global().unwrap();
let server = config.servers.get("perplexity").unwrap();
assert_eq!(
server.env.get("PERPLEXITY_API_KEY"),
Some(&"${PERPLEXITY_API_KEY}".to_string())
);
}
#[test]
fn test_multiple_secrets_per_server() {
let yaml = r#"
version: 1
servers:
neo4j:
command: npx
args: ["-y", "@neo4j/mcp-server-neo4j"]
env:
NEO4J_URI: bolt://localhost:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: "${NEO4J_PASSWORD}"
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let servers = load_nika_mcp_servers_with_manager(&manager).unwrap();
let mcp_config = servers.get("neo4j").unwrap();
assert_eq!(
mcp_config.env.get("NEO4J_URI"),
Some(&"bolt://localhost:7687".to_string())
);
assert_eq!(mcp_config.env.get("NEO4J_USER"), Some(&"neo4j".to_string()));
assert_eq!(
mcp_config.env.get("NEO4J_PASSWORD"),
Some(&"${NEO4J_PASSWORD}".to_string())
);
}
#[test]
fn test_spn_mcp_server_types_with_secrets() {
let yaml = r#"
version: 1
servers:
neo4j:
command: npx
args: ["-y", "@neo4j/mcp-server-neo4j"]
env:
NEO4J_PASSWORD: "${NEO4J_PASSWORD}"
enabled: true
perplexity:
command: npx
args: ["-y", "@anthropic/mcp-server-perplexity"]
env:
PERPLEXITY_API_KEY: "${PERPLEXITY_API_KEY}"
enabled: true
firecrawl:
command: npx
args: ["-y", "@anthropic/mcp-server-firecrawl"]
env:
FIRECRAWL_API_KEY: "${FIRECRAWL_API_KEY}"
enabled: true
supadata:
command: npx
args: ["-y", "@supadata/mcp-server"]
env:
SUPADATA_API_KEY: "${SUPADATA_API_KEY}"
enabled: true
github:
command: npx
args: ["-y", "@anthropic/mcp-server-github"]
env:
GITHUB_TOKEN: "${GITHUB_TOKEN}"
enabled: true
slack:
command: npx
args: ["-y", "@anthropic/mcp-server-slack"]
env:
SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN}"
SLACK_TEAM_ID: "${SLACK_TEAM_ID}"
enabled: true
"#;
let (_temp, manager) = create_test_config(yaml);
let servers = load_nika_mcp_servers_with_manager(&manager).unwrap();
assert_eq!(servers.len(), 6);
assert!(servers
.get("neo4j")
.unwrap()
.env
.contains_key("NEO4J_PASSWORD"));
assert!(servers
.get("perplexity")
.unwrap()
.env
.contains_key("PERPLEXITY_API_KEY"));
assert!(servers
.get("firecrawl")
.unwrap()
.env
.contains_key("FIRECRAWL_API_KEY"));
assert!(servers
.get("supadata")
.unwrap()
.env
.contains_key("SUPADATA_API_KEY"));
assert!(servers
.get("github")
.unwrap()
.env
.contains_key("GITHUB_TOKEN"));
assert!(servers
.get("slack")
.unwrap()
.env
.contains_key("SLACK_BOT_TOKEN"));
assert!(servers
.get("slack")
.unwrap()
.env
.contains_key("SLACK_TEAM_ID"));
}
#[test]
fn test_mcp_config_conversion_preserves_secrets() {
let server = NikaMcpServer {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@test/server".to_string()],
env: {
let mut env = HashMap::new();
env.insert("API_KEY".to_string(), "${API_KEY}".to_string());
env.insert("STATIC_VALUE".to_string(), "fixed_value".to_string());
env
},
description: None,
enabled: true,
source: None,
};
let mcp_config = server.to_mcp_config("test");
assert_eq!(
mcp_config.env.get("API_KEY"),
Some(&"${API_KEY}".to_string())
);
assert_eq!(
mcp_config.env.get("STATIC_VALUE"),
Some(&"fixed_value".to_string())
);
}
#[test]
fn test_project_config_merges_with_global_secrets() {
let temp = TempDir::new().unwrap();
let global_path = temp.path().join("global_mcp.yaml");
std::fs::write(
&global_path,
r#"
version: 1
servers:
perplexity:
command: npx
args: ["-y", "@anthropic/mcp-server-perplexity"]
env:
PERPLEXITY_API_KEY: "${PERPLEXITY_API_KEY}"
enabled: true
"#,
)
.unwrap();
let project_root = temp.path().join("project");
std::fs::create_dir_all(project_root.join(".nika")).unwrap();
let project_path = project_root.join(".nika/mcp.yaml");
std::fs::write(
&project_path,
r#"
version: 1
servers:
neo4j:
command: npx
args: ["-y", "@neo4j/mcp-server-neo4j"]
env:
NEO4J_PASSWORD: "${NEO4J_PASSWORD}"
enabled: true
"#,
)
.unwrap();
let mut manager = NikaMcpConfigManager::with_global_path(global_path);
manager.project_root = Some(project_root);
let merged = manager.load_merged().unwrap();
assert_eq!(merged.servers.len(), 2);
assert!(merged.servers.contains_key("perplexity"));
assert!(merged.servers.contains_key("neo4j"));
assert_eq!(
merged
.servers
.get("perplexity")
.unwrap()
.env
.get("PERPLEXITY_API_KEY"),
Some(&"${PERPLEXITY_API_KEY}".to_string())
);
assert_eq!(
merged
.servers
.get("neo4j")
.unwrap()
.env
.get("NEO4J_PASSWORD"),
Some(&"${NEO4J_PASSWORD}".to_string())
);
}
#[test]
fn test_disabled_server_secrets_not_loaded() {
let yaml = r#"
version: 1
servers:
active:
command: npx
env:
SECRET: "${SECRET}"
enabled: true
disabled:
command: npx
env:
DISABLED_SECRET: "${DISABLED_SECRET}"
enabled: false
"#;
let (_temp, manager) = create_test_config(yaml);
let servers = load_nika_mcp_servers_with_manager(&manager).unwrap();
assert_eq!(servers.len(), 1);
assert!(servers.contains_key("active"));
assert!(!servers.contains_key("disabled"));
assert!(servers.get("active").unwrap().env.contains_key("SECRET"));
}
}