use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
pub type McpServers = BTreeMap<String, McpServerConfig>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McpServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(flatten)]
pub extra: Value,
}
impl McpServerConfig {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
env: BTreeMap::new(),
cwd: None,
extra: Value::Object(Default::default()),
}
}
pub fn with_args(command: impl Into<String>, args: Vec<String>) -> Self {
Self {
command: command.into(),
args,
env: BTreeMap::new(),
cwd: None,
extra: Value::Object(Default::default()),
}
}
}
pub(crate) fn mcp_servers_to_wire(servers: &McpServers) -> Vec<Value> {
servers
.iter()
.map(|(name, config)| {
let mut obj = serde_json::to_value(config).unwrap_or_default();
if let Some(map) = obj.as_object_mut() {
map.insert("name".to_string(), Value::String(name.clone()));
}
obj
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_config_new() {
let cfg = McpServerConfig::new("uvx");
assert_eq!(cfg.command, "uvx");
assert!(cfg.args.is_empty(), "args should default to empty");
assert!(cfg.env.is_empty(), "env should default to empty");
assert!(cfg.cwd.is_none(), "cwd should default to None");
assert_eq!(cfg.extra, Value::Object(Default::default()));
}
#[test]
fn test_mcp_config_with_args() {
let args = vec![
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
"/tmp".to_string(),
];
let cfg = McpServerConfig::with_args("npx", args.clone());
assert_eq!(cfg.command, "npx");
assert_eq!(cfg.args, args);
assert!(cfg.env.is_empty());
assert!(cfg.cwd.is_none());
}
#[test]
fn test_mcp_config_serde_roundtrip() {
let mut env = BTreeMap::new();
env.insert("API_KEY".to_string(), "secret".to_string());
let original = McpServerConfig {
command: "python".to_string(),
args: vec!["-m".to_string(), "mcp_server".to_string()],
env,
cwd: Some("/workspace".to_string()),
extra: Value::Object(Default::default()),
};
let json = serde_json::to_string(&original).expect("serialize failed");
let decoded: McpServerConfig =
serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(original, decoded);
}
#[test]
fn test_mcp_servers_to_wire_with_entries() {
let mut servers: McpServers = BTreeMap::new();
servers.insert(
"alpha".to_string(),
McpServerConfig::new("cmd-alpha"),
);
servers.insert(
"beta".to_string(),
McpServerConfig::with_args(
"cmd-beta",
vec!["--flag".to_string()],
),
);
let wire = mcp_servers_to_wire(&servers);
assert_eq!(wire.len(), 2);
let alpha = &wire[0];
assert_eq!(alpha["name"], Value::String("alpha".to_string()));
assert_eq!(alpha["command"], Value::String("cmd-alpha".to_string()));
let beta = &wire[1];
assert_eq!(beta["name"], Value::String("beta".to_string()));
assert_eq!(beta["command"], Value::String("cmd-beta".to_string()));
assert_eq!(
beta["args"],
serde_json::json!(["--flag"]),
"args must survive wire conversion"
);
}
#[test]
fn test_mcp_servers_to_wire_empty() {
let servers: McpServers = BTreeMap::new();
let wire = mcp_servers_to_wire(&servers);
assert!(wire.is_empty(), "empty map must produce empty vec");
}
}