use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(default = "default_transport")]
pub transport: McpTransport,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub init_options: Option<serde_json::Value>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_true")]
pub auto_reconnect: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum McpTransport {
#[default]
Stdio,
Sse,
Http,
}
fn default_transport() -> McpTransport {
McpTransport::Stdio
}
fn default_timeout() -> u64 {
30
}
fn default_true() -> bool {
true
}
impl McpServerConfig {
pub fn stdio(name: impl Into<String>, command: impl Into<String>) -> Self {
Self {
name: name.into(),
transport: McpTransport::Stdio,
command: Some(command.into()),
args: Vec::new(),
env: HashMap::new(),
endpoint: None,
tools: Vec::new(),
init_options: None,
timeout_secs: default_timeout(),
auto_reconnect: true,
}
}
pub fn sse(name: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self {
name: name.into(),
transport: McpTransport::Sse,
command: None,
args: Vec::new(),
env: HashMap::new(),
endpoint: Some(endpoint.into()),
tools: Vec::new(),
init_options: None,
timeout_secs: default_timeout(),
auto_reconnect: true,
}
}
pub fn http(name: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self {
name: name.into(),
transport: McpTransport::Http,
command: None,
args: Vec::new(),
env: HashMap::new(),
endpoint: Some(endpoint.into()),
tools: Vec::new(),
init_options: None,
timeout_secs: default_timeout(),
auto_reconnect: true,
}
}
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
pub fn with_env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
self.tools = tools;
self
}
pub fn with_init_options(mut self, options: serde_json::Value) -> Self {
self.init_options = Some(options);
self
}
pub fn validate(&self) -> Result<(), String> {
match self.transport {
McpTransport::Stdio => {
if self.command.is_none() {
return Err("Stdio transport requires 'command' field".to_string());
}
}
McpTransport::Sse | McpTransport::Http => {
if self.endpoint.is_none() {
return Err(format!(
"{:?} transport requires 'endpoint' field",
self.transport
));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stdio_config_creation() {
let config = McpServerConfig::stdio("test-server", "npx")
.with_args(vec!["@anthropic-ai/mcp-server-fs".to_string()])
.with_env_var("MCP_FS_ROOT", "/workspace");
assert_eq!(config.name, "test-server");
assert_eq!(config.transport, McpTransport::Stdio);
assert_eq!(config.command, Some("npx".to_string()));
assert_eq!(config.args.len(), 1);
assert_eq!(config.env.get("MCP_FS_ROOT"), Some(&"/workspace".to_string()));
assert!(config.validate().is_ok());
}
#[test]
fn test_sse_config_creation() {
let config = McpServerConfig::sse("remote-server", "http://localhost:3000/mcp");
assert_eq!(config.name, "remote-server");
assert_eq!(config.transport, McpTransport::Sse);
assert_eq!(config.endpoint, Some("http://localhost:3000/mcp".to_string()));
assert!(config.validate().is_ok());
}
#[test]
fn test_http_config_creation() {
let config = McpServerConfig::http("api-server", "http://localhost:8080/mcp/v1");
assert_eq!(config.name, "api-server");
assert_eq!(config.transport, McpTransport::Http);
assert_eq!(config.endpoint, Some("http://localhost:8080/mcp/v1".to_string()));
assert!(config.validate().is_ok());
}
#[test]
fn test_stdio_config_without_command_fails_validation() {
let config = McpServerConfig {
name: "test".to_string(),
transport: McpTransport::Stdio,
command: None,
args: Vec::new(),
env: HashMap::new(),
endpoint: None,
tools: Vec::new(),
init_options: None,
timeout_secs: 30,
auto_reconnect: true,
};
assert!(config.validate().is_err());
}
#[test]
fn test_yaml_deserialization_stdio() {
let yaml = r#"
name: filesystem
transport: stdio
command: npx
args:
- "@anthropic-ai/mcp-server-fs"
env:
MCP_FS_ROOT: /workspace
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "filesystem");
assert_eq!(config.transport, McpTransport::Stdio);
assert_eq!(config.command, Some("npx".to_string()));
assert!(config.validate().is_ok());
}
#[test]
fn test_yaml_deserialization_sse() {
let yaml = r#"
name: remote-tools
transport: sse
endpoint: http://localhost:3000/mcp
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "remote-tools");
assert_eq!(config.transport, McpTransport::Sse);
assert_eq!(config.endpoint, Some("http://localhost:3000/mcp".to_string()));
assert!(config.validate().is_ok());
}
#[test]
fn test_yaml_deserialization_with_init_options() {
let yaml = r#"
name: advanced-server
transport: stdio
command: ./mcp-server
init_options:
debug: true
workspace: /home/user
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "advanced-server");
assert!(config.init_options.is_some());
let opts = config.init_options.unwrap();
assert_eq!(opts.get("debug"), Some(&serde_json::json!(true)));
}
#[test]
fn test_default_transport() {
let yaml = r#"
name: default-transport
command: ./server
"#;
let config: McpServerConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.transport, McpTransport::Stdio);
}
}