use anyhow::{Context, Result};
use mcp_execution_core::{
Error as CoreError, REDACTED_PLACEHOLDER, RedactedItems, RedactedMapValues, RedactedUrl,
ServerConfig, ServerConfigBuilder, ServerId, sanitize_path_for_error,
};
use mcp_execution_skill::MAX_SERVER_ID_LENGTH;
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{debug, warn};
use url::Url;
const FALLBACK_SERVER_ID_SLUG: &str = "http-server";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpConfig {
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
#[derive(Clone)]
pub enum McpTransport {
Stdio {
command: String,
args: Vec<String>,
env: HashMap<String, String>,
cwd: Option<PathBuf>,
},
Http {
url: String,
headers: HashMap<String, String>,
},
Sse {
url: String,
headers: HashMap<String, String>,
},
}
impl fmt::Debug for McpTransport {
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(Debug, Clone)]
pub struct McpServerEntry {
pub transport: McpTransport,
pub connect_timeout_secs: Option<u64>,
pub discover_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum TransportTag {
Stdio,
Http,
Sse,
}
impl TransportTag {
const fn as_str(self) -> &'static str {
match self {
Self::Stdio => "stdio",
Self::Http => "http",
Self::Sse => "sse",
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", rename = "McpServerEntry")]
struct RawMcpServerEntry {
#[serde(rename = "type")]
transport_type: Option<TransportTag>,
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
cwd: Option<String>,
url: Option<String>,
#[serde(default)]
headers: HashMap<String, String>,
connect_timeout_secs: Option<u64>,
discover_timeout_secs: Option<u64>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
struct RedactedExtra<'a>(&'a HashMap<String, serde_json::Value>);
impl fmt::Debug for RedactedExtra<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map()
.entries(self.0.keys().map(|key| (key, REDACTED_PLACEHOLDER)))
.finish()
}
}
impl fmt::Debug for RawMcpServerEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RawMcpServerEntry")
.field("transport_type", &self.transport_type)
.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(|cwd| sanitize_path_for_error(Path::new(cwd))),
)
.field("url", &self.url.as_deref().map(RedactedUrl))
.field("headers", &RedactedMapValues(&self.headers))
.field("connect_timeout_secs", &self.connect_timeout_secs)
.field("discover_timeout_secs", &self.discover_timeout_secs)
.field("extra", &RedactedExtra(&self.extra))
.finish()
}
}
fn http_like_transport(
tag_name: &str,
command: Option<&str>,
url: Option<String>,
headers: HashMap<String, String>,
) -> Result<(String, HashMap<String, String>), String> {
if command.is_some() {
return Err(format!("{tag_name} server entry must not set \"command\""));
}
let url = url.ok_or_else(|| format!("{tag_name} server entry requires \"url\""))?;
Ok((url, headers))
}
impl TryFrom<RawMcpServerEntry> for McpServerEntry {
type Error = String;
fn try_from(raw: RawMcpServerEntry) -> Result<Self, Self::Error> {
if !raw.extra.is_empty() {
let mut keys: Vec<&str> = raw.extra.keys().map(String::as_str).collect();
keys.sort_unstable();
warn!(
"mcp.json server entry has unrecognized field(s), ignoring: {}",
keys.join(", ")
);
}
let tag = match raw.transport_type {
Some(tag) => tag,
None if raw.command.is_some() => TransportTag::Stdio,
None if raw.url.is_some() => TransportTag::Http,
None => {
return Err(
"server entry must set either \"command\" (stdio) or \"type\" and \"url\" \
(http/sse)"
.to_string(),
);
}
};
let transport = match tag {
TransportTag::Stdio => {
if raw.url.is_some() {
return Err("stdio server entry must not set \"url\"".to_string());
}
let command = raw
.command
.ok_or_else(|| "stdio server entry requires \"command\"".to_string())?;
McpTransport::Stdio {
command,
args: raw.args,
env: raw.env,
cwd: raw.cwd.map(PathBuf::from),
}
}
TransportTag::Http => {
let (url, headers) = http_like_transport(
tag.as_str(),
raw.command.as_deref(),
raw.url,
raw.headers,
)?;
McpTransport::Http { url, headers }
}
TransportTag::Sse => {
let (url, headers) = http_like_transport(
tag.as_str(),
raw.command.as_deref(),
raw.url,
raw.headers,
)?;
McpTransport::Sse { url, headers }
}
};
Ok(Self {
transport,
connect_timeout_secs: raw.connect_timeout_secs,
discover_timeout_secs: raw.discover_timeout_secs,
})
}
}
impl<'de> Deserialize<'de> for McpServerEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawMcpServerEntry::deserialize(deserializer)?;
raw.try_into().map_err(serde::de::Error::custom)
}
}
fn load_mcp_config_from(path: &Path) -> Result<McpConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read MCP config from {}", path.display()))?;
serde_json::from_str(&content).context("failed to parse MCP config JSON")
}
fn load_mcp_config() -> Result<McpConfig> {
let home = dirs::home_dir().context("failed to get home directory")?;
load_mcp_config_from(&home.join(".claude").join("mcp.json"))
}
fn list_mcp_servers_from(path: &Path) -> Result<Vec<(String, McpServerEntry)>> {
if !path.exists() {
return Ok(Vec::new());
}
let config = load_mcp_config_from(path)?;
Ok(config.mcp_servers.into_iter().collect())
}
pub(crate) fn list_mcp_servers() -> Result<Vec<(String, McpServerEntry)>> {
let home = dirs::home_dir().context("failed to get home directory")?;
list_mcp_servers_from(&home.join(".claude").join("mcp.json"))
}
pub(crate) fn get_mcp_server(name: &str) -> Result<(ServerId, ServerConfig, McpServerEntry)> {
let (server_id, entry) = get_mcp_server_entry(name)?;
let server_config = build_core_config(&entry)?;
Ok((server_id, server_config, entry))
}
pub(crate) fn get_mcp_server_entry(name: &str) -> Result<(ServerId, McpServerEntry)> {
let config = load_mcp_config()?;
let entry = config
.mcp_servers
.get(name)
.with_context(|| {
format!(
"server '{name}' not found in ~/.claude/mcp.json\n\
Hint: ensure the server is defined in ~/.claude/mcp.json under \"mcpServers\""
)
})?
.clone();
let server_id = ServerId::new(name).with_context(|| {
format!("server '{name}' in ~/.claude/mcp.json is not a valid server id")
})?;
Ok((server_id, entry))
}
pub(crate) fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
let (id, config, _) = get_mcp_server(name)?;
Ok((id, config))
}
fn builder_for_transport(transport: McpTransport) -> ServerConfigBuilder {
match transport {
McpTransport::Stdio {
command,
args,
env,
cwd,
} => {
let mut builder = ServerConfig::builder().command(command);
if !args.is_empty() {
builder = builder.args(args);
}
for (key, value) in env {
builder = builder.env(key, value);
}
if let Some(dir) = cwd {
builder = builder.cwd(dir);
}
builder
}
McpTransport::Http { url, headers } => {
let mut builder = ServerConfig::builder().http_transport(url);
for (key, value) in headers {
builder = builder.header(key, value);
}
builder
}
McpTransport::Sse { url, headers } => {
let mut builder = ServerConfig::builder().sse_transport(url);
for (key, value) in headers {
builder = builder.header(key, value);
}
builder
}
}
}
pub(crate) fn build_core_config(entry: &McpServerEntry) -> Result<ServerConfig> {
let mut builder = builder_for_transport(entry.transport.clone());
if let Some(secs) = entry.connect_timeout_secs {
builder = builder.connect_timeout(Duration::from_secs(secs));
}
if let Some(secs) = entry.discover_timeout_secs {
builder = builder.discover_timeout(Duration::from_secs(secs));
}
Ok(builder.build()?)
}
fn parse_key_value(s: &str, kind: &str) -> Result<(String, String)> {
let Some((key, value)) = s.split_once('=') else {
return Err(CoreError::InvalidArgument(format!(
"invalid {kind} format: no '=' separator found (expected KEY=VALUE)"
))
.into());
};
if key.is_empty() {
return Err(CoreError::InvalidArgument(format!(
"invalid {kind} format: key cannot be empty (expected KEY=VALUE)"
))
.into());
}
if key
.chars()
.any(|c| c.is_whitespace() || c == ':' || c.is_control())
{
return Err(CoreError::InvalidArgument(format!(
"invalid {kind} format: text before '=' contains characters that are never valid \
in a key, suggesting '=' matched inside a value rather than as the separator; \
refusing to echo it since it may contain a secret (expected KEY=VALUE)"
))
.into());
}
Ok((key.to_string(), value.to_string()))
}
#[derive(Clone)]
pub enum TransportArgs {
Stdio {
command: String,
args: Vec<String>,
env: Vec<String>,
cwd: Option<String>,
},
Http {
url: String,
headers: Vec<String>,
},
Sse {
url: String,
headers: Vec<String>,
},
}
impl fmt::Debug for TransportArgs {
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", &RedactedItems(env))
.field(
"cwd",
&cwd.as_deref()
.map(|cwd| sanitize_path_for_error(Path::new(cwd))),
)
.finish(),
Self::Http { url, headers } => f
.debug_struct("Http")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedItems(headers))
.finish(),
Self::Sse { url, headers } => f
.debug_struct("Sse")
.field("url", &RedactedUrl(url))
.field("headers", &RedactedItems(headers))
.finish(),
}
}
}
impl TryFrom<TransportArgs> for McpTransport {
type Error = anyhow::Error;
fn try_from(args: TransportArgs) -> Result<Self> {
match args {
TransportArgs::Stdio {
command,
args,
env,
cwd,
} => {
let env = env
.iter()
.map(|s| parse_key_value(s, "environment variable"))
.collect::<Result<HashMap<_, _>>>()?;
Ok(Self::Stdio {
command,
args,
env,
cwd: cwd.map(PathBuf::from),
})
}
TransportArgs::Http { url, headers } => {
let headers = headers
.iter()
.map(|s| parse_key_value(s, "header"))
.collect::<Result<HashMap<_, _>>>()?;
Ok(Self::Http { url, headers })
}
TransportArgs::Sse { url, headers } => {
let headers = headers
.iter()
.map(|s| parse_key_value(s, "header"))
.collect::<Result<HashMap<_, _>>>()?;
Ok(Self::Sse { url, headers })
}
}
}
}
pub(crate) fn build_server_config(
transport: TransportArgs,
connect_timeout_secs: Option<u64>,
discover_timeout_secs: Option<u64>,
) -> Result<(ServerId, ServerConfig)> {
let server_id = match &transport {
TransportArgs::Stdio { command, .. } => derive_server_id_from_path_or_name(command),
TransportArgs::Http { url, .. } | TransportArgs::Sse { url, .. } => {
derive_server_id_from_url(url)
}
};
let mut builder = builder_for_transport(McpTransport::try_from(transport)?);
if let Some(secs) = connect_timeout_secs {
builder = builder.connect_timeout(Duration::from_secs(secs));
}
if let Some(secs) = discover_timeout_secs {
builder = builder.discover_timeout(Duration::from_secs(secs));
}
Ok((server_id, builder.build()?))
}
#[derive(Debug, Clone)]
pub enum ServerSource {
Config {
name: String,
},
Flags {
transport: TransportArgs,
connect_timeout_secs: Option<u64>,
discover_timeout_secs: Option<u64>,
},
}
pub(crate) fn resolve_server_config(source: ServerSource) -> Result<(ServerId, ServerConfig)> {
match source {
ServerSource::Config { name } => {
debug!("Loading server configuration from ~/.claude/mcp.json: {name}");
load_server_from_config(&name)
}
ServerSource::Flags {
transport,
connect_timeout_secs,
discover_timeout_secs,
} => build_server_config(transport, connect_timeout_secs, discover_timeout_secs),
}
}
fn slugify(input: &str) -> ServerId {
let mut slug = String::with_capacity(input.len());
for ch in input.chars() {
let lower = ch.to_ascii_lowercase();
if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
slug.push(lower);
} else if slug.chars().next_back().is_some_and(|last| last != '-') {
slug.push('-');
}
}
let slug = slug.trim_matches('-');
let slug = &slug[..slug.len().min(MAX_SERVER_ID_LENGTH)];
let slug = slug.trim_end_matches('-');
ServerId::new(if slug.is_empty() {
FALLBACK_SERVER_ID_SLUG
} else {
slug
})
.expect("slugify only ever produces a valid path-segment ServerId")
}
fn derive_server_id_from_url(url: &str) -> ServerId {
let host_and_path = Url::parse(url)
.ok()
.map(|parsed| format!("{}{}", parsed.host_str().unwrap_or_default(), parsed.path()))
.unwrap_or_default();
slugify(&host_and_path)
}
pub(crate) fn derive_server_id_from_path_or_name(raw: &str) -> ServerId {
slugify(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn create_test_config(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file.flush().unwrap();
file
}
fn stdio_transport(
command: &str,
args: Vec<&str>,
env: Vec<&str>,
cwd: Option<&str>,
) -> TransportArgs {
TransportArgs::Stdio {
command: command.to_string(),
args: args.into_iter().map(String::from).collect(),
env: env.into_iter().map(String::from).collect(),
cwd: cwd.map(String::from),
}
}
fn http_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
TransportArgs::Http {
url: url.to_string(),
headers: headers.into_iter().map(String::from).collect(),
}
}
fn sse_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
TransportArgs::Sse {
url: url.to_string(),
headers: headers.into_iter().map(String::from).collect(),
}
}
#[test]
fn test_load_mcp_config_from_valid() {
let json = r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
assert!(config.mcp_servers.contains_key("github"));
}
#[test]
fn test_load_mcp_config_from_empty_servers() {
let json = r"{}";
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert!(config.mcp_servers.is_empty());
}
#[test]
fn test_load_mcp_config_from_minimal_server() {
let json = r#"{"mcpServers": {"minimal": {"command": "python"}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
let entry = &config.mcp_servers["minimal"];
match &entry.transport {
McpTransport::Stdio {
command, args, env, ..
} => {
assert_eq!(command, "python");
assert!(args.is_empty());
assert!(env.is_empty());
}
other => panic!("expected Stdio transport, got {other:?}"),
}
}
#[test]
fn test_load_mcp_config_from_multiple_servers() {
let json = r#"{
"mcpServers": {
"server1": {"command": "node", "args": ["s1.js"]},
"server2": {"command": "python", "args": ["s2.py"]}
}
}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(config.mcp_servers.contains_key("server1"));
assert!(config.mcp_servers.contains_key("server2"));
}
#[test]
fn test_load_mcp_config_from_not_found() {
let result = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("failed to read"));
}
#[test]
fn test_load_mcp_config_from_malformed_json() {
let file = create_test_config("not valid json");
let result = load_mcp_config_from(file.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parse MCP config"));
}
#[test]
fn test_load_mcp_config_mixed_stdio_http_sse() {
let json = r#"{
"mcpServers": {
"local": {"command": "node", "args": ["server.js"]},
"remote-http": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}},
"remote-sse": {"type": "sse", "url": "https://example.com/sse"}
}
}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert_eq!(config.mcp_servers.len(), 3);
assert!(matches!(
config.mcp_servers["local"].transport,
McpTransport::Stdio { .. }
));
assert!(matches!(
config.mcp_servers["remote-http"].transport,
McpTransport::Http { .. }
));
assert!(matches!(
config.mcp_servers["remote-sse"].transport,
McpTransport::Sse { .. }
));
}
#[test]
fn test_load_mcp_config_http_entry_type_absent_but_url_present() {
let json = r#"{"mcpServers": {"remote": {"url": "https://api.example.com/mcp"}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert!(matches!(
config.mcp_servers["remote"].transport,
McpTransport::Http { .. }
));
}
#[test]
fn test_load_mcp_config_http_entry_missing_url_errors_naming_url() {
let json = r#"{"mcpServers": {"remote": {"type": "http"}}}"#;
let file = create_test_config(json);
let result = load_mcp_config_from(file.path());
assert!(result.is_err());
assert!(format!("{:#}", result.unwrap_err()).contains("url"));
}
#[test]
fn test_load_mcp_config_entry_with_neither_command_nor_type_errors() {
let json = r#"{"mcpServers": {"broken": {}}}"#;
let file = create_test_config(json);
let result = load_mcp_config_from(file.path());
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("command"));
assert!(msg.contains("url"));
}
#[test]
fn test_load_mcp_config_http_entry_with_command_errors() {
let json = r#"{"mcpServers": {"bad": {"type": "http", "url": "https://x.com", "command": "node"}}}"#;
let file = create_test_config(json);
let result = load_mcp_config_from(file.path());
assert!(result.is_err());
assert!(format!("{:#}", result.unwrap_err()).contains("command"));
}
#[test]
fn test_load_mcp_config_stdio_entry_with_url_errors() {
let json = r#"{"mcpServers": {"bad": {"command": "node", "url": "https://x.com"}}}"#;
let file = create_test_config(json);
let result = load_mcp_config_from(file.path());
assert!(result.is_err());
assert!(format!("{:#}", result.unwrap_err()).contains("url"));
}
#[test]
fn test_load_mcp_config_unknown_field_still_parses() {
let json = r#"{"mcpServers": {"github": {"command": "node", "disabled": false, "description": "x"}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert!(matches!(
config.mcp_servers["github"].transport,
McpTransport::Stdio { .. }
));
}
#[test]
fn test_build_server_config_stdio() {
let (id, config) = build_server_config(
stdio_transport(
"github-mcp-server",
vec!["stdio"],
vec!["TOKEN=abc123"],
None,
),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "github-mcp-server");
assert_eq!(config.command(), Some("github-mcp-server"));
assert_eq!(config.args(), &["stdio"]);
assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
}
#[test]
fn test_build_server_config_docker() {
let (id, config) = build_server_config(
stdio_transport(
"docker",
vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"],
None,
),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "docker");
assert_eq!(config.command(), Some("docker"));
assert_eq!(
config.args(),
&["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
);
assert_eq!(
config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
Some(&"ghp_xxx".to_string())
);
}
#[test]
fn test_build_server_config_http() {
let (id, config) = build_server_config(
http_transport(
"https://api.githubcopilot.com/mcp/",
vec!["Authorization=Bearer token123"],
),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "api-githubcopilot-com-mcp");
assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
assert_eq!(
config.headers().get("Authorization"),
Some(&"Bearer token123".to_string())
);
}
#[test]
fn test_build_server_config_sse() {
let (id, config) = build_server_config(
sse_transport("https://example.com/sse", vec!["X-API-Key=secret"]),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "example-com-sse");
assert_eq!(config.url(), Some("https://example.com/sse"));
assert_eq!(
config.headers().get("X-API-Key"),
Some(&"secret".to_string())
);
}
#[test]
fn test_build_server_config_with_cwd() {
let (_, config) = build_server_config(
stdio_transport("server", vec![], vec![], Some("/tmp/workdir")),
None,
None,
)
.unwrap();
assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
}
#[test]
fn test_build_server_config_invalid_env() {
let secret = "ghp_verySECRETtoken1234567890abcdef";
let result = build_server_config(
stdio_transport("server", vec![], vec![secret], None),
None,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(format!("{err:?}").contains("expected KEY=VALUE"));
assert!(
!format!("{err:?}").contains(secret),
"error chain leaked the raw secret: {err:?}"
);
}
#[test]
fn test_build_server_config_invalid_header() {
let secret = "Bearer sk-live-supersecretvalue1234567890";
let result = build_server_config(
http_transport("https://example.com", vec![secret]),
None,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(format!("{err:?}").contains("expected KEY=VALUE"));
assert!(
!format!("{err:?}").contains(secret),
"error chain leaked the raw secret: {err:?}"
);
}
#[test]
fn test_mcp_transport_debug_redacts_headers_http() {
let secret_body = "sk-verySECRETtoken1234567890";
let secret = format!("Bearer {secret_body}");
let transport = McpTransport::Http {
url: "https://api.example.com/mcp".to_string(),
headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
};
let debug_output = format!("{transport:?}");
assert!(debug_output.contains("Authorization"));
assert!(debug_output.contains("<redacted>"));
assert!(!debug_output.contains(&secret));
assert!(!debug_output.contains(secret_body));
}
#[test]
fn test_mcp_transport_debug_redacts_headers_sse() {
let secret_body = "sk-verySECRETtoken1234567890";
let secret = format!("Bearer {secret_body}");
let transport = McpTransport::Sse {
url: "https://api.example.com/sse".to_string(),
headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
};
let debug_output = format!("{transport:?}");
assert!(debug_output.contains("Authorization"));
assert!(debug_output.contains("<redacted>"));
assert!(!debug_output.contains(&secret));
assert!(!debug_output.contains(secret_body));
}
#[test]
fn test_mcp_transport_debug_redacts_env() {
let secret_body = "ghp_verySECRETtoken1234567890abcdef";
let transport = McpTransport::Stdio {
command: "node".to_string(),
args: vec![],
env: HashMap::from([("GITHUB_TOKEN".to_string(), secret_body.to_string())]),
cwd: None,
};
let debug_output = format!("{transport:?}");
assert!(debug_output.contains("GITHUB_TOKEN"));
assert!(debug_output.contains("<redacted>"));
assert!(!debug_output.contains(secret_body));
}
#[test]
fn test_mcp_server_entry_debug_redacts_via_transport() {
let secret_body = "sk-verySECRETtoken1234567890";
let secret = format!("Bearer {secret_body}");
let entry = McpServerEntry {
transport: McpTransport::Http {
url: "https://api.example.com/mcp".to_string(),
headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let debug_output = format!("{entry:?}");
assert!(debug_output.contains("Authorization"));
assert!(!debug_output.contains(&secret));
assert!(!debug_output.contains(secret_body));
}
#[test]
fn test_server_source_debug_redacts_via_transport() {
let secret_body = "sk-verySECRETtoken1234567890";
let secret = format!("Bearer {secret_body}");
let source = ServerSource::Flags {
transport: TransportArgs::Http {
url: "https://api.example.com/mcp".to_string(),
headers: vec![format!("Authorization={secret}")],
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let debug_output = format!("{source:?}");
assert!(!debug_output.contains(&secret));
assert!(!debug_output.contains(secret_body));
assert!(debug_output.contains("<redacted>"));
}
#[test]
fn test_transport_args_debug_redacts_headers_and_env() {
let secret_body = "sk-verySECRETtoken1234567890";
let header_entry = format!("Authorization=Bearer {secret_body}");
let http = TransportArgs::Http {
url: "https://api.example.com/mcp".to_string(),
headers: vec![header_entry.clone()],
};
let http_debug = format!("{http:?}");
assert!(!http_debug.contains(&header_entry));
assert!(!http_debug.contains(secret_body));
assert!(!http_debug.contains("Authorization"));
assert!(http_debug.contains("<redacted>"));
let sse = TransportArgs::Sse {
url: "https://api.example.com/sse".to_string(),
headers: vec![header_entry.clone()],
};
let sse_debug = format!("{sse:?}");
assert!(!sse_debug.contains(&header_entry));
assert!(!sse_debug.contains(secret_body));
let env_entry = format!("GITHUB_TOKEN={secret_body}");
let stdio = TransportArgs::Stdio {
command: "node".to_string(),
args: vec![],
env: vec![env_entry.clone()],
cwd: None,
};
let stdio_debug = format!("{stdio:?}");
assert!(!stdio_debug.contains(&env_entry));
assert!(!stdio_debug.contains(secret_body));
assert!(!stdio_debug.contains("GITHUB_TOKEN"));
assert!(stdio_debug.contains("<redacted>"));
}
#[test]
fn test_mcp_transport_debug_redacts_args() {
let secret = "sk-live-secret";
let transport = McpTransport::Stdio {
command: "node".to_string(),
args: vec!["--api-key".to_string(), secret.to_string()],
env: HashMap::new(),
cwd: None,
};
let debug_output = format!("{transport:?}");
assert!(!debug_output.contains(secret));
}
#[test]
fn test_mcp_transport_debug_redacts_url_userinfo_and_query() {
let secret = "hunter2";
let http = McpTransport::Http {
url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
headers: HashMap::new(),
};
let http_debug = format!("{http:?}");
assert!(!http_debug.contains(secret));
assert!(http_debug.contains("api.example.com/mcp"));
let sse = McpTransport::Sse {
url: format!("https://user:{secret}@api.example.com/sse?token={secret}"),
headers: HashMap::new(),
};
let sse_debug = format!("{sse:?}");
assert!(!sse_debug.contains(secret));
assert!(sse_debug.contains("api.example.com/sse"));
}
#[test]
fn test_transport_args_debug_redacts_args() {
let secret = "sk-live-secret";
let stdio = TransportArgs::Stdio {
command: "node".to_string(),
args: vec!["--api-key".to_string(), secret.to_string()],
env: vec![],
cwd: None,
};
let debug_output = format!("{stdio:?}");
assert!(!debug_output.contains(secret));
}
#[test]
fn test_transport_args_debug_redacts_url_userinfo_and_query() {
let secret = "hunter2";
let http = TransportArgs::Http {
url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
headers: vec![],
};
let http_debug = format!("{http:?}");
assert!(!http_debug.contains(secret));
assert!(http_debug.contains("api.example.com/mcp"));
}
#[test]
fn test_raw_mcp_server_entry_debug_redacts_secret_shaped_fields() {
let secret = "sk-live-secret";
let entry = RawMcpServerEntry {
transport_type: Some(TransportTag::Stdio),
command: Some("node".to_string()),
args: vec!["--api-key".to_string(), secret.to_string()],
env: HashMap::from([("GITHUB_TOKEN".to_string(), secret.to_string())]),
cwd: None,
url: None,
headers: HashMap::new(),
connect_timeout_secs: None,
discover_timeout_secs: None,
extra: HashMap::from([(
"someUnknownSecret".to_string(),
serde_json::Value::String(secret.to_string()),
)]),
};
let debug_output = format!("{entry:?}");
assert!(!debug_output.contains(secret));
assert!(debug_output.contains("GITHUB_TOKEN"));
assert!(debug_output.contains("someUnknownSecret"));
assert!(debug_output.contains("node"));
}
#[test]
fn test_build_server_config_header_name_value_typo_does_not_leak_secret() {
let secret = "c2VjcmV0dG9rZW4=";
let header = format!("Authorization: Bearer {secret}");
let result = build_server_config(
http_transport("https://example.com", vec![&header]),
None,
None,
);
let err = result.unwrap_err();
assert!(
!format!("{err:?}").contains(secret),
"error chain leaked the raw secret: {err:?}"
);
assert!(
!format!("{err:?}").contains(&header),
"error chain leaked the raw header argument: {err:?}"
);
}
#[test]
fn test_build_server_config_invalid_env_classifies_as_invalid_argument() {
let result = build_server_config(
stdio_transport("server", vec![], vec!["INVALID_FORMAT"], None),
None,
None,
);
let err = result.unwrap_err();
assert!(matches!(
err.downcast_ref::<CoreError>(),
Some(CoreError::InvalidArgument(_))
));
}
#[test]
fn test_build_server_config_multiple_env_vars() {
let (_, config) = build_server_config(
stdio_transport(
"server",
vec![],
vec!["TOKEN=abc123", "API_KEY=secret456", "DEBUG=true"],
None,
),
None,
None,
)
.unwrap();
assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
assert_eq!(config.env().len(), 3);
}
#[test]
fn test_build_server_config_env_with_special_chars() {
let (_, config) = build_server_config(
stdio_transport(
"server",
vec![],
vec![
"TOKEN=abc=def=123",
"URL=https://example.com?key=value",
"ENCODED=a=b=c=d",
],
None,
),
None,
None,
)
.unwrap();
assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
assert_eq!(
config.env().get("URL"),
Some(&"https://example.com?key=value".to_string())
);
assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
}
#[test]
fn test_build_server_config_empty_args_stdio() {
let (id, config) = build_server_config(
stdio_transport("simple-server", vec![], vec![], None),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "simple-server");
assert_eq!(config.command(), Some("simple-server"));
assert!(config.args().is_empty());
assert!(config.env().is_empty());
}
#[test]
fn test_build_server_config_http_multiple_headers() {
let (_, config) = build_server_config(
http_transport(
"https://api.example.com",
vec![
"Authorization=Bearer token123",
"X-API-Key=secret",
"Content-Type=application/json",
],
),
None,
None,
)
.unwrap();
assert_eq!(
config.headers().get("Authorization"),
Some(&"Bearer token123".to_string())
);
assert_eq!(
config.headers().get("X-API-Key"),
Some(&"secret".to_string())
);
assert_eq!(
config.headers().get("Content-Type"),
Some(&"application/json".to_string())
);
assert_eq!(config.headers().len(), 3);
}
#[test]
fn test_build_server_config_header_with_special_chars() {
let (_, config) = build_server_config(
http_transport(
"https://api.example.com",
vec!["X-Custom=value=with=equals", "X-Query=a=b&c=d"],
),
None,
None,
)
.unwrap();
assert_eq!(
config.headers().get("X-Custom"),
Some(&"value=with=equals".to_string())
);
assert_eq!(
config.headers().get("X-Query"),
Some(&"a=b&c=d".to_string())
);
}
#[test]
fn test_build_server_config_sse_with_headers() {
let (id, config) = build_server_config(
sse_transport(
"https://sse.example.com/events",
vec!["Authorization=Bearer xyz"],
),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "sse-example-com-events");
assert_eq!(config.url(), Some("https://sse.example.com/events"));
assert_eq!(
config.headers().get("Authorization"),
Some(&"Bearer xyz".to_string())
);
}
#[test]
fn test_build_server_config_empty_value_in_env() {
let (_, config) = build_server_config(
stdio_transport("server", vec![], vec!["EMPTY="], None),
None,
None,
)
.unwrap();
assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
}
#[test]
fn test_build_server_config_empty_value_in_header() {
let (_, config) = build_server_config(
http_transport("https://example.com", vec!["X-Empty="]),
None,
None,
)
.unwrap();
assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
}
#[test]
fn test_build_server_config_complex_docker_scenario() {
let (id, config) = build_server_config(
stdio_transport(
"docker",
vec!["run", "-i", "--rm", "--network=host", "my-image:latest"],
vec!["API_TOKEN=secret123", "LOG_LEVEL=debug"],
Some("/app/workdir"),
),
None,
None,
)
.unwrap();
assert_eq!(id.as_str(), "docker");
assert_eq!(config.command(), Some("docker"));
assert_eq!(
config.args(),
&["run", "-i", "--rm", "--network=host", "my-image:latest"]
);
assert_eq!(
config.env().get("API_TOKEN"),
Some(&"secret123".to_string())
);
assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
}
#[test]
fn test_build_server_config_empty_key_in_env() {
let secret = "topsecretvalue";
let env_arg = format!("={secret}");
let result = build_server_config(
stdio_transport("server", vec![], vec![&env_arg], None),
None,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(format!("{err:?}").contains("key cannot be empty"));
assert!(
!format!("{err:?}").contains(secret),
"error chain leaked the raw secret: {err:?}"
);
}
#[test]
fn test_build_server_config_empty_key_in_header() {
let secret = "topsecretheadervalue";
let header_arg = format!("={secret}");
let result = build_server_config(
http_transport("https://example.com", vec![&header_arg]),
None,
None,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(format!("{err:?}").contains("key cannot be empty"));
assert!(
!format!("{err:?}").contains(secret),
"error chain leaked the raw secret: {err:?}"
);
}
#[test]
fn test_build_server_config_timeout_override_reaches_core_validation() {
let result = build_server_config(
stdio_transport("docker", vec![], vec![], None),
Some(0),
None,
);
let err = result.unwrap_err();
let core_err = err.downcast::<mcp_execution_core::Error>().unwrap();
if let mcp_execution_core::Error::ValidationError { field, reason } = core_err {
assert_eq!(field, "connect_timeout");
assert!(reason.contains("greater than zero"));
} else {
panic!("expected ValidationError for connect_timeout");
}
}
#[test]
fn test_build_server_config_timeout_overrides() {
let (_, config) = build_server_config(
stdio_transport("server", vec![], vec![], None),
Some(5),
Some(90),
)
.unwrap();
assert_eq!(config.connect_timeout(), Duration::from_secs(5));
assert_eq!(config.discover_timeout(), Duration::from_secs(90));
}
#[test]
fn test_build_server_config_default_timeouts_without_overrides() {
let (_, config) =
build_server_config(stdio_transport("server", vec![], vec![], None), None, None)
.unwrap();
assert_eq!(config.connect_timeout(), Duration::from_secs(30));
assert_eq!(config.discover_timeout(), Duration::from_secs(30));
}
#[test]
fn test_load_server_from_config_not_found() {
let result = load_server_from_config("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_load_mcp_config_no_file() {
let result = load_mcp_config_from(Path::new("/nonexistent/mcp.json"));
if let Err(error) = result {
let error = error.to_string();
assert!(
error.contains("failed to read MCP config")
|| error.contains("failed to get home directory"),
"Expected config read error or home dir error, got: {error}"
);
}
}
#[test]
fn test_list_mcp_servers_from_missing_file_returns_empty() {
let result = list_mcp_servers_from(Path::new("/nonexistent/path/mcp.json"));
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_list_mcp_servers_from_valid_file() {
let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
let file = create_test_config(json);
let servers = list_mcp_servers_from(file.path()).unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].0, "github");
assert!(matches!(
servers[0].1.transport,
McpTransport::Stdio { ref command, .. } if command == "node"
));
}
#[test]
fn test_list_mcp_servers_from_empty_servers_key() {
let json = r#"{"mcpServers": {}}"#;
let file = create_test_config(json);
let servers = list_mcp_servers_from(file.path()).unwrap();
assert!(servers.is_empty());
}
#[test]
fn test_load_mcp_config_without_timeout_keys_uses_defaults() {
let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
let entry = &config.mcp_servers["github"];
assert_eq!(entry.connect_timeout_secs, None);
assert_eq!(entry.discover_timeout_secs, None);
let server_config = build_core_config(entry).unwrap();
assert_eq!(server_config.connect_timeout(), Duration::from_secs(30));
assert_eq!(server_config.discover_timeout(), Duration::from_secs(30));
}
#[test]
fn test_load_mcp_config_with_timeout_keys_reaches_server_config() {
let json = r#"{"mcpServers": {"github": {
"command": "node",
"connectTimeoutSecs": 5,
"discoverTimeoutSecs": 90
}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
let entry = &config.mcp_servers["github"];
assert_eq!(entry.connect_timeout_secs, Some(5));
assert_eq!(entry.discover_timeout_secs, Some(90));
let server_config = build_core_config(entry).unwrap();
assert_eq!(server_config.connect_timeout(), Duration::from_secs(5));
assert_eq!(server_config.discover_timeout(), Duration::from_secs(90));
}
#[test]
fn test_build_core_config_http_entry_reaches_server_config() {
let json = r#"{"mcpServers": {"remote": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
let entry = &config.mcp_servers["remote"];
let server_config = build_core_config(entry).unwrap();
assert_eq!(server_config.url(), Some("https://api.example.com/mcp"));
assert_eq!(
server_config.headers().get("Authorization"),
Some(&"Bearer x".to_string())
);
}
#[test]
fn test_build_core_config_stdio_cwd_reaches_server_config() {
let json = r#"{"mcpServers": {"local": {"command": "node", "cwd": "/tmp/workdir"}}}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
let entry = &config.mcp_servers["local"];
let server_config = build_core_config(entry).unwrap();
assert_eq!(server_config.cwd(), Some(&PathBuf::from("/tmp/workdir")));
}
#[test]
fn test_load_mcp_config_serde_default_on_missing_mcp_servers() {
let json = r#"{"someOtherKey": "value"}"#;
let file = create_test_config(json);
let config = load_mcp_config_from(file.path()).unwrap();
assert!(
config.mcp_servers.is_empty(),
"missing mcpServers key must produce empty map, not error"
);
}
#[test]
fn test_derive_server_id_from_url_basic() {
assert_eq!(
derive_server_id_from_url("https://api.githubcopilot.com/mcp/").as_str(),
"api-githubcopilot-com-mcp"
);
assert_eq!(
derive_server_id_from_url("https://example.com/sse").as_str(),
"example-com-sse"
);
}
#[test]
fn test_derive_server_id_from_url_strips_credentials() {
let id = derive_server_id_from_url("https://user:sekrit-token@api.example.com/mcp");
assert!(!id.as_str().contains("sekrit"));
assert!(!id.as_str().contains("user"));
assert_eq!(id.as_str(), "api-example-com-mcp");
}
#[test]
fn test_derive_server_id_from_url_rejects_path_traversal_chars() {
let id = derive_server_id_from_url("https://api.example.com/../../etc/passwd");
assert!(!id.as_str().contains(".."));
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_derive_server_id_from_url_join_never_escapes_base_dir() {
let base_dir = PathBuf::from("/home/user/.claude/servers");
let malicious_urls = [
"https://api.example.com/../../../../etc/passwd",
"https://api.example.com/..%2f..%2fescape",
"https://api.example.com/./././escape",
];
for url in malicious_urls {
let id = derive_server_id_from_url(url);
let joined = base_dir.join(id.as_str());
assert!(
joined.starts_with(&base_dir),
"joining derived id {:?} (from {url:?}) onto {base_dir:?} escaped it: {joined:?}",
id.as_str()
);
}
}
#[test]
fn test_derive_server_id_from_url_normalizes_case() {
assert_eq!(
derive_server_id_from_url("https://API.Example.COM/MCP").as_str(),
"api-example-com-mcp"
);
}
#[test]
fn test_derive_server_id_from_url_truncates_to_length_limit() {
let long_path = "a".repeat(200);
let id = derive_server_id_from_url(&format!("https://example.com/{long_path}"));
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_derive_server_id_from_url_falls_back_when_empty() {
let id = derive_server_id_from_url("https://...");
assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_derive_server_id_from_url_falls_back_on_unparseable_url() {
for unparseable in ["not a url at all", "", "://", "!!!"] {
let id = derive_server_id_from_url(unparseable);
assert_eq!(
id.as_str(),
FALLBACK_SERVER_ID_SLUG,
"input {unparseable:?} should fall back to the default slug"
);
}
}
#[test]
fn test_derive_server_id_from_url_unparseable_credential_bearing_url_leaks_nothing() {
let id = derive_server_id_from_url("https://user:pass@evil.com:99999/x");
assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
assert!(!id.as_str().contains("user"));
assert!(!id.as_str().contains("pass"));
assert!(!id.as_str().contains("evil"));
}
#[test]
fn test_derive_server_id_from_url_always_passes_validate_server_id() {
let urls = [
"https://api.githubcopilot.com/mcp/",
"https://example.com/sse",
"https://user:token@host.example.com/mcp?query=1#frag",
"https://HOST.EXAMPLE.COM/Path/With/Mixed_Case",
"https://127.0.0.1:8443/mcp",
"https://example.com/../../escape",
"https://",
"not-a-url",
];
for url in urls {
let id = derive_server_id_from_url(url);
assert!(
mcp_execution_skill::validate_server_id(id.as_str()).is_ok(),
"derived id {:?} from url {url:?} must satisfy validate_server_id",
id.as_str()
);
}
}
#[test]
fn test_build_server_config_http_id_passes_validate_server_id() {
let (id, _config) = build_server_config(
http_transport("https://user:token@api.example.com/mcp/../secret", vec![]),
None,
None,
)
.unwrap();
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
assert!(!id.as_str().contains("token"));
}
#[test]
fn test_derive_server_id_from_path_or_name_rejects_parent_traversal() {
let id = derive_server_id_from_path_or_name("../../../../etc/passwd");
assert!(!id.as_str().contains(".."));
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_derive_server_id_from_path_or_name_rejects_absolute_path() {
let id = derive_server_id_from_path_or_name("/etc/cron.d/evil");
assert!(!id.as_str().starts_with('/'));
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_derive_server_id_from_path_or_name_join_never_escapes_base_dir() {
let base_dir = PathBuf::from("/home/user/.claude/servers");
let malicious_inputs = [
"../../../../etc/passwd",
"/etc/cron.d/evil",
"/../../escape",
"..",
"./../escape",
];
for input in malicious_inputs {
let id = derive_server_id_from_path_or_name(input);
let joined = base_dir.join(id.as_str());
assert!(
joined.starts_with(&base_dir),
"joining derived id {:?} (from {input:?}) onto {base_dir:?} escaped it: {joined:?}",
id.as_str()
);
}
}
#[test]
fn test_derive_server_id_from_path_or_name_preserves_ordinary_commands() {
assert_eq!(
derive_server_id_from_path_or_name("github-mcp-server").as_str(),
"github-mcp-server"
);
assert_eq!(
derive_server_id_from_path_or_name("docker").as_str(),
"docker"
);
}
#[test]
fn test_derive_server_id_from_path_or_name_strips_path_components() {
let id = derive_server_id_from_path_or_name("/usr/local/bin/mcp-server");
assert_eq!(id.as_str(), "usr-local-bin-mcp-server");
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
}
#[test]
fn test_build_server_config_stdio_traversal_command_never_escapes_base_dir() {
let base_dir = PathBuf::from("/home/user/.claude/servers");
for command in ["../../../../etc/passwd", "/bin/sh"] {
let (id, _config) =
build_server_config(stdio_transport(command, vec![], vec![], None), None, None)
.unwrap();
assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
let joined = base_dir.join(id.as_str());
assert!(
joined.starts_with(&base_dir),
"joining derived id {:?} (from command {command:?}) escaped {base_dir:?}: {joined:?}",
id.as_str()
);
}
}
#[cfg(unix)]
static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
#[test]
fn test_list_and_get_mcp_server_success_via_default_path() {
let _guard = HOME_ENV_LOCK.lock().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("mcp.json"),
r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", temp.path());
}
let list_result = list_mcp_servers();
let get_result = get_mcp_server("github");
unsafe {
match &original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
let servers = list_result.expect("list_mcp_servers must resolve the default path");
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].0, "github");
let (id, _config, entry) =
get_result.expect("get_mcp_server must find the configured server");
assert_eq!(id.as_str(), "github");
assert!(matches!(
entry.transport,
McpTransport::Stdio { ref command, .. } if command == "node"
));
}
#[cfg(unix)]
#[test]
fn test_get_mcp_server_accepts_non_slug_shaped_config_key() {
let _guard = HOME_ENV_LOCK.lock().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("mcp.json"),
r#"{"mcpServers": {"claude_ai_Gmail": {"command": "node", "args": ["server.js"]}}}"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", temp.path());
}
let get_result = get_mcp_server("claude_ai_Gmail");
unsafe {
match &original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
let (id, _config, _entry) =
get_result.expect("get_mcp_server must not reject a non-slug-shaped mcp.json key");
assert_eq!(id.as_str(), "claude_ai_Gmail");
}
#[cfg(unix)]
#[test]
fn test_get_mcp_server_entry_finds_entry_that_fails_config_validation() {
let _guard = HOME_ENV_LOCK.lock().unwrap();
let temp = tempfile::TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("mcp.json"),
r#"{"mcpServers": {"badscheme": {"type": "http", "url": "not-a-url"}}}"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", temp.path());
}
let entry_result = get_mcp_server_entry("badscheme");
unsafe {
match &original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
let (id, entry) = entry_result.expect(
"get_mcp_server_entry must find the entry even though its url fails validation",
);
assert_eq!(id.as_str(), "badscheme");
let config_err = build_core_config(&entry).expect_err(
"the entry's url is expected to fail build_core_config's scheme validation",
);
let message = config_err.to_string();
assert!(
!message.to_lowercase().contains("not found"),
"build_core_config's error must not read like a not-found message, got: {message}"
);
}
}