use serde::de;
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct McpConfig {
pub version: u32,
pub servers: Vec<McpServerConfig>,
}
fn default_version() -> u32 {
1
}
impl Default for McpConfig {
fn default() -> Self {
Self {
version: 1,
servers: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct McpConfigLegacyDisk {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
struct McpServerConfigFlatDisk {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
disabled: bool,
#[serde(default)]
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
env_encrypted: HashMap<String, String>,
#[serde(default)]
env_credential_refs: HashMap<String, String>,
#[serde(default)]
startup_timeout_ms: Option<u64>,
#[serde(default)]
url: Option<String>,
#[serde(default, deserialize_with = "deserialize_headers")]
headers: Vec<HeaderConfig>,
#[serde(default)]
headers_encrypted: HashMap<String, String>,
#[serde(default)]
header_credential_refs: HashMap<String, String>,
#[serde(default)]
connect_timeout_ms: Option<u64>,
#[serde(default)]
transport_kind: Option<String>,
#[serde(default)]
request_timeout_ms: Option<u64>,
#[serde(default)]
healthcheck_interval_ms: Option<u64>,
#[serde(default)]
reconnect: Option<ReconnectConfig>,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
denied_tools: Vec<String>,
}
fn deserialize_headers<'de, D>(deserializer: D) -> Result<Vec<HeaderConfig>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(Vec::new());
}
if let Some(map) = value.as_object() {
let mut headers = Vec::with_capacity(map.len());
for (name, raw) in map.iter() {
let value = raw.as_str().unwrap_or("").to_string();
headers.push(HeaderConfig {
name: name.clone(),
value,
value_encrypted: None,
credential_ref: None,
});
}
return Ok(headers);
}
if value.is_array() {
return serde_json::from_value::<Vec<HeaderConfig>>(value).map_err(de::Error::custom);
}
Err(de::Error::custom(
"MCP SSE headers must be an object map or an array",
))
}
impl McpServerConfigFlatDisk {
fn into_internal(self) -> Result<McpServerConfig, String> {
let enabled = self.enabled.unwrap_or(!self.disabled);
let request_timeout_ms = self
.request_timeout_ms
.unwrap_or_else(default_request_timeout);
let healthcheck_interval_ms = self
.healthcheck_interval_ms
.unwrap_or_else(default_healthcheck_interval);
let reconnect = self.reconnect.unwrap_or_default();
let transport = match (self.command, self.url) {
(Some(command), None) => TransportConfig::Stdio(StdioConfig {
command,
args: self.args,
cwd: self.cwd,
env: self.env,
env_encrypted: self.env_encrypted,
env_credential_refs: self.env_credential_refs,
startup_timeout_ms: self
.startup_timeout_ms
.unwrap_or_else(default_startup_timeout),
}),
(None, Some(url)) => {
let headers = merge_header_secrets(
self.headers,
self.headers_encrypted,
self.header_credential_refs,
);
let connect_timeout_ms = self
.connect_timeout_ms
.unwrap_or_else(default_connect_timeout);
if self.transport_kind.as_deref() == Some("streamable_http") {
TransportConfig::StreamableHttp(StreamableHttpConfig {
url,
headers,
connect_timeout_ms,
})
} else {
TransportConfig::Sse(SseConfig {
url,
headers,
connect_timeout_ms,
})
}
}
(Some(_), Some(_)) => {
return Err("MCP server config cannot contain both 'command' and 'url'".to_string())
}
(None, None) => {
return Err(
"MCP server config must contain either 'command' (stdio) or 'url' (sse)"
.to_string(),
)
}
};
Ok(McpServerConfig {
id: self.id,
name: self.name,
enabled,
transport,
request_timeout_ms,
healthcheck_interval_ms,
reconnect,
allowed_tools: self.allowed_tools,
denied_tools: self.denied_tools,
})
}
}
fn merge_header_secrets(
mut headers: Vec<HeaderConfig>,
encrypted: HashMap<String, String>,
credential_refs: HashMap<String, String>,
) -> Vec<HeaderConfig> {
for (name, value_encrypted) in encrypted {
if let Some(header) = headers.iter_mut().find(|header| header.name == name) {
header.value_encrypted = Some(value_encrypted);
} else {
headers.push(HeaderConfig {
name,
value: String::new(),
value_encrypted: Some(value_encrypted),
credential_ref: None,
});
}
}
for (name, credential_ref) in credential_refs {
if let Some(header) = headers.iter_mut().find(|header| header.name == name) {
header.credential_ref = Some(credential_ref);
} else {
headers.push(HeaderConfig {
name,
value: String::new(),
value_encrypted: None,
credential_ref: Some(credential_ref),
});
}
}
headers
}
#[derive(Debug, Clone, Serialize)]
struct McpServerDiskOut {
#[serde(default, skip_serializing_if = "is_false")]
disabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env_encrypted: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env_credential_refs: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
startup_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
headers_encrypted: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
header_credential_refs: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
transport_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
connect_timeout_ms: Option<u64>,
request_timeout_ms: u64,
healthcheck_interval_ms: u64,
reconnect: ReconnectConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
transport: Option<TransportConfig>,
}
fn is_false(value: &bool) -> bool {
!*value
}
impl From<&McpServerConfig> for McpServerDiskOut {
fn from(server: &McpServerConfig) -> Self {
let mut out = Self {
disabled: !server.enabled,
name: server.name.clone(),
command: None,
args: Vec::new(),
cwd: None,
env: HashMap::new(),
env_encrypted: HashMap::new(),
env_credential_refs: HashMap::new(),
startup_timeout_ms: None,
url: None,
headers: HashMap::new(),
headers_encrypted: HashMap::new(),
header_credential_refs: HashMap::new(),
transport_kind: None,
connect_timeout_ms: None,
request_timeout_ms: server.request_timeout_ms,
healthcheck_interval_ms: server.healthcheck_interval_ms,
reconnect: server.reconnect.clone(),
allowed_tools: server.allowed_tools.clone(),
denied_tools: server.denied_tools.clone(),
transport: None,
};
match &server.transport {
TransportConfig::Stdio(stdio) => {
out.command = Some(stdio.command.clone());
out.args = stdio.args.clone();
out.cwd = stdio.cwd.clone();
out.env = stdio
.env
.iter()
.filter(|(name, value)| {
!stdio.env_encrypted.contains_key(*name) || !value.is_empty()
})
.map(|(name, value)| (name.clone(), value.clone()))
.collect();
out.env_encrypted = stdio.env_encrypted.clone();
out.env_credential_refs = stdio.env_credential_refs.clone();
out.startup_timeout_ms = Some(stdio.startup_timeout_ms);
}
TransportConfig::Sse(sse) => {
out.url = Some(sse.url.clone());
out.headers = sse
.headers
.iter()
.filter(|header| {
!header.name.trim().is_empty()
&& (header.value_encrypted.is_none() || !header.value.is_empty())
})
.map(|h| (h.name.clone(), h.value.clone()))
.collect();
out.headers_encrypted = sse
.headers
.iter()
.filter_map(|header| {
header
.value_encrypted
.as_ref()
.map(|value| (header.name.clone(), value.clone()))
})
.collect();
out.header_credential_refs = sse
.headers
.iter()
.filter_map(|header| {
header
.credential_ref
.as_ref()
.map(|value| (header.name.clone(), value.clone()))
})
.collect();
out.connect_timeout_ms = Some(sse.connect_timeout_ms);
}
TransportConfig::StreamableHttp(config) => {
out.url = Some(config.url.clone());
out.headers = config
.headers
.iter()
.filter(|header| {
!header.name.trim().is_empty()
&& (header.value_encrypted.is_none() || !header.value.is_empty())
})
.map(|header| (header.name.clone(), header.value.clone()))
.collect();
out.headers_encrypted = config
.headers
.iter()
.filter_map(|header| {
header
.value_encrypted
.as_ref()
.map(|value| (header.name.clone(), value.clone()))
})
.collect();
out.header_credential_refs = config
.headers
.iter()
.filter_map(|header| {
header
.credential_ref
.as_ref()
.map(|value| (header.name.clone(), value.clone()))
})
.collect();
out.transport_kind = Some("streamable_http".to_string());
out.connect_timeout_ms = Some(config.connect_timeout_ms);
}
}
out
}
}
impl Serialize for McpConfig {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(self.servers.len()))?;
for server in &self.servers {
let entry = McpServerDiskOut::from(server);
map.serialize_entry(&server.id, &entry)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for McpConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
if value.get("servers").is_some() {
let legacy: McpConfigLegacyDisk =
serde_json::from_value(value).map_err(de::Error::custom)?;
return Ok(Self {
version: legacy.version,
servers: legacy.servers,
});
}
let Some(obj) = value.as_object() else {
return Err(de::Error::custom(
"MCP config must be an object (legacy {version,servers} or a server map)",
));
};
let mut servers = Vec::with_capacity(obj.len());
for (id, raw_entry) in obj.iter() {
let mut entry = raw_entry.clone();
let entry_obj = entry
.as_object_mut()
.ok_or_else(|| de::Error::custom("MCP server entry must be an object"))?;
entry_obj.insert("id".to_string(), Value::String(id.clone()));
if let Ok(server) = serde_json::from_value::<McpServerConfig>(entry.clone()) {
servers.push(server);
continue;
}
let flat: McpServerConfigFlatDisk =
serde_json::from_value(entry).map_err(de::Error::custom)?;
let server = flat.into_internal().map_err(de::Error::custom)?;
servers.push(server);
}
Ok(Self {
version: default_version(),
servers,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub transport: TransportConfig,
#[serde(default = "default_request_timeout")]
pub request_timeout_ms: u64,
#[serde(default = "default_healthcheck_interval")]
pub healthcheck_interval_ms: u64,
#[serde(default)]
pub reconnect: ReconnectConfig,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub denied_tools: Vec<String>,
}
fn default_true() -> bool {
true
}
pub fn default_request_timeout() -> u64 {
60000 }
pub fn default_healthcheck_interval() -> u64 {
30000 }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum TransportConfig {
Stdio(StdioConfig),
Sse(SseConfig),
#[serde(rename = "streamable_http")]
StreamableHttp(StreamableHttpConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StdioConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing)]
pub env_encrypted: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env_credential_refs: HashMap<String, String>,
#[serde(default = "default_startup_timeout")]
pub startup_timeout_ms: u64,
}
pub fn default_startup_timeout() -> u64 {
20000 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SseConfig {
pub url: String,
#[serde(default)]
pub headers: Vec<HeaderConfig>,
#[serde(default = "default_connect_timeout")]
pub connect_timeout_ms: u64,
}
pub fn default_connect_timeout() -> u64 {
10000 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamableHttpConfig {
pub url: String,
#[serde(default)]
pub headers: Vec<HeaderConfig>,
#[serde(default = "default_connect_timeout")]
pub connect_timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderConfig {
pub name: String,
#[serde(default)]
pub value: String,
#[serde(default, skip_serializing)]
pub value_encrypted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReconnectConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_initial_backoff")]
pub initial_backoff_ms: u64,
#[serde(default = "default_max_backoff")]
pub max_backoff_ms: u64,
#[serde(default)]
pub max_attempts: u32,
}
impl Default for ReconnectConfig {
fn default() -> Self {
Self {
enabled: true,
initial_backoff_ms: 1000,
max_backoff_ms: 30000,
max_attempts: 0,
}
}
}
fn default_initial_backoff() -> u64 {
1000
}
fn default_max_backoff() -> u64 {
30000
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_config_default() {
let config = McpConfig::default();
assert_eq!(config.version, 1);
assert!(config.servers.is_empty());
}
#[test]
fn test_mcp_config_deserialization() {
let json = r#"{"version": 2, "servers": []}"#;
let config: McpConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 2);
assert!(config.servers.is_empty());
}
#[test]
fn test_mcp_config_default_version() {
let json = r#"{"servers": []}"#;
let config: McpConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
}
#[test]
fn test_mcp_server_config_minimal() {
let json = r#"{
"id": "test-server",
"transport": {
"type": "stdio",
"command": "node"
}
}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.id, "test-server");
assert!(config.enabled); assert_eq!(config.request_timeout_ms, 60000); assert_eq!(config.healthcheck_interval_ms, 30000); assert!(config.reconnect.enabled); assert!(config.allowed_tools.is_empty());
assert!(config.denied_tools.is_empty());
}
#[test]
fn test_mcp_server_config_full() {
let json = r#"{
"id": "test-server",
"name": "Test Server",
"enabled": false,
"transport": {
"type": "stdio",
"command": "node",
"args": ["server.js"],
"cwd": "/app",
"env": {"NODE_ENV": "production"},
"startup_timeout_ms": 30000
},
"request_timeout_ms": 120000,
"healthcheck_interval_ms": 60000,
"reconnect": {
"enabled": true,
"initial_backoff_ms": 2000,
"max_backoff_ms": 60000,
"max_attempts": 5
},
"allowed_tools": ["tool1", "tool2"],
"denied_tools": ["tool3"]
}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.id, "test-server");
assert_eq!(config.name, Some("Test Server".to_string()));
assert!(!config.enabled);
assert_eq!(config.request_timeout_ms, 120000);
assert_eq!(config.healthcheck_interval_ms, 60000);
assert!(config.reconnect.enabled);
assert_eq!(config.reconnect.initial_backoff_ms, 2000);
assert_eq!(config.reconnect.max_backoff_ms, 60000);
assert_eq!(config.reconnect.max_attempts, 5);
assert_eq!(config.allowed_tools, vec!["tool1", "tool2"]);
assert_eq!(config.denied_tools, vec!["tool3"]);
}
#[test]
fn test_stdio_config() {
let json = r#"{
"type": "stdio",
"command": "python",
"args": ["-m", "server"],
"cwd": "/home/user",
"env": {"DEBUG": "1"},
"startup_timeout_ms": 15000
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "python");
assert_eq!(stdio.args, vec!["-m", "server"]);
assert_eq!(stdio.cwd, Some("/home/user".to_string()));
assert_eq!(stdio.env.get("DEBUG"), Some(&"1".to_string()));
assert_eq!(stdio.startup_timeout_ms, 15000);
}
_ => panic!("Expected Stdio transport"),
}
}
#[test]
fn test_stdio_config_minimal() {
let json = r#"{
"type": "stdio",
"command": "node"
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "node");
assert!(stdio.args.is_empty());
assert!(stdio.cwd.is_none());
assert!(stdio.env.is_empty());
assert_eq!(stdio.startup_timeout_ms, 20000); }
_ => panic!("Expected Stdio transport"),
}
}
#[test]
fn test_sse_config() {
let json = r#"{
"type": "sse",
"url": "http://localhost:8080/sse",
"headers": [
{"name": "Authorization", "value": "Bearer token123"}
],
"connect_timeout_ms": 5000
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::Sse(sse) => {
assert_eq!(sse.url, "http://localhost:8080/sse");
assert_eq!(sse.headers.len(), 1);
assert_eq!(sse.headers[0].name, "Authorization");
assert_eq!(sse.headers[0].value, "Bearer token123");
assert_eq!(sse.connect_timeout_ms, 5000);
}
_ => panic!("Expected SSE transport"),
}
}
#[test]
fn test_sse_config_minimal() {
let json = r#"{
"type": "sse",
"url": "http://localhost:8080/sse"
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::Sse(sse) => {
assert_eq!(sse.url, "http://localhost:8080/sse");
assert!(sse.headers.is_empty());
assert_eq!(sse.connect_timeout_ms, 10000); }
_ => panic!("Expected SSE transport"),
}
}
#[test]
fn test_streamable_http_config() {
let json = r#"{
"type": "streamable_http",
"url": "http://localhost:3000/mcp",
"headers": [
{"name": "Authorization", "value": "Bearer token123"}
],
"connect_timeout_ms": 5000
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::StreamableHttp(cfg) => {
assert_eq!(cfg.url, "http://localhost:3000/mcp");
assert_eq!(cfg.headers.len(), 1);
assert_eq!(cfg.headers[0].name, "Authorization");
assert_eq!(cfg.connect_timeout_ms, 5000);
}
_ => panic!("Expected StreamableHttp transport"),
}
}
#[test]
fn test_streamable_http_config_minimal() {
let json = r#"{
"type": "streamable_http",
"url": "http://localhost:3000/mcp"
}"#;
let config: TransportConfig = serde_json::from_str(json).unwrap();
match config {
TransportConfig::StreamableHttp(cfg) => {
assert_eq!(cfg.url, "http://localhost:3000/mcp");
assert!(cfg.headers.is_empty());
assert_eq!(cfg.connect_timeout_ms, 10000); }
_ => panic!("Expected StreamableHttp transport"),
}
}
#[test]
fn test_streamable_http_round_trip() {
let cfg = McpConfig {
version: 1,
servers: vec![McpServerConfig {
id: "test-sh".to_string(),
name: None,
enabled: true,
transport: TransportConfig::StreamableHttp(StreamableHttpConfig {
url: "http://localhost:3000/mcp".to_string(),
headers: vec![HeaderConfig {
name: "Authorization".to_string(),
value: "Bearer token".to_string(),
value_encrypted: None,
credential_ref: None,
}],
connect_timeout_ms: 5000,
}),
request_timeout_ms: default_request_timeout(),
healthcheck_interval_ms: default_healthcheck_interval(),
reconnect: ReconnectConfig::default(),
allowed_tools: vec![],
denied_tools: vec![],
}],
};
let json = serde_json::to_string(&cfg).unwrap();
let parsed: McpConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.servers.len(), 1);
match &parsed.servers[0].transport {
TransportConfig::StreamableHttp(sh) => {
assert_eq!(sh.url, "http://localhost:3000/mcp");
assert_eq!(sh.connect_timeout_ms, 5000);
}
_ => panic!("Expected StreamableHttp transport"),
}
}
#[test]
fn test_reconnect_config_default() {
let config = ReconnectConfig::default();
assert!(config.enabled);
assert_eq!(config.initial_backoff_ms, 1000);
assert_eq!(config.max_backoff_ms, 30000);
assert_eq!(config.max_attempts, 0); }
#[test]
fn test_reconnect_config_unlimited_attempts() {
let json = r#"{
"enabled": true,
"initial_backoff_ms": 500,
"max_backoff_ms": 10000
}"#;
let config: ReconnectConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.initial_backoff_ms, 500);
assert_eq!(config.max_backoff_ms, 10000);
assert_eq!(config.max_attempts, 0);
}
#[test]
fn test_reconnect_config_disabled() {
let json = r#"{"enabled": false}"#;
let config: ReconnectConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
}
#[test]
fn test_header_config() {
let header = HeaderConfig {
name: "Content-Type".to_string(),
value: "application/json".to_string(),
value_encrypted: None,
credential_ref: None,
};
assert_eq!(header.name, "Content-Type");
assert_eq!(header.value, "application/json");
}
#[test]
fn test_full_mcp_config() {
let json = r#"{
"version": 1,
"servers": [
{
"id": "fs-server",
"transport": {
"type": "stdio",
"command": "mcp-server-filesystem"
}
},
{
"id": "web-server",
"transport": {
"type": "sse",
"url": "http://localhost:3000/sse"
}
}
]
}"#;
let config: McpConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.servers.len(), 2);
assert_eq!(config.servers[0].id, "fs-server");
assert_eq!(config.servers[1].id, "web-server");
}
#[test]
fn test_mcp_config_deserialization_mainstream_map_stdio() {
let json = r#"{
"filesystem": {
"command": "node",
"args": ["server.js"],
"env": {"MCP_ROOT": "/tmp"}
}
}"#;
let config: McpConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.servers.len(), 1);
assert_eq!(config.servers[0].id, "filesystem");
match &config.servers[0].transport {
TransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "node");
assert_eq!(stdio.args, vec!["server.js"]);
assert_eq!(stdio.env.get("MCP_ROOT").map(|s| s.as_str()), Some("/tmp"));
}
_ => panic!("Expected stdio transport"),
}
}
#[test]
fn test_mcp_config_serialization_is_map() {
let mut env_encrypted = HashMap::new();
env_encrypted.insert("TOKEN".to_string(), "nonce:ciphertext".to_string());
let cfg = McpConfig {
version: 1,
servers: vec![McpServerConfig {
id: "demo".to_string(),
name: Some("Demo".to_string()),
enabled: true,
transport: TransportConfig::Stdio(StdioConfig {
command: "node".to_string(),
args: vec!["server.js".to_string()],
cwd: None,
env: HashMap::new(),
env_encrypted,
env_credential_refs: HashMap::new(),
startup_timeout_ms: default_startup_timeout(),
}),
request_timeout_ms: default_request_timeout(),
healthcheck_interval_ms: default_healthcheck_interval(),
reconnect: ReconnectConfig::default(),
allowed_tools: vec![],
denied_tools: vec![],
}],
};
let value = serde_json::to_value(&cfg).unwrap();
assert!(value.get("servers").is_none());
assert!(value.get("demo").is_some());
}
#[test]
fn public_serde_retains_plaintext_alongside_ciphertext() {
let server = |id: &str, transport| McpServerConfig {
id: id.to_string(),
name: None,
enabled: true,
transport,
request_timeout_ms: default_request_timeout(),
healthcheck_interval_ms: default_healthcheck_interval(),
reconnect: ReconnectConfig::default(),
allowed_tools: vec![],
denied_tools: vec![],
};
let cfg = McpConfig {
version: 1,
servers: vec![
server(
"stdio",
TransportConfig::Stdio(StdioConfig {
command: "node".to_string(),
args: vec![],
cwd: None,
env: HashMap::from([("TOKEN".to_string(), "stdio-secret".to_string())]),
env_encrypted: HashMap::from([(
"TOKEN".to_string(),
"stdio-ciphertext".to_string(),
)]),
env_credential_refs: HashMap::new(),
startup_timeout_ms: default_startup_timeout(),
}),
),
server(
"sse",
TransportConfig::Sse(SseConfig {
url: "https://example.test/sse".to_string(),
headers: vec![HeaderConfig {
name: "Authorization".to_string(),
value: "sse-secret".to_string(),
value_encrypted: Some("sse-ciphertext".to_string()),
credential_ref: None,
}],
connect_timeout_ms: default_connect_timeout(),
}),
),
server(
"http",
TransportConfig::StreamableHttp(StreamableHttpConfig {
url: "https://example.test/mcp".to_string(),
headers: vec![HeaderConfig {
name: "Authorization".to_string(),
value: "http-secret".to_string(),
value_encrypted: Some("http-ciphertext".to_string()),
credential_ref: None,
}],
connect_timeout_ms: default_connect_timeout(),
}),
),
],
};
let value = serde_json::to_value(&cfg).unwrap();
assert_eq!(value["stdio"]["env"]["TOKEN"], "stdio-secret");
assert_eq!(value["stdio"]["env_encrypted"]["TOKEN"], "stdio-ciphertext");
assert_eq!(value["sse"]["headers"]["Authorization"], "sse-secret");
assert_eq!(
value["sse"]["headers_encrypted"]["Authorization"],
"sse-ciphertext"
);
assert_eq!(value["http"]["headers"]["Authorization"], "http-secret");
assert_eq!(
value["http"]["headers_encrypted"]["Authorization"],
"http-ciphertext"
);
let round_trip: McpConfig = serde_json::from_value(value).unwrap();
let stdio_server = round_trip
.servers
.iter()
.find(|server| server.id == "stdio")
.unwrap();
let TransportConfig::Stdio(stdio) = &stdio_server.transport else {
panic!("expected stdio transport");
};
assert_eq!(stdio.env["TOKEN"], "stdio-secret");
assert_eq!(stdio.env_encrypted["TOKEN"], "stdio-ciphertext");
let sse_server = round_trip
.servers
.iter()
.find(|server| server.id == "sse")
.unwrap();
let TransportConfig::Sse(sse) = &sse_server.transport else {
panic!("expected SSE transport");
};
assert_eq!(sse.headers[0].value, "sse-secret");
assert_eq!(
sse.headers[0].value_encrypted.as_deref(),
Some("sse-ciphertext")
);
let http_server = round_trip
.servers
.iter()
.find(|server| server.id == "http")
.unwrap();
let TransportConfig::StreamableHttp(http) = &http_server.transport else {
panic!("expected streamable HTTP transport");
};
assert_eq!(http.headers[0].value, "http-secret");
assert_eq!(
http.headers[0].value_encrypted.as_deref(),
Some("http-ciphertext")
);
}
#[test]
fn test_server_config_disabled() {
let json = r#"{
"id": "disabled-server",
"enabled": false,
"transport": {"type": "stdio", "command": "node"}
}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
}
}