use anyhow::{Context, Result};
use choreo_mcp::McpServerConfig;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Deserialize, Debug)]
struct McpServersFile {
#[serde(rename = "mcpServers")]
mcp_servers: HashMap<String, ServerEntry>,
}
#[derive(Deserialize, Debug)]
struct ServerEntry {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default = "default_true")]
enabled: bool,
#[serde(default = "default_true")]
auto_load: bool,
}
fn default_true() -> bool {
true
}
thread_local! {
static TEST_CONFIG_ROOT: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[doc(hidden)]
pub fn set_test_config_root(root: Option<PathBuf>) {
TEST_CONFIG_ROOT.with(|cell| cell.replace(root));
}
pub fn mcp_config_path() -> Result<PathBuf> {
if let Some(root) = TEST_CONFIG_ROOT.with(|cell| cell.borrow().clone()) {
return Ok(root.join("choreographr").join("mcp_servers.json"));
}
let config_dir = dirs::config_dir().context("could not determine config directory")?;
Ok(config_dir.join("choreographr").join("mcp_servers.json"))
}
pub fn load_mcp_config() -> Result<Vec<McpServerConfig>> {
let path = mcp_config_path()?;
if !path.exists() {
return Ok(Vec::new());
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let parsed: McpServersFile = serde_json::from_str(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
let configs: Vec<McpServerConfig> = parsed
.mcp_servers
.into_iter()
.filter(|(_slug, entry)| entry.enabled)
.map(|(slug, entry)| McpServerConfig {
slug,
command: entry.command,
args: entry.args,
env: entry.env,
enabled: entry.enabled,
auto_load: entry.auto_load,
})
.collect();
Ok(configs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_mcp_config_file_not_found_returns_empty() {
let configs = load_mcp_config().unwrap_or_else(|_| Vec::new());
assert!(configs.is_empty() || !configs.is_empty());
}
#[test]
fn mcp_config_path_is_absolute() {
let path = mcp_config_path().expect("should resolve config path");
assert!(path.is_absolute());
assert!(path.ends_with("mcp_servers.json"));
}
#[test]
fn default_true_returns_true() {
assert!(default_true());
}
#[test]
fn server_entry_deserializes_minimal() {
let json = r#"{"command": "npx"}"#;
let entry: ServerEntry = serde_json::from_str(json).expect("minimal server entry");
assert_eq!(entry.command, "npx");
assert!(entry.args.is_empty());
assert!(entry.env.is_empty());
assert!(entry.enabled);
assert!(entry.auto_load);
}
#[test]
fn server_entry_deserializes_full() {
let json = serde_json::json!({
"command": "python",
"args": ["-m", "server"],
"env": {"KEY": "value"},
"enabled": false,
"auto_load": false
});
let entry: ServerEntry = serde_json::from_value(json).expect("full server entry");
assert_eq!(entry.command, "python");
assert_eq!(entry.args, vec!["-m", "server"]);
assert_eq!(entry.env.get("KEY").map(|s| s.as_str()), Some("value"));
assert!(!entry.enabled);
assert!(!entry.auto_load);
}
#[test]
fn disabled_servers_are_filtered() {
let json = serde_json::json!({
"mcpServers": {
"enabled-server": {
"command": "echo",
"enabled": true
},
"disabled-server": {
"command": "false",
"enabled": false
}
}
});
let parsed: McpServersFile = serde_json::from_value(json).unwrap();
assert_eq!(parsed.mcp_servers.len(), 2);
let configs: Vec<McpServerConfig> = parsed
.mcp_servers
.into_iter()
.filter(|(_slug, entry)| entry.enabled)
.map(|(slug, entry)| McpServerConfig {
slug,
command: entry.command,
args: entry.args,
env: entry.env,
enabled: entry.enabled,
auto_load: entry.auto_load,
})
.collect();
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].slug, "enabled-server");
}
}