use crate::path::sanitize_path_for_error;
use crate::redact::{RedactedItems, RedactedMapValues, RedactedUrl};
use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Duration;
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_DISCOVER_TIMEOUT: Duration = Duration::from_secs(30);
const fn default_connect_timeout() -> Duration {
DEFAULT_CONNECT_TIMEOUT
}
const fn default_discover_timeout() -> Duration {
DEFAULT_DISCOVER_TIMEOUT
}
static EMPTY_MAP: LazyLock<HashMap<String, String>> = LazyLock::new(HashMap::new);
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "transport", rename_all = "lowercase")]
pub enum Transport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
cwd: Option<PathBuf>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
Sse {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
}
impl fmt::Debug for Transport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Stdio {
command,
args,
env,
cwd,
} => f
.debug_struct("Stdio")
.field("command", &sanitize_path_for_error(Path::new(command)))
.field("args", &RedactedItems(args))
.field("env", &RedactedMapValues(env))
.field("cwd", &cwd.as_deref().map(sanitize_path_for_error))
.finish(),
Self::Http { url, headers } => f
.debug_struct("Http")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedMapValues(headers))
.finish(),
Self::Sse { url, headers } => f
.debug_struct("Sse")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedMapValues(headers))
.finish(),
}
}
}
#[derive(Clone, Serialize, PartialEq, Eq)]
pub struct ServerConfig {
#[serde(flatten)]
transport: Transport,
#[serde(default = "default_connect_timeout")]
connect_timeout: Duration,
#[serde(default = "default_discover_timeout")]
discover_timeout: Duration,
}
impl<'de> Deserialize<'de> for ServerConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
#[serde(flatten)]
transport: Transport,
#[serde(default = "default_connect_timeout")]
connect_timeout: Duration,
#[serde(default = "default_discover_timeout")]
discover_timeout: Duration,
}
let raw = Raw::deserialize(deserializer)?;
let config = Self {
transport: raw.transport,
connect_timeout: raw.connect_timeout,
discover_timeout: raw.discover_timeout,
};
crate::validate_server_config(&config).map_err(D::Error::custom)?;
Ok(config)
}
}
impl fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut s = f.debug_struct("ServerConfig");
match &self.transport {
Transport::Stdio {
command,
args,
env,
cwd,
} => {
s.field("transport", &"stdio")
.field("command", &sanitize_path_for_error(Path::new(command)))
.field("args", &RedactedItems(args))
.field("env", &RedactedMapValues(env))
.field("cwd", &cwd.as_deref().map(sanitize_path_for_error));
}
Transport::Http { url, headers } => {
s.field("transport", &"http")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedMapValues(headers));
}
Transport::Sse { url, headers } => {
s.field("transport", &"sse")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedMapValues(headers));
}
}
s.field("connect_timeout", &self.connect_timeout)
.field("discover_timeout", &self.discover_timeout)
.finish()
}
}
impl ServerConfig {
#[must_use]
pub fn builder() -> ServerConfigBuilder {
ServerConfigBuilder::default()
}
#[must_use]
pub const fn transport(&self) -> &Transport {
&self.transport
}
#[must_use]
pub fn command(&self) -> Option<&str> {
match &self.transport {
Transport::Stdio { command, .. } => Some(command),
Transport::Http { .. } | Transport::Sse { .. } => None,
}
}
#[must_use]
pub fn args(&self) -> &[String] {
match &self.transport {
Transport::Stdio { args, .. } => args,
Transport::Http { .. } | Transport::Sse { .. } => &[],
}
}
#[must_use]
pub fn env(&self) -> &HashMap<String, String> {
match &self.transport {
Transport::Stdio { env, .. } => env,
Transport::Http { .. } | Transport::Sse { .. } => &EMPTY_MAP,
}
}
#[must_use]
pub const fn cwd(&self) -> Option<&PathBuf> {
match &self.transport {
Transport::Stdio { cwd, .. } => cwd.as_ref(),
Transport::Http { .. } | Transport::Sse { .. } => None,
}
}
#[must_use]
pub fn url(&self) -> Option<&str> {
match &self.transport {
Transport::Stdio { .. } => None,
Transport::Http { url, .. } | Transport::Sse { url, .. } => Some(url),
}
}
#[must_use]
pub fn headers(&self) -> &HashMap<String, String> {
match &self.transport {
Transport::Stdio { .. } => &EMPTY_MAP,
Transport::Http { headers, .. } | Transport::Sse { headers, .. } => headers,
}
}
#[must_use]
pub const fn connect_timeout(&self) -> Duration {
self.connect_timeout
}
#[must_use]
pub const fn discover_timeout(&self) -> Duration {
self.discover_timeout
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum TransportKind {
#[default]
Stdio,
Http,
Sse,
}
#[derive(Clone)]
pub struct ServerConfigBuilder {
transport: TransportKind,
command: Option<String>,
args: Vec<String>,
env: HashMap<String, String>,
cwd: Option<PathBuf>,
url: Option<String>,
headers: HashMap<String, String>,
connect_timeout: Duration,
discover_timeout: Duration,
}
impl fmt::Debug for ServerConfigBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ServerConfigBuilder")
.field("transport", &self.transport)
.field(
"command",
&self
.command
.as_deref()
.map(|command| sanitize_path_for_error(Path::new(command))),
)
.field("args", &RedactedItems(&self.args))
.field("env", &RedactedMapValues(&self.env))
.field("cwd", &self.cwd.as_deref().map(sanitize_path_for_error))
.field("url", &self.url.as_deref().map(RedactedUrl))
.field("headers", &RedactedMapValues(&self.headers))
.field("connect_timeout", &self.connect_timeout)
.field("discover_timeout", &self.discover_timeout)
.finish()
}
}
impl Default for ServerConfigBuilder {
fn default() -> Self {
Self {
transport: TransportKind::default(),
command: None,
args: Vec::new(),
env: HashMap::new(),
cwd: None,
url: None,
headers: HashMap::new(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
discover_timeout: DEFAULT_DISCOVER_TIMEOUT,
}
}
}
impl ServerConfigBuilder {
#[must_use]
pub fn command(mut self, command: String) -> Self {
self.command = Some(command);
self
}
#[must_use]
pub fn arg(mut self, arg: String) -> Self {
self.args.push(arg);
self
}
#[must_use]
pub fn args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
#[must_use]
pub fn env(mut self, key: String, value: String) -> Self {
self.env.insert(key, value);
self
}
#[must_use]
pub fn environment(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
#[must_use]
pub fn cwd(mut self, cwd: PathBuf) -> Self {
self.cwd = Some(cwd);
self
}
#[must_use]
pub fn http_transport(mut self, url: String) -> Self {
self.transport = TransportKind::Http;
self.url = Some(url);
self
}
#[must_use]
pub fn sse_transport(mut self, url: String) -> Self {
self.transport = TransportKind::Sse;
self.url = Some(url);
self
}
#[must_use]
pub fn url(mut self, url: String) -> Self {
self.url = Some(url);
self
}
#[must_use]
pub fn header(mut self, key: String, value: String) -> Self {
self.headers.insert(key, value);
self
}
#[must_use]
pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = headers;
self
}
#[must_use]
pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
#[must_use]
pub const fn discover_timeout(mut self, timeout: Duration) -> Self {
self.discover_timeout = timeout;
self
}
pub fn build(self) -> crate::Result<ServerConfig> {
let config = self.build_structural()?;
crate::validate_server_config(&config)?;
Ok(config)
}
fn build_structural(self) -> crate::Result<ServerConfig> {
let transport = match self.transport {
TransportKind::Stdio => {
let command = self.command.ok_or_else(|| crate::Error::ValidationError {
field: "command".to_string(),
reason: "command is required for stdio transport".to_string(),
})?;
if command.trim().is_empty() {
return Err(crate::Error::ValidationError {
field: "command".to_string(),
reason: "command cannot be empty for stdio transport".to_string(),
});
}
Transport::Stdio {
command,
args: self.args,
env: self.env,
cwd: self.cwd,
}
}
TransportKind::Http => {
let url = self.url.ok_or_else(|| crate::Error::ValidationError {
field: "url".to_string(),
reason: "url is required for HTTP transport".to_string(),
})?;
Transport::Http {
url,
headers: self.headers,
}
}
TransportKind::Sse => {
let url = self.url.ok_or_else(|| crate::Error::ValidationError {
field: "url".to_string(),
reason: "url is required for SSE transport".to_string(),
})?;
Transport::Sse {
url,
headers: self.headers,
}
}
};
Ok(ServerConfig {
transport,
connect_timeout: self.connect_timeout,
discover_timeout: self.discover_timeout,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_builder_minimal() {
let config = ServerConfig::builder()
.command("docker".to_string())
.build()
.unwrap();
assert_eq!(config.command(), Some("docker"));
assert!(config.args().is_empty());
assert!(config.env().is_empty());
assert!(config.cwd().is_none());
}
#[test]
fn test_server_config_builder_with_args() {
let config = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.arg("--rm".to_string())
.arg("mcp-server".to_string())
.build()
.unwrap();
assert_eq!(config.command(), Some("docker"));
assert_eq!(config.args(), vec!["run", "--rm", "mcp-server"]);
}
#[test]
fn test_server_config_builder_with_args_vec() {
let config = ServerConfig::builder()
.command("docker".to_string())
.args(vec!["run".to_string(), "--rm".to_string()])
.build()
.unwrap();
assert_eq!(config.args(), vec!["run", "--rm"]);
}
#[test]
fn test_server_config_builder_with_env() {
let config = ServerConfig::builder()
.command("mcp-server".to_string())
.env("LOG_LEVEL".to_string(), "debug".to_string())
.env("DEBUG".to_string(), "1".to_string())
.build()
.unwrap();
assert_eq!(config.env().len(), 2);
assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
assert_eq!(config.env().get("DEBUG"), Some(&"1".to_string()));
}
#[test]
fn test_server_config_builder_with_environment_map() {
let mut env_map = HashMap::new();
env_map.insert("VAR1".to_string(), "value1".to_string());
env_map.insert("VAR2".to_string(), "value2".to_string());
let config = ServerConfig::builder()
.command("mcp-server".to_string())
.environment(env_map)
.build()
.unwrap();
assert_eq!(config.env().len(), 2);
}
#[test]
fn test_server_config_builder_with_cwd() {
let config = ServerConfig::builder()
.command("mcp-server".to_string())
.cwd(PathBuf::from("/tmp"))
.build()
.unwrap();
assert_eq!(config.cwd(), Some(&PathBuf::from("/tmp")));
}
#[test]
fn test_server_config_builder_full() {
let mut env_map = HashMap::new();
env_map.insert("LOG_LEVEL".to_string(), "debug".to_string());
let config = ServerConfig::builder()
.command("mcp-server".to_string())
.args(vec!["--port".to_string(), "8080".to_string()])
.environment(env_map)
.cwd(PathBuf::from("/var/run"))
.build()
.unwrap();
assert_eq!(config.command(), Some("mcp-server"));
assert_eq!(config.args().len(), 2);
assert_eq!(config.env().len(), 1);
assert_eq!(config.cwd(), Some(&PathBuf::from("/var/run")));
}
#[test]
#[should_panic(expected = "command")]
fn test_server_config_builder_missing_command() {
let _ = ServerConfig::builder().build().unwrap();
}
#[test]
fn test_server_config_builder_build_missing_command() {
let result = ServerConfig::builder().build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("command"));
}
#[test]
fn test_build_rejects_shell_metacharacters_at_construction() {
let result = ServerConfig::builder()
.command("docker".to_string())
.arg("run; rm -rf /".to_string())
.build();
assert!(result.is_err());
assert!(result.unwrap_err().is_security_error());
}
#[test]
fn test_build_rejects_forbidden_env_var_at_construction() {
let result = ServerConfig::builder()
.command("docker".to_string())
.env("LD_PRELOAD".to_string(), "/evil.so".to_string())
.build();
assert!(result.is_err());
assert!(result.unwrap_err().is_security_error());
}
#[test]
fn test_server_config_accessors() {
let config = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.env("VAR".to_string(), "value".to_string())
.cwd(PathBuf::from("/tmp"))
.build()
.unwrap();
assert_eq!(config.command(), Some("docker"));
assert_eq!(config.args(), &["run".to_string()]);
assert_eq!(config.env().len(), 1);
assert_eq!(config.cwd(), Some(&PathBuf::from("/tmp")));
}
#[test]
fn test_server_config_serialize_deserialize() {
let config = ServerConfig::builder()
.command("mcp-server".to_string())
.arg("--verbose".to_string())
.env("DEBUG".to_string(), "1".to_string())
.build()
.unwrap();
let json = serde_json::to_string(&config).unwrap();
let deserialized: ServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_server_config_clone() {
let config = ServerConfig::builder()
.command("docker".to_string())
.build()
.unwrap();
let cloned = config.clone();
assert_eq!(config, cloned);
}
#[test]
fn test_server_config_debug() {
let config = ServerConfig::builder()
.command("docker".to_string())
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(debug_str.contains("docker"));
}
#[test]
fn test_server_config_debug_redacts_header_values() {
let config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(
"Authorization".to_string(),
"Bearer sk-secret-header-value".to_string(),
)
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(debug_str.contains("Authorization"));
assert!(!debug_str.contains("sk-secret-header-value"));
}
#[test]
fn test_server_config_debug_redacts_env_values() {
let config = ServerConfig::builder()
.command("docker".to_string())
.env(
"GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
"ghp_supersecretvalue".to_string(),
)
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
assert!(!debug_str.contains("ghp_supersecretvalue"));
}
#[test]
fn test_server_config_debug_redacts_args() {
let secret = "sk-live-secret-arg-value";
let config = ServerConfig::builder()
.command("docker".to_string())
.arg("--api-key".to_string())
.arg(secret.to_string())
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(!debug_str.contains(secret));
}
#[test]
fn test_server_config_debug_redacts_url_userinfo_and_query() {
let secret = "hunter2";
let config = ServerConfig::builder()
.http_transport(format!(
"https://user:{secret}@api.example.com/mcp?token={secret}"
))
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(!debug_str.contains(secret));
assert!(debug_str.contains("api.example.com/mcp"));
}
#[test]
fn test_server_config_builder_debug_redacts_args() {
let secret = "sk-live-secret-arg-value";
let builder = ServerConfig::builder()
.command("docker".to_string())
.arg("--api-key".to_string())
.arg(secret.to_string());
let debug_str = format!("{builder:?}");
assert!(!debug_str.contains(secret));
}
#[test]
fn test_server_config_builder_debug_redacts_url_userinfo_and_query() {
let secret = "hunter2";
let builder = ServerConfig::builder().url(format!(
"https://user:{secret}@api.example.com/mcp?token={secret}"
));
let debug_str = format!("{builder:?}");
assert!(!debug_str.contains(secret));
assert!(debug_str.contains("api.example.com/mcp"));
}
#[test]
fn test_server_config_builder_debug_redacts_env_and_header_values() {
let builder = ServerConfig::builder()
.command("docker".to_string())
.env(
"GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
"ghp_supersecretvalue".to_string(),
)
.header(
"Authorization".to_string(),
"Bearer sk-secret-header-value".to_string(),
);
let debug_str = format!("{builder:?}");
assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
assert!(debug_str.contains("Authorization"));
assert!(!debug_str.contains("ghp_supersecretvalue"));
assert!(!debug_str.contains("sk-secret-header-value"));
}
#[test]
fn test_server_config_serialize_still_contains_real_secret_values() {
let http_config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(
"Authorization".to_string(),
"Bearer sk-secret-header-value".to_string(),
)
.build()
.unwrap();
let http_json = serde_json::to_string(&http_config).unwrap();
assert!(http_json.contains("sk-secret-header-value"));
let http_deserialized: ServerConfig = serde_json::from_str(&http_json).unwrap();
assert_eq!(http_config, http_deserialized);
let stdio_config = ServerConfig::builder()
.command("docker".to_string())
.env(
"GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
"ghp_supersecretvalue".to_string(),
)
.build()
.unwrap();
let stdio_json = serde_json::to_string(&stdio_config).unwrap();
assert!(stdio_json.contains("ghp_supersecretvalue"));
let stdio_deserialized: ServerConfig = serde_json::from_str(&stdio_json).unwrap();
assert_eq!(stdio_config, stdio_deserialized);
}
#[test]
fn test_transport_kind_default() {
assert_eq!(TransportKind::default(), TransportKind::Stdio);
}
#[test]
fn test_server_config_http_transport() {
let config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Http { .. }));
assert_eq!(config.url(), Some("https://api.example.com/mcp"));
assert!(config.headers().is_empty());
assert!(config.command().is_none());
assert!(config.args().is_empty());
assert!(config.env().is_empty());
assert_eq!(config.cwd(), None);
}
#[test]
fn test_server_config_http_with_headers() {
let config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "Bearer token".to_string())
.header("Content-Type".to_string(), "application/json".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Http { .. }));
assert_eq!(config.headers().len(), 2);
assert_eq!(
config.headers().get("Authorization"),
Some(&"Bearer token".to_string())
);
assert_eq!(
config.headers().get("Content-Type"),
Some(&"application/json".to_string())
);
}
#[test]
fn test_server_config_http_with_headers_map() {
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer token".to_string());
let config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.headers(headers)
.build()
.unwrap();
assert_eq!(config.headers().len(), 1);
}
#[test]
fn test_server_config_default_builder_build_missing_command() {
let result = ServerConfig::builder().build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("required"));
}
#[test]
fn test_server_config_http_build_missing_url() {
let mut builder = ServerConfig::builder();
builder.transport = TransportKind::Http;
let result = builder.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("url is required"));
}
#[test]
fn test_server_config_http_accessors() {
let config = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Auth".to_string(), "token".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Http { .. }));
assert_eq!(config.url(), Some("https://api.example.com/mcp"));
assert_eq!(config.headers().len(), 1);
}
#[test]
fn test_server_config_stdio_default_transport() {
let config = ServerConfig::builder()
.command("docker".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Stdio { .. }));
}
#[test]
fn test_server_config_sse_transport() {
let config = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Sse { .. }));
assert_eq!(config.url(), Some("https://api.example.com/sse"));
assert!(config.headers().is_empty());
assert!(config.command().is_none());
assert!(config.args().is_empty());
assert!(config.env().is_empty());
assert_eq!(config.cwd(), None);
}
#[test]
fn test_server_config_debug_sse() {
let secret = "sk-secret-sse-header-value";
let config = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.header("Authorization".to_string(), secret.to_string())
.build()
.unwrap();
let debug_str = format!("{config:?}");
assert!(debug_str.contains("api.example.com/sse"));
assert!(debug_str.contains("Authorization"));
assert!(!debug_str.contains(secret));
}
#[test]
fn test_server_config_sse_serialize_deserialize_round_trip() {
let config = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.header("Authorization".to_string(), "Bearer token".to_string())
.build()
.unwrap();
let json = serde_json::to_string(&config).unwrap();
let deserialized: ServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_server_config_sse_with_headers() {
let config = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.header("Authorization".to_string(), "Bearer token".to_string())
.header("X-Custom".to_string(), "value".to_string())
.build()
.unwrap();
assert!(matches!(config.transport(), Transport::Sse { .. }));
assert_eq!(config.headers().len(), 2);
assert_eq!(
config.headers().get("Authorization"),
Some(&"Bearer token".to_string())
);
assert_eq!(config.headers().get("X-Custom"), Some(&"value".to_string()));
}
#[test]
fn test_server_config_sse_build_missing_url() {
let mut builder = ServerConfig::builder();
builder.transport = TransportKind::Sse;
let result = builder.build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("url is required"));
}
#[test]
fn test_transport_serialization_tags_variant() {
let stdio = ServerConfig::builder()
.command("docker".to_string())
.build()
.unwrap();
let http = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build()
.unwrap();
let sse = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.build()
.unwrap();
assert!(
serde_json::to_string(&stdio)
.unwrap()
.contains(r#""transport":"stdio""#)
);
assert!(
serde_json::to_string(&http)
.unwrap()
.contains(r#""transport":"http""#)
);
assert!(
serde_json::to_string(&sse)
.unwrap()
.contains(r#""transport":"sse""#)
);
}
#[test]
fn test_transport_deserialization_from_tag() {
let stdio: ServerConfig =
serde_json::from_str(r#"{"transport":"stdio","command":"docker"}"#).unwrap();
let http: ServerConfig =
serde_json::from_str(r#"{"transport":"http","url":"https://api.example.com/mcp"}"#)
.unwrap();
let sse: ServerConfig =
serde_json::from_str(r#"{"transport":"sse","url":"https://api.example.com/sse"}"#)
.unwrap();
assert!(matches!(stdio.transport(), Transport::Stdio { .. }));
assert!(matches!(http.transport(), Transport::Http { .. }));
assert!(matches!(sse.transport(), Transport::Sse { .. }));
}
#[test]
fn test_deserialize_http_config_missing_url_is_rejected() {
let result: std::result::Result<ServerConfig, _> =
serde_json::from_str(r#"{"transport": "http"}"#);
assert!(result.is_err());
}
#[test]
fn test_deserialize_rejects_shell_metacharacters() {
let result: std::result::Result<ServerConfig, _> = serde_json::from_str(
r#"{"transport":"stdio","command":"docker","args":["run; rm -rf /"]}"#,
);
assert!(result.is_err());
}
#[test]
fn test_transport_debug_redacts_stdio_args_and_env() {
let secret_arg = "sk-live-secret-arg-value";
let secret_env = "ghp_supersecretvalue";
let transport = Transport::Stdio {
command: "docker".to_string(),
args: vec!["--api-key".to_string(), secret_arg.to_string()],
env: HashMap::from([(
"GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
secret_env.to_string(),
)]),
cwd: None,
};
let debug_str = format!("{transport:?}");
assert!(debug_str.contains("docker"));
assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
assert!(!debug_str.contains(secret_arg));
assert!(!debug_str.contains(secret_env));
}
#[test]
fn test_transport_debug_redacts_http_url_and_headers() {
let secret = "hunter2";
let transport = Transport::Http {
url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
headers: HashMap::from([("Authorization".to_string(), secret.to_string())]),
};
let debug_str = format!("{transport:?}");
assert!(debug_str.contains("api.example.com/mcp"));
assert!(debug_str.contains("Authorization"));
assert!(!debug_str.contains(secret));
}
#[test]
fn test_transport_debug_redacts_sse_url_and_headers() {
let secret = "sk-secret-sse-header-value";
let transport = Transport::Sse {
url: "https://api.example.com/sse".to_string(),
headers: HashMap::from([("Authorization".to_string(), secret.to_string())]),
};
let debug_str = format!("{transport:?}");
assert!(debug_str.contains("api.example.com/sse"));
assert!(debug_str.contains("Authorization"));
assert!(!debug_str.contains(secret));
}
}