1use anyhow::{Context, Result};
7use mcp_execution_core::{
8 Error as CoreError, REDACTED_PLACEHOLDER, RedactedItems, RedactedMapValues, RedactedUrl,
9 ServerConfig, ServerConfigBuilder, ServerId, sanitize_path_for_error,
10};
11use mcp_execution_skill::MAX_SERVER_ID_LENGTH;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17use tracing::{debug, warn};
18use url::Url;
19
20const FALLBACK_SERVER_ID_SLUG: &str = "http-server";
22
23#[derive(Debug, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct McpConfig {
43 #[serde(default)]
45 pub mcp_servers: HashMap<String, McpServerEntry>,
46}
47
48#[derive(Clone)]
84pub enum McpTransport {
85 Stdio {
87 command: String,
89 args: Vec<String>,
91 env: HashMap<String, String>,
93 cwd: Option<PathBuf>,
95 },
96 Http {
98 url: String,
100 headers: HashMap<String, String>,
102 },
103 Sse {
105 url: String,
107 headers: HashMap<String, String>,
109 },
110}
111
112impl fmt::Debug for McpTransport {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::Stdio {
116 command,
117 args,
118 env,
119 cwd,
120 } => f
121 .debug_struct("Stdio")
122 .field("command", &sanitize_path_for_error(Path::new(command)))
123 .field("args", &RedactedItems(args))
124 .field("env", &RedactedMapValues(env))
125 .field("cwd", &cwd.as_deref().map(sanitize_path_for_error))
126 .finish(),
127 Self::Http { url, headers } => f
128 .debug_struct("Http")
129 .field("url", &RedactedUrl(url))
130 .field("headers", &RedactedMapValues(headers))
131 .finish(),
132 Self::Sse { url, headers } => f
133 .debug_struct("Sse")
134 .field("url", &RedactedUrl(url))
135 .field("headers", &RedactedMapValues(headers))
136 .finish(),
137 }
138 }
139}
140
141#[derive(Debug, Clone)]
161pub struct McpServerEntry {
162 pub transport: McpTransport,
164 pub connect_timeout_secs: Option<u64>,
167 pub discover_timeout_secs: Option<u64>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
174#[serde(rename_all = "lowercase")]
175enum TransportTag {
176 Stdio,
177 Http,
178 Sse,
179}
180
181impl TransportTag {
182 const fn as_str(self) -> &'static str {
183 match self {
184 Self::Stdio => "stdio",
185 Self::Http => "http",
186 Self::Sse => "sse",
187 }
188 }
189}
190
191#[derive(Deserialize)]
209#[serde(rename_all = "camelCase", rename = "McpServerEntry")]
210struct RawMcpServerEntry {
211 #[serde(rename = "type")]
212 transport_type: Option<TransportTag>,
213 command: Option<String>,
214 #[serde(default)]
215 args: Vec<String>,
216 #[serde(default)]
217 env: HashMap<String, String>,
218 cwd: Option<String>,
219 url: Option<String>,
220 #[serde(default)]
221 headers: HashMap<String, String>,
222 connect_timeout_secs: Option<u64>,
223 discover_timeout_secs: Option<u64>,
224 #[serde(flatten)]
225 extra: HashMap<String, serde_json::Value>,
226}
227
228struct RedactedExtra<'a>(&'a HashMap<String, serde_json::Value>);
235
236impl fmt::Debug for RedactedExtra<'_> {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.debug_map()
239 .entries(self.0.keys().map(|key| (key, REDACTED_PLACEHOLDER)))
240 .finish()
241 }
242}
243
244impl fmt::Debug for RawMcpServerEntry {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.debug_struct("RawMcpServerEntry")
247 .field("transport_type", &self.transport_type)
248 .field(
249 "command",
250 &self
251 .command
252 .as_deref()
253 .map(|command| sanitize_path_for_error(Path::new(command))),
254 )
255 .field("args", &RedactedItems(&self.args))
256 .field("env", &RedactedMapValues(&self.env))
257 .field(
258 "cwd",
259 &self
260 .cwd
261 .as_deref()
262 .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
263 )
264 .field("url", &self.url.as_deref().map(RedactedUrl))
265 .field("headers", &RedactedMapValues(&self.headers))
266 .field("connect_timeout_secs", &self.connect_timeout_secs)
267 .field("discover_timeout_secs", &self.discover_timeout_secs)
268 .field("extra", &RedactedExtra(&self.extra))
269 .finish()
270 }
271}
272
273fn http_like_transport(
276 tag_name: &str,
277 command: Option<&str>,
278 url: Option<String>,
279 headers: HashMap<String, String>,
280) -> Result<(String, HashMap<String, String>), String> {
281 if command.is_some() {
282 return Err(format!("{tag_name} server entry must not set \"command\""));
283 }
284 let url = url.ok_or_else(|| format!("{tag_name} server entry requires \"url\""))?;
285 Ok((url, headers))
286}
287
288impl TryFrom<RawMcpServerEntry> for McpServerEntry {
289 type Error = String;
290
291 fn try_from(raw: RawMcpServerEntry) -> Result<Self, Self::Error> {
292 if !raw.extra.is_empty() {
293 let mut keys: Vec<&str> = raw.extra.keys().map(String::as_str).collect();
294 keys.sort_unstable();
295 warn!(
296 "mcp.json server entry has unrecognized field(s), ignoring: {}",
297 keys.join(", ")
298 );
299 }
300
301 let tag = match raw.transport_type {
302 Some(tag) => tag,
303 None if raw.command.is_some() => TransportTag::Stdio,
304 None if raw.url.is_some() => TransportTag::Http,
305 None => {
306 return Err(
307 "server entry must set either \"command\" (stdio) or \"type\" and \"url\" \
308 (http/sse)"
309 .to_string(),
310 );
311 }
312 };
313
314 let transport = match tag {
315 TransportTag::Stdio => {
316 if raw.url.is_some() {
317 return Err("stdio server entry must not set \"url\"".to_string());
318 }
319 let command = raw
320 .command
321 .ok_or_else(|| "stdio server entry requires \"command\"".to_string())?;
322 McpTransport::Stdio {
323 command,
324 args: raw.args,
325 env: raw.env,
326 cwd: raw.cwd.map(PathBuf::from),
327 }
328 }
329 TransportTag::Http => {
330 let (url, headers) = http_like_transport(
331 tag.as_str(),
332 raw.command.as_deref(),
333 raw.url,
334 raw.headers,
335 )?;
336 McpTransport::Http { url, headers }
337 }
338 TransportTag::Sse => {
339 let (url, headers) = http_like_transport(
340 tag.as_str(),
341 raw.command.as_deref(),
342 raw.url,
343 raw.headers,
344 )?;
345 McpTransport::Sse { url, headers }
346 }
347 };
348
349 Ok(Self {
350 transport,
351 connect_timeout_secs: raw.connect_timeout_secs,
352 discover_timeout_secs: raw.discover_timeout_secs,
353 })
354 }
355}
356
357impl<'de> Deserialize<'de> for McpServerEntry {
358 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
359 where
360 D: serde::Deserializer<'de>,
361 {
362 let raw = RawMcpServerEntry::deserialize(deserializer)?;
363 raw.try_into().map_err(serde::de::Error::custom)
364 }
365}
366
367fn load_mcp_config_from(path: &Path) -> Result<McpConfig> {
376 let content = std::fs::read_to_string(path)
377 .with_context(|| format!("failed to read MCP config from {}", path.display()))?;
378
379 serde_json::from_str(&content).context("failed to parse MCP config JSON")
380}
381
382fn load_mcp_config() -> Result<McpConfig> {
391 let home = dirs::home_dir().context("failed to get home directory")?;
392 load_mcp_config_from(&home.join(".claude").join("mcp.json"))
393}
394
395fn list_mcp_servers_from(path: &Path) -> Result<Vec<(String, McpServerEntry)>> {
404 if !path.exists() {
405 return Ok(Vec::new());
406 }
407 let config = load_mcp_config_from(path)?;
408 Ok(config.mcp_servers.into_iter().collect())
409}
410
411pub(crate) fn list_mcp_servers() -> Result<Vec<(String, McpServerEntry)>> {
427 let home = dirs::home_dir().context("failed to get home directory")?;
428 list_mcp_servers_from(&home.join(".claude").join("mcp.json"))
429}
430
431pub(crate) fn get_mcp_server(name: &str) -> Result<(ServerId, ServerConfig, McpServerEntry)> {
465 let (server_id, entry) = get_mcp_server_entry(name)?;
466 let server_config = build_core_config(&entry)?;
467 Ok((server_id, server_config, entry))
468}
469
470pub(crate) fn get_mcp_server_entry(name: &str) -> Result<(ServerId, McpServerEntry)> {
485 let config = load_mcp_config()?;
486
487 let entry = config
488 .mcp_servers
489 .get(name)
490 .with_context(|| {
491 format!(
492 "server '{name}' not found in ~/.claude/mcp.json\n\
493 Hint: ensure the server is defined in ~/.claude/mcp.json under \"mcpServers\""
494 )
495 })?
496 .clone();
497
498 let server_id = ServerId::new(name).with_context(|| {
499 format!("server '{name}' in ~/.claude/mcp.json is not a valid server id")
500 })?;
501 Ok((server_id, entry))
502}
503
504pub(crate) fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
518 let (id, config, _) = get_mcp_server(name)?;
519 Ok((id, config))
520}
521
522fn builder_for_transport(transport: McpTransport) -> ServerConfigBuilder {
528 match transport {
529 McpTransport::Stdio {
530 command,
531 args,
532 env,
533 cwd,
534 } => {
535 let mut builder = ServerConfig::builder().command(command);
536 if !args.is_empty() {
537 builder = builder.args(args);
538 }
539 for (key, value) in env {
540 builder = builder.env(key, value);
541 }
542 if let Some(dir) = cwd {
543 builder = builder.cwd(dir);
544 }
545 builder
546 }
547 McpTransport::Http { url, headers } => {
548 let mut builder = ServerConfig::builder().http_transport(url);
549 for (key, value) in headers {
550 builder = builder.header(key, value);
551 }
552 builder
553 }
554 McpTransport::Sse { url, headers } => {
555 let mut builder = ServerConfig::builder().sse_transport(url);
556 for (key, value) in headers {
557 builder = builder.header(key, value);
558 }
559 builder
560 }
561 }
562}
563
564pub(crate) fn build_core_config(entry: &McpServerEntry) -> Result<ServerConfig> {
577 let mut builder = builder_for_transport(entry.transport.clone());
578
579 if let Some(secs) = entry.connect_timeout_secs {
580 builder = builder.connect_timeout(Duration::from_secs(secs));
581 }
582
583 if let Some(secs) = entry.discover_timeout_secs {
584 builder = builder.discover_timeout(Duration::from_secs(secs));
585 }
586
587 Ok(builder.build()?)
588}
589
590fn parse_key_value(s: &str, kind: &str) -> Result<(String, String)> {
599 let Some((key, value)) = s.split_once('=') else {
603 return Err(CoreError::InvalidArgument(format!(
604 "invalid {kind} format: no '=' separator found (expected KEY=VALUE)"
605 ))
606 .into());
607 };
608 if key.is_empty() {
609 return Err(CoreError::InvalidArgument(format!(
610 "invalid {kind} format: key cannot be empty (expected KEY=VALUE)"
611 ))
612 .into());
613 }
614 if key
621 .chars()
622 .any(|c| c.is_whitespace() || c == ':' || c.is_control())
623 {
624 return Err(CoreError::InvalidArgument(format!(
625 "invalid {kind} format: text before '=' contains characters that are never valid \
626 in a key, suggesting '=' matched inside a value rather than as the separator; \
627 refusing to echo it since it may contain a secret (expected KEY=VALUE)"
628 ))
629 .into());
630 }
631 Ok((key.to_string(), value.to_string()))
632}
633
634#[derive(Clone)]
681pub enum TransportArgs {
682 Stdio {
684 command: String,
686 args: Vec<String>,
688 env: Vec<String>,
690 cwd: Option<String>,
692 },
693 Http {
695 url: String,
697 headers: Vec<String>,
699 },
700 Sse {
702 url: String,
704 headers: Vec<String>,
706 },
707}
708
709impl fmt::Debug for TransportArgs {
710 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711 match self {
712 Self::Stdio {
713 command,
714 args,
715 env,
716 cwd,
717 } => f
718 .debug_struct("Stdio")
719 .field("command", &sanitize_path_for_error(Path::new(command)))
720 .field("args", &RedactedItems(args))
721 .field("env", &RedactedItems(env))
722 .field(
723 "cwd",
724 &cwd.as_deref()
725 .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
726 )
727 .finish(),
728 Self::Http { url, headers } => f
729 .debug_struct("Http")
730 .field("url", &RedactedUrl(url))
731 .field("headers", &RedactedItems(headers))
732 .finish(),
733 Self::Sse { url, headers } => f
734 .debug_struct("Sse")
735 .field("url", &RedactedUrl(url))
736 .field("headers", &RedactedItems(headers))
737 .finish(),
738 }
739 }
740}
741
742impl TryFrom<TransportArgs> for McpTransport {
743 type Error = anyhow::Error;
744
745 fn try_from(args: TransportArgs) -> Result<Self> {
746 match args {
747 TransportArgs::Stdio {
748 command,
749 args,
750 env,
751 cwd,
752 } => {
753 let env = env
754 .iter()
755 .map(|s| parse_key_value(s, "environment variable"))
756 .collect::<Result<HashMap<_, _>>>()?;
757 Ok(Self::Stdio {
758 command,
759 args,
760 env,
761 cwd: cwd.map(PathBuf::from),
762 })
763 }
764 TransportArgs::Http { url, headers } => {
765 let headers = headers
766 .iter()
767 .map(|s| parse_key_value(s, "header"))
768 .collect::<Result<HashMap<_, _>>>()?;
769 Ok(Self::Http { url, headers })
770 }
771 TransportArgs::Sse { url, headers } => {
772 let headers = headers
773 .iter()
774 .map(|s| parse_key_value(s, "header"))
775 .collect::<Result<HashMap<_, _>>>()?;
776 Ok(Self::Sse { url, headers })
777 }
778 }
779 }
780}
781
782pub(crate) fn build_server_config(
803 transport: TransportArgs,
804 connect_timeout_secs: Option<u64>,
805 discover_timeout_secs: Option<u64>,
806) -> Result<(ServerId, ServerConfig)> {
807 let server_id = match &transport {
808 TransportArgs::Stdio { command, .. } => derive_server_id_from_path_or_name(command),
809 TransportArgs::Http { url, .. } | TransportArgs::Sse { url, .. } => {
810 derive_server_id_from_url(url)
811 }
812 };
813
814 let mut builder = builder_for_transport(McpTransport::try_from(transport)?);
815
816 if let Some(secs) = connect_timeout_secs {
817 builder = builder.connect_timeout(Duration::from_secs(secs));
818 }
819
820 if let Some(secs) = discover_timeout_secs {
821 builder = builder.discover_timeout(Duration::from_secs(secs));
822 }
823
824 Ok((server_id, builder.build()?))
825}
826
827#[derive(Debug, Clone)]
877pub enum ServerSource {
878 Config {
880 name: String,
882 },
883 Flags {
885 transport: TransportArgs,
887 connect_timeout_secs: Option<u64>,
889 discover_timeout_secs: Option<u64>,
891 },
892}
893
894pub(crate) fn resolve_server_config(source: ServerSource) -> Result<(ServerId, ServerConfig)> {
909 match source {
910 ServerSource::Config { name } => {
911 debug!("Loading server configuration from ~/.claude/mcp.json: {name}");
912 load_server_from_config(&name)
913 }
914 ServerSource::Flags {
915 transport,
916 connect_timeout_secs,
917 discover_timeout_secs,
918 } => build_server_config(transport, connect_timeout_secs, discover_timeout_secs),
919 }
920}
921
922fn slugify(input: &str) -> ServerId {
935 let mut slug = String::with_capacity(input.len());
936 for ch in input.chars() {
937 let lower = ch.to_ascii_lowercase();
938 if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
939 slug.push(lower);
940 } else if slug.chars().next_back().is_some_and(|last| last != '-') {
941 slug.push('-');
942 }
943 }
944
945 let slug = slug.trim_matches('-');
946 let slug = &slug[..slug.len().min(MAX_SERVER_ID_LENGTH)];
947 let slug = slug.trim_end_matches('-');
948
949 ServerId::new(if slug.is_empty() {
953 FALLBACK_SERVER_ID_SLUG
954 } else {
955 slug
956 })
957 .expect("slugify only ever produces a valid path-segment ServerId")
958}
959
960fn derive_server_id_from_url(url: &str) -> ServerId {
974 let host_and_path = Url::parse(url)
982 .ok()
983 .map(|parsed| format!("{}{}", parsed.host_str().unwrap_or_default(), parsed.path()))
984 .unwrap_or_default();
985
986 slugify(&host_and_path)
987}
988
989pub(crate) fn derive_server_id_from_path_or_name(raw: &str) -> ServerId {
1003 slugify(raw)
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use std::io::Write;
1010
1011 fn create_test_config(content: &str) -> tempfile::NamedTempFile {
1013 let mut file = tempfile::NamedTempFile::new().unwrap();
1014 file.write_all(content.as_bytes()).unwrap();
1015 file.flush().unwrap();
1016 file
1017 }
1018
1019 fn stdio_transport(
1020 command: &str,
1021 args: Vec<&str>,
1022 env: Vec<&str>,
1023 cwd: Option<&str>,
1024 ) -> TransportArgs {
1025 TransportArgs::Stdio {
1026 command: command.to_string(),
1027 args: args.into_iter().map(String::from).collect(),
1028 env: env.into_iter().map(String::from).collect(),
1029 cwd: cwd.map(String::from),
1030 }
1031 }
1032
1033 fn http_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1034 TransportArgs::Http {
1035 url: url.to_string(),
1036 headers: headers.into_iter().map(String::from).collect(),
1037 }
1038 }
1039
1040 fn sse_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1041 TransportArgs::Sse {
1042 url: url.to_string(),
1043 headers: headers.into_iter().map(String::from).collect(),
1044 }
1045 }
1046
1047 #[test]
1048 fn test_load_mcp_config_from_valid() {
1049 let json = r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#;
1050 let file = create_test_config(json);
1051
1052 let config = load_mcp_config_from(file.path()).unwrap();
1053 assert_eq!(config.mcp_servers.len(), 1);
1054 assert!(config.mcp_servers.contains_key("github"));
1055 }
1056
1057 #[test]
1058 fn test_load_mcp_config_from_empty_servers() {
1059 let json = r"{}";
1061 let file = create_test_config(json);
1062
1063 let config = load_mcp_config_from(file.path()).unwrap();
1064 assert!(config.mcp_servers.is_empty());
1065 }
1066
1067 #[test]
1068 fn test_load_mcp_config_from_minimal_server() {
1069 let json = r#"{"mcpServers": {"minimal": {"command": "python"}}}"#;
1071 let file = create_test_config(json);
1072
1073 let config = load_mcp_config_from(file.path()).unwrap();
1074 let entry = &config.mcp_servers["minimal"];
1075 match &entry.transport {
1076 McpTransport::Stdio {
1077 command, args, env, ..
1078 } => {
1079 assert_eq!(command, "python");
1080 assert!(args.is_empty());
1081 assert!(env.is_empty());
1082 }
1083 other => panic!("expected Stdio transport, got {other:?}"),
1084 }
1085 }
1086
1087 #[test]
1088 fn test_load_mcp_config_from_multiple_servers() {
1089 let json = r#"{
1090 "mcpServers": {
1091 "server1": {"command": "node", "args": ["s1.js"]},
1092 "server2": {"command": "python", "args": ["s2.py"]}
1093 }
1094 }"#;
1095 let file = create_test_config(json);
1096
1097 let config = load_mcp_config_from(file.path()).unwrap();
1098 assert_eq!(config.mcp_servers.len(), 2);
1099 assert!(config.mcp_servers.contains_key("server1"));
1100 assert!(config.mcp_servers.contains_key("server2"));
1101 }
1102
1103 #[test]
1104 fn test_load_mcp_config_from_not_found() {
1105 let result = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"));
1106 assert!(result.is_err());
1107 assert!(result.unwrap_err().to_string().contains("failed to read"));
1108 }
1109
1110 #[test]
1111 fn test_load_mcp_config_from_malformed_json() {
1112 let file = create_test_config("not valid json");
1113 let result = load_mcp_config_from(file.path());
1114 assert!(result.is_err());
1115 assert!(result.unwrap_err().to_string().contains("parse MCP config"));
1116 }
1117
1118 #[test]
1121 fn test_load_mcp_config_mixed_stdio_http_sse() {
1122 let json = r#"{
1123 "mcpServers": {
1124 "local": {"command": "node", "args": ["server.js"]},
1125 "remote-http": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}},
1126 "remote-sse": {"type": "sse", "url": "https://example.com/sse"}
1127 }
1128 }"#;
1129 let file = create_test_config(json);
1130
1131 let config = load_mcp_config_from(file.path()).unwrap();
1132 assert_eq!(config.mcp_servers.len(), 3);
1133
1134 assert!(matches!(
1135 config.mcp_servers["local"].transport,
1136 McpTransport::Stdio { .. }
1137 ));
1138 assert!(matches!(
1139 config.mcp_servers["remote-http"].transport,
1140 McpTransport::Http { .. }
1141 ));
1142 assert!(matches!(
1143 config.mcp_servers["remote-sse"].transport,
1144 McpTransport::Sse { .. }
1145 ));
1146 }
1147
1148 #[test]
1149 fn test_load_mcp_config_http_entry_type_absent_but_url_present() {
1150 let json = r#"{"mcpServers": {"remote": {"url": "https://api.example.com/mcp"}}}"#;
1152 let file = create_test_config(json);
1153
1154 let config = load_mcp_config_from(file.path()).unwrap();
1155 assert!(matches!(
1156 config.mcp_servers["remote"].transport,
1157 McpTransport::Http { .. }
1158 ));
1159 }
1160
1161 #[test]
1162 fn test_load_mcp_config_http_entry_missing_url_errors_naming_url() {
1163 let json = r#"{"mcpServers": {"remote": {"type": "http"}}}"#;
1164 let file = create_test_config(json);
1165
1166 let result = load_mcp_config_from(file.path());
1167 assert!(result.is_err());
1168 assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1171 }
1172
1173 #[test]
1174 fn test_load_mcp_config_entry_with_neither_command_nor_type_errors() {
1175 let json = r#"{"mcpServers": {"broken": {}}}"#;
1176 let file = create_test_config(json);
1177
1178 let result = load_mcp_config_from(file.path());
1179 assert!(result.is_err());
1180 let msg = format!("{:#}", result.unwrap_err());
1181 assert!(msg.contains("command"));
1182 assert!(msg.contains("url"));
1183 }
1184
1185 #[test]
1186 fn test_load_mcp_config_http_entry_with_command_errors() {
1187 let json = r#"{"mcpServers": {"bad": {"type": "http", "url": "https://x.com", "command": "node"}}}"#;
1188 let file = create_test_config(json);
1189
1190 let result = load_mcp_config_from(file.path());
1191 assert!(result.is_err());
1192 assert!(format!("{:#}", result.unwrap_err()).contains("command"));
1193 }
1194
1195 #[test]
1196 fn test_load_mcp_config_stdio_entry_with_url_errors() {
1197 let json = r#"{"mcpServers": {"bad": {"command": "node", "url": "https://x.com"}}}"#;
1198 let file = create_test_config(json);
1199
1200 let result = load_mcp_config_from(file.path());
1201 assert!(result.is_err());
1202 assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1203 }
1204
1205 #[test]
1206 fn test_load_mcp_config_unknown_field_still_parses() {
1207 let json = r#"{"mcpServers": {"github": {"command": "node", "disabled": false, "description": "x"}}}"#;
1210 let file = create_test_config(json);
1211
1212 let config = load_mcp_config_from(file.path()).unwrap();
1213 assert!(matches!(
1214 config.mcp_servers["github"].transport,
1215 McpTransport::Stdio { .. }
1216 ));
1217 }
1218
1219 #[test]
1220 fn test_build_server_config_stdio() {
1221 let (id, config) = build_server_config(
1222 stdio_transport(
1223 "github-mcp-server",
1224 vec!["stdio"],
1225 vec!["TOKEN=abc123"],
1226 None,
1227 ),
1228 None,
1229 None,
1230 )
1231 .unwrap();
1232
1233 assert_eq!(id.as_str(), "github-mcp-server");
1234 assert_eq!(config.command(), Some("github-mcp-server"));
1235 assert_eq!(config.args(), &["stdio"]);
1236 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1237 }
1238
1239 #[test]
1240 fn test_build_server_config_docker() {
1241 let (id, config) = build_server_config(
1242 stdio_transport(
1243 "docker",
1244 vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
1245 vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"],
1246 None,
1247 ),
1248 None,
1249 None,
1250 )
1251 .unwrap();
1252
1253 assert_eq!(id.as_str(), "docker");
1254 assert_eq!(config.command(), Some("docker"));
1255 assert_eq!(
1256 config.args(),
1257 &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
1258 );
1259 assert_eq!(
1260 config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
1261 Some(&"ghp_xxx".to_string())
1262 );
1263 }
1264
1265 #[test]
1266 fn test_build_server_config_http() {
1267 let (id, config) = build_server_config(
1268 http_transport(
1269 "https://api.githubcopilot.com/mcp/",
1270 vec!["Authorization=Bearer token123"],
1271 ),
1272 None,
1273 None,
1274 )
1275 .unwrap();
1276
1277 assert_eq!(id.as_str(), "api-githubcopilot-com-mcp");
1278 assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
1279 assert_eq!(
1280 config.headers().get("Authorization"),
1281 Some(&"Bearer token123".to_string())
1282 );
1283 }
1284
1285 #[test]
1286 fn test_build_server_config_sse() {
1287 let (id, config) = build_server_config(
1288 sse_transport("https://example.com/sse", vec!["X-API-Key=secret"]),
1289 None,
1290 None,
1291 )
1292 .unwrap();
1293
1294 assert_eq!(id.as_str(), "example-com-sse");
1295 assert_eq!(config.url(), Some("https://example.com/sse"));
1296 assert_eq!(
1297 config.headers().get("X-API-Key"),
1298 Some(&"secret".to_string())
1299 );
1300 }
1301
1302 #[test]
1303 fn test_build_server_config_with_cwd() {
1304 let (_, config) = build_server_config(
1305 stdio_transport("server", vec![], vec![], Some("/tmp/workdir")),
1306 None,
1307 None,
1308 )
1309 .unwrap();
1310
1311 assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
1312 }
1313
1314 #[test]
1315 fn test_build_server_config_invalid_env() {
1316 let secret = "ghp_verySECRETtoken1234567890abcdef";
1321 let result = build_server_config(
1322 stdio_transport("server", vec![], vec![secret], None),
1323 None,
1324 None,
1325 );
1326
1327 assert!(result.is_err());
1328 let err = result.unwrap_err();
1329 assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1330 assert!(
1331 !format!("{err:?}").contains(secret),
1332 "error chain leaked the raw secret: {err:?}"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_build_server_config_invalid_header() {
1338 let secret = "Bearer sk-live-supersecretvalue1234567890";
1341 let result = build_server_config(
1342 http_transport("https://example.com", vec![secret]),
1343 None,
1344 None,
1345 );
1346
1347 assert!(result.is_err());
1348 let err = result.unwrap_err();
1349 assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1350 assert!(
1351 !format!("{err:?}").contains(secret),
1352 "error chain leaked the raw secret: {err:?}"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_mcp_transport_debug_redacts_headers_http() {
1358 let secret_body = "sk-verySECRETtoken1234567890";
1364 let secret = format!("Bearer {secret_body}");
1365 let transport = McpTransport::Http {
1366 url: "https://api.example.com/mcp".to_string(),
1367 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1368 };
1369
1370 let debug_output = format!("{transport:?}");
1371 assert!(debug_output.contains("Authorization"));
1372 assert!(debug_output.contains("<redacted>"));
1373 assert!(!debug_output.contains(&secret));
1374 assert!(!debug_output.contains(secret_body));
1375 }
1376
1377 #[test]
1378 fn test_mcp_transport_debug_redacts_headers_sse() {
1379 let secret_body = "sk-verySECRETtoken1234567890";
1382 let secret = format!("Bearer {secret_body}");
1383 let transport = McpTransport::Sse {
1384 url: "https://api.example.com/sse".to_string(),
1385 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1386 };
1387
1388 let debug_output = format!("{transport:?}");
1389 assert!(debug_output.contains("Authorization"));
1390 assert!(debug_output.contains("<redacted>"));
1391 assert!(!debug_output.contains(&secret));
1392 assert!(!debug_output.contains(secret_body));
1393 }
1394
1395 #[test]
1396 fn test_mcp_transport_debug_redacts_env() {
1397 let secret_body = "ghp_verySECRETtoken1234567890abcdef";
1398 let transport = McpTransport::Stdio {
1399 command: "node".to_string(),
1400 args: vec![],
1401 env: HashMap::from([("GITHUB_TOKEN".to_string(), secret_body.to_string())]),
1402 cwd: None,
1403 };
1404
1405 let debug_output = format!("{transport:?}");
1406 assert!(debug_output.contains("GITHUB_TOKEN"));
1407 assert!(debug_output.contains("<redacted>"));
1408 assert!(!debug_output.contains(secret_body));
1409 }
1410
1411 #[test]
1412 fn test_mcp_server_entry_debug_redacts_via_transport() {
1413 let secret_body = "sk-verySECRETtoken1234567890";
1417 let secret = format!("Bearer {secret_body}");
1418 let entry = McpServerEntry {
1419 transport: McpTransport::Http {
1420 url: "https://api.example.com/mcp".to_string(),
1421 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1422 },
1423 connect_timeout_secs: None,
1424 discover_timeout_secs: None,
1425 };
1426
1427 let debug_output = format!("{entry:?}");
1428 assert!(debug_output.contains("Authorization"));
1429 assert!(!debug_output.contains(&secret));
1430 assert!(!debug_output.contains(secret_body));
1431 }
1432
1433 #[test]
1434 fn test_server_source_debug_redacts_via_transport() {
1435 let secret_body = "sk-verySECRETtoken1234567890";
1439 let secret = format!("Bearer {secret_body}");
1440 let source = ServerSource::Flags {
1441 transport: TransportArgs::Http {
1442 url: "https://api.example.com/mcp".to_string(),
1443 headers: vec![format!("Authorization={secret}")],
1444 },
1445 connect_timeout_secs: None,
1446 discover_timeout_secs: None,
1447 };
1448
1449 let debug_output = format!("{source:?}");
1450 assert!(!debug_output.contains(&secret));
1451 assert!(!debug_output.contains(secret_body));
1452 assert!(debug_output.contains("<redacted>"));
1453 }
1454
1455 #[test]
1456 fn test_transport_args_debug_redacts_headers_and_env() {
1457 let secret_body = "sk-verySECRETtoken1234567890";
1461 let header_entry = format!("Authorization=Bearer {secret_body}");
1462 let http = TransportArgs::Http {
1463 url: "https://api.example.com/mcp".to_string(),
1464 headers: vec![header_entry.clone()],
1465 };
1466 let http_debug = format!("{http:?}");
1467 assert!(!http_debug.contains(&header_entry));
1468 assert!(!http_debug.contains(secret_body));
1469 assert!(!http_debug.contains("Authorization"));
1470 assert!(http_debug.contains("<redacted>"));
1471
1472 let sse = TransportArgs::Sse {
1473 url: "https://api.example.com/sse".to_string(),
1474 headers: vec![header_entry.clone()],
1475 };
1476 let sse_debug = format!("{sse:?}");
1477 assert!(!sse_debug.contains(&header_entry));
1478 assert!(!sse_debug.contains(secret_body));
1479
1480 let env_entry = format!("GITHUB_TOKEN={secret_body}");
1481 let stdio = TransportArgs::Stdio {
1482 command: "node".to_string(),
1483 args: vec![],
1484 env: vec![env_entry.clone()],
1485 cwd: None,
1486 };
1487 let stdio_debug = format!("{stdio:?}");
1488 assert!(!stdio_debug.contains(&env_entry));
1489 assert!(!stdio_debug.contains(secret_body));
1490 assert!(!stdio_debug.contains("GITHUB_TOKEN"));
1491 assert!(stdio_debug.contains("<redacted>"));
1492 }
1493
1494 #[test]
1495 fn test_mcp_transport_debug_redacts_args() {
1496 let secret = "sk-live-secret";
1497 let transport = McpTransport::Stdio {
1498 command: "node".to_string(),
1499 args: vec!["--api-key".to_string(), secret.to_string()],
1500 env: HashMap::new(),
1501 cwd: None,
1502 };
1503
1504 let debug_output = format!("{transport:?}");
1505 assert!(!debug_output.contains(secret));
1506 }
1507
1508 #[test]
1509 fn test_mcp_transport_debug_redacts_url_userinfo_and_query() {
1510 let secret = "hunter2";
1511 let http = McpTransport::Http {
1512 url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1513 headers: HashMap::new(),
1514 };
1515 let http_debug = format!("{http:?}");
1516 assert!(!http_debug.contains(secret));
1517 assert!(http_debug.contains("api.example.com/mcp"));
1518
1519 let sse = McpTransport::Sse {
1520 url: format!("https://user:{secret}@api.example.com/sse?token={secret}"),
1521 headers: HashMap::new(),
1522 };
1523 let sse_debug = format!("{sse:?}");
1524 assert!(!sse_debug.contains(secret));
1525 assert!(sse_debug.contains("api.example.com/sse"));
1526 }
1527
1528 #[test]
1529 fn test_transport_args_debug_redacts_args() {
1530 let secret = "sk-live-secret";
1531 let stdio = TransportArgs::Stdio {
1532 command: "node".to_string(),
1533 args: vec!["--api-key".to_string(), secret.to_string()],
1534 env: vec![],
1535 cwd: None,
1536 };
1537
1538 let debug_output = format!("{stdio:?}");
1539 assert!(!debug_output.contains(secret));
1540 }
1541
1542 #[test]
1543 fn test_transport_args_debug_redacts_url_userinfo_and_query() {
1544 let secret = "hunter2";
1545 let http = TransportArgs::Http {
1546 url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1547 headers: vec![],
1548 };
1549 let http_debug = format!("{http:?}");
1550 assert!(!http_debug.contains(secret));
1551 assert!(http_debug.contains("api.example.com/mcp"));
1552 }
1553
1554 #[test]
1555 fn test_raw_mcp_server_entry_debug_redacts_secret_shaped_fields() {
1556 let secret = "sk-live-secret";
1557 let entry = RawMcpServerEntry {
1558 transport_type: Some(TransportTag::Stdio),
1559 command: Some("node".to_string()),
1560 args: vec!["--api-key".to_string(), secret.to_string()],
1561 env: HashMap::from([("GITHUB_TOKEN".to_string(), secret.to_string())]),
1562 cwd: None,
1563 url: None,
1564 headers: HashMap::new(),
1565 connect_timeout_secs: None,
1566 discover_timeout_secs: None,
1567 extra: HashMap::from([(
1568 "someUnknownSecret".to_string(),
1569 serde_json::Value::String(secret.to_string()),
1570 )]),
1571 };
1572
1573 let debug_output = format!("{entry:?}");
1574 assert!(!debug_output.contains(secret));
1575 assert!(debug_output.contains("GITHUB_TOKEN"));
1577 assert!(debug_output.contains("someUnknownSecret"));
1578 assert!(debug_output.contains("node"));
1579 }
1580
1581 #[test]
1582 fn test_build_server_config_header_name_value_typo_does_not_leak_secret() {
1583 let secret = "c2VjcmV0dG9rZW4=";
1592 let header = format!("Authorization: Bearer {secret}");
1593 let result = build_server_config(
1594 http_transport("https://example.com", vec![&header]),
1595 None,
1596 None,
1597 );
1598
1599 let err = result.unwrap_err();
1600 assert!(
1601 !format!("{err:?}").contains(secret),
1602 "error chain leaked the raw secret: {err:?}"
1603 );
1604 assert!(
1605 !format!("{err:?}").contains(&header),
1606 "error chain leaked the raw header argument: {err:?}"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_build_server_config_invalid_env_classifies_as_invalid_argument() {
1612 let result = build_server_config(
1618 stdio_transport("server", vec![], vec!["INVALID_FORMAT"], None),
1619 None,
1620 None,
1621 );
1622
1623 let err = result.unwrap_err();
1624 assert!(matches!(
1625 err.downcast_ref::<CoreError>(),
1626 Some(CoreError::InvalidArgument(_))
1627 ));
1628 }
1629
1630 #[test]
1631 fn test_build_server_config_multiple_env_vars() {
1632 let (_, config) = build_server_config(
1633 stdio_transport(
1634 "server",
1635 vec![],
1636 vec!["TOKEN=abc123", "API_KEY=secret456", "DEBUG=true"],
1637 None,
1638 ),
1639 None,
1640 None,
1641 )
1642 .unwrap();
1643
1644 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1645 assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
1646 assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
1647 assert_eq!(config.env().len(), 3);
1648 }
1649
1650 #[test]
1651 fn test_build_server_config_env_with_special_chars() {
1652 let (_, config) = build_server_config(
1654 stdio_transport(
1655 "server",
1656 vec![],
1657 vec![
1658 "TOKEN=abc=def=123",
1659 "URL=https://example.com?key=value",
1660 "ENCODED=a=b=c=d",
1661 ],
1662 None,
1663 ),
1664 None,
1665 None,
1666 )
1667 .unwrap();
1668
1669 assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
1670 assert_eq!(
1671 config.env().get("URL"),
1672 Some(&"https://example.com?key=value".to_string())
1673 );
1674 assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
1675 }
1676
1677 #[test]
1678 fn test_build_server_config_empty_args_stdio() {
1679 let (id, config) = build_server_config(
1680 stdio_transport("simple-server", vec![], vec![], None),
1681 None,
1682 None,
1683 )
1684 .unwrap();
1685
1686 assert_eq!(id.as_str(), "simple-server");
1687 assert_eq!(config.command(), Some("simple-server"));
1688 assert!(config.args().is_empty());
1689 assert!(config.env().is_empty());
1690 }
1691
1692 #[test]
1693 fn test_build_server_config_http_multiple_headers() {
1694 let (_, config) = build_server_config(
1695 http_transport(
1696 "https://api.example.com",
1697 vec![
1698 "Authorization=Bearer token123",
1699 "X-API-Key=secret",
1700 "Content-Type=application/json",
1701 ],
1702 ),
1703 None,
1704 None,
1705 )
1706 .unwrap();
1707
1708 assert_eq!(
1709 config.headers().get("Authorization"),
1710 Some(&"Bearer token123".to_string())
1711 );
1712 assert_eq!(
1713 config.headers().get("X-API-Key"),
1714 Some(&"secret".to_string())
1715 );
1716 assert_eq!(
1717 config.headers().get("Content-Type"),
1718 Some(&"application/json".to_string())
1719 );
1720 assert_eq!(config.headers().len(), 3);
1721 }
1722
1723 #[test]
1724 fn test_build_server_config_header_with_special_chars() {
1725 let (_, config) = build_server_config(
1727 http_transport(
1728 "https://api.example.com",
1729 vec!["X-Custom=value=with=equals", "X-Query=a=b&c=d"],
1730 ),
1731 None,
1732 None,
1733 )
1734 .unwrap();
1735
1736 assert_eq!(
1737 config.headers().get("X-Custom"),
1738 Some(&"value=with=equals".to_string())
1739 );
1740 assert_eq!(
1741 config.headers().get("X-Query"),
1742 Some(&"a=b&c=d".to_string())
1743 );
1744 }
1745
1746 #[test]
1747 fn test_build_server_config_sse_with_headers() {
1748 let (id, config) = build_server_config(
1749 sse_transport(
1750 "https://sse.example.com/events",
1751 vec!["Authorization=Bearer xyz"],
1752 ),
1753 None,
1754 None,
1755 )
1756 .unwrap();
1757
1758 assert_eq!(id.as_str(), "sse-example-com-events");
1759 assert_eq!(config.url(), Some("https://sse.example.com/events"));
1760 assert_eq!(
1761 config.headers().get("Authorization"),
1762 Some(&"Bearer xyz".to_string())
1763 );
1764 }
1765
1766 #[test]
1767 fn test_build_server_config_empty_value_in_env() {
1768 let (_, config) = build_server_config(
1770 stdio_transport("server", vec![], vec!["EMPTY="], None),
1771 None,
1772 None,
1773 )
1774 .unwrap();
1775
1776 assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
1777 }
1778
1779 #[test]
1780 fn test_build_server_config_empty_value_in_header() {
1781 let (_, config) = build_server_config(
1783 http_transport("https://example.com", vec!["X-Empty="]),
1784 None,
1785 None,
1786 )
1787 .unwrap();
1788
1789 assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
1790 }
1791
1792 #[test]
1793 fn test_build_server_config_complex_docker_scenario() {
1794 let (id, config) = build_server_config(
1795 stdio_transport(
1796 "docker",
1797 vec!["run", "-i", "--rm", "--network=host", "my-image:latest"],
1798 vec!["API_TOKEN=secret123", "LOG_LEVEL=debug"],
1799 Some("/app/workdir"),
1800 ),
1801 None,
1802 None,
1803 )
1804 .unwrap();
1805
1806 assert_eq!(id.as_str(), "docker");
1807 assert_eq!(config.command(), Some("docker"));
1808 assert_eq!(
1809 config.args(),
1810 &["run", "-i", "--rm", "--network=host", "my-image:latest"]
1811 );
1812 assert_eq!(
1813 config.env().get("API_TOKEN"),
1814 Some(&"secret123".to_string())
1815 );
1816 assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
1817 assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
1818 }
1819
1820 #[test]
1821 fn test_build_server_config_empty_key_in_env() {
1822 let secret = "topsecretvalue";
1826 let env_arg = format!("={secret}");
1827 let result = build_server_config(
1828 stdio_transport("server", vec![], vec![&env_arg], None),
1829 None,
1830 None,
1831 );
1832
1833 assert!(result.is_err());
1834 let err = result.unwrap_err();
1835 assert!(format!("{err:?}").contains("key cannot be empty"));
1836 assert!(
1837 !format!("{err:?}").contains(secret),
1838 "error chain leaked the raw secret: {err:?}"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_build_server_config_empty_key_in_header() {
1844 let secret = "topsecretheadervalue";
1845 let header_arg = format!("={secret}");
1846 let result = build_server_config(
1847 http_transport("https://example.com", vec![&header_arg]),
1848 None,
1849 None,
1850 );
1851
1852 assert!(result.is_err());
1853 let err = result.unwrap_err();
1854 assert!(format!("{err:?}").contains("key cannot be empty"));
1855 assert!(
1856 !format!("{err:?}").contains(secret),
1857 "error chain leaked the raw secret: {err:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_build_server_config_timeout_override_reaches_core_validation() {
1863 let result = build_server_config(
1869 stdio_transport("docker", vec![], vec![], None),
1870 Some(0),
1871 None,
1872 );
1873
1874 let err = result.unwrap_err();
1875 let core_err = err.downcast::<mcp_execution_core::Error>().unwrap();
1876 if let mcp_execution_core::Error::ValidationError { field, reason } = core_err {
1877 assert_eq!(field, "connect_timeout");
1878 assert!(reason.contains("greater than zero"));
1879 } else {
1880 panic!("expected ValidationError for connect_timeout");
1881 }
1882 }
1883
1884 #[test]
1885 fn test_build_server_config_timeout_overrides() {
1886 let (_, config) = build_server_config(
1887 stdio_transport("server", vec![], vec![], None),
1888 Some(5),
1889 Some(90),
1890 )
1891 .unwrap();
1892
1893 assert_eq!(config.connect_timeout(), Duration::from_secs(5));
1894 assert_eq!(config.discover_timeout(), Duration::from_secs(90));
1895 }
1896
1897 #[test]
1898 fn test_build_server_config_default_timeouts_without_overrides() {
1899 let (_, config) =
1900 build_server_config(stdio_transport("server", vec![], vec![], None), None, None)
1901 .unwrap();
1902
1903 assert_eq!(config.connect_timeout(), Duration::from_secs(30));
1904 assert_eq!(config.discover_timeout(), Duration::from_secs(30));
1905 }
1906
1907 #[test]
1908 fn test_load_server_from_config_not_found() {
1909 let result = load_server_from_config("nonexistent");
1911 assert!(result.is_err());
1912 }
1913
1914 #[test]
1915 fn test_load_mcp_config_no_file() {
1916 let result = load_mcp_config_from(Path::new("/nonexistent/mcp.json"));
1918
1919 if let Err(error) = result {
1920 let error = error.to_string();
1921 assert!(
1922 error.contains("failed to read MCP config")
1923 || error.contains("failed to get home directory"),
1924 "Expected config read error or home dir error, got: {error}"
1925 );
1926 }
1927 }
1928
1929 #[test]
1930 fn test_list_mcp_servers_from_missing_file_returns_empty() {
1931 let result = list_mcp_servers_from(Path::new("/nonexistent/path/mcp.json"));
1933 assert!(result.is_ok());
1934 assert!(result.unwrap().is_empty());
1935 }
1936
1937 #[test]
1938 fn test_list_mcp_servers_from_valid_file() {
1939 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1940 let file = create_test_config(json);
1941
1942 let servers = list_mcp_servers_from(file.path()).unwrap();
1943 assert_eq!(servers.len(), 1);
1944 assert_eq!(servers[0].0, "github");
1945 assert!(matches!(
1946 servers[0].1.transport,
1947 McpTransport::Stdio { ref command, .. } if command == "node"
1948 ));
1949 }
1950
1951 #[test]
1952 fn test_list_mcp_servers_from_empty_servers_key() {
1953 let json = r#"{"mcpServers": {}}"#;
1954 let file = create_test_config(json);
1955
1956 let servers = list_mcp_servers_from(file.path()).unwrap();
1957 assert!(servers.is_empty());
1958 }
1959
1960 #[test]
1961 fn test_load_mcp_config_without_timeout_keys_uses_defaults() {
1962 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1963 let file = create_test_config(json);
1964
1965 let config = load_mcp_config_from(file.path()).unwrap();
1966 let entry = &config.mcp_servers["github"];
1967 assert_eq!(entry.connect_timeout_secs, None);
1968 assert_eq!(entry.discover_timeout_secs, None);
1969
1970 let server_config = build_core_config(entry).unwrap();
1971 assert_eq!(server_config.connect_timeout(), Duration::from_secs(30));
1972 assert_eq!(server_config.discover_timeout(), Duration::from_secs(30));
1973 }
1974
1975 #[test]
1976 fn test_load_mcp_config_with_timeout_keys_reaches_server_config() {
1977 let json = r#"{"mcpServers": {"github": {
1978 "command": "node",
1979 "connectTimeoutSecs": 5,
1980 "discoverTimeoutSecs": 90
1981 }}}"#;
1982 let file = create_test_config(json);
1983
1984 let config = load_mcp_config_from(file.path()).unwrap();
1985 let entry = &config.mcp_servers["github"];
1986 assert_eq!(entry.connect_timeout_secs, Some(5));
1987 assert_eq!(entry.discover_timeout_secs, Some(90));
1988
1989 let server_config = build_core_config(entry).unwrap();
1990 assert_eq!(server_config.connect_timeout(), Duration::from_secs(5));
1991 assert_eq!(server_config.discover_timeout(), Duration::from_secs(90));
1992 }
1993
1994 #[test]
1995 fn test_build_core_config_http_entry_reaches_server_config() {
1996 let json = r#"{"mcpServers": {"remote": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}}}}"#;
2000 let file = create_test_config(json);
2001
2002 let config = load_mcp_config_from(file.path()).unwrap();
2003 let entry = &config.mcp_servers["remote"];
2004
2005 let server_config = build_core_config(entry).unwrap();
2006 assert_eq!(server_config.url(), Some("https://api.example.com/mcp"));
2007 assert_eq!(
2008 server_config.headers().get("Authorization"),
2009 Some(&"Bearer x".to_string())
2010 );
2011 }
2012
2013 #[test]
2014 fn test_build_core_config_stdio_cwd_reaches_server_config() {
2015 let json = r#"{"mcpServers": {"local": {"command": "node", "cwd": "/tmp/workdir"}}}"#;
2016 let file = create_test_config(json);
2017
2018 let config = load_mcp_config_from(file.path()).unwrap();
2019 let entry = &config.mcp_servers["local"];
2020
2021 let server_config = build_core_config(entry).unwrap();
2022 assert_eq!(server_config.cwd(), Some(&PathBuf::from("/tmp/workdir")));
2023 }
2024
2025 #[test]
2026 fn test_load_mcp_config_serde_default_on_missing_mcp_servers() {
2027 let json = r#"{"someOtherKey": "value"}"#;
2029 let file = create_test_config(json);
2030
2031 let config = load_mcp_config_from(file.path()).unwrap();
2032 assert!(
2033 config.mcp_servers.is_empty(),
2034 "missing mcpServers key must produce empty map, not error"
2035 );
2036 }
2037
2038 #[test]
2041 fn test_derive_server_id_from_url_basic() {
2042 assert_eq!(
2043 derive_server_id_from_url("https://api.githubcopilot.com/mcp/").as_str(),
2044 "api-githubcopilot-com-mcp"
2045 );
2046 assert_eq!(
2047 derive_server_id_from_url("https://example.com/sse").as_str(),
2048 "example-com-sse"
2049 );
2050 }
2051
2052 #[test]
2053 fn test_derive_server_id_from_url_strips_credentials() {
2054 let id = derive_server_id_from_url("https://user:sekrit-token@api.example.com/mcp");
2057 assert!(!id.as_str().contains("sekrit"));
2058 assert!(!id.as_str().contains("user"));
2059 assert_eq!(id.as_str(), "api-example-com-mcp");
2060 }
2061
2062 #[test]
2063 fn test_derive_server_id_from_url_rejects_path_traversal_chars() {
2064 let id = derive_server_id_from_url("https://api.example.com/../../etc/passwd");
2067 assert!(!id.as_str().contains(".."));
2068 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2069 }
2070
2071 #[test]
2072 fn test_derive_server_id_from_url_join_never_escapes_base_dir() {
2073 let base_dir = PathBuf::from("/home/user/.claude/servers");
2079 let malicious_urls = [
2080 "https://api.example.com/../../../../etc/passwd",
2081 "https://api.example.com/..%2f..%2fescape",
2082 "https://api.example.com/./././escape",
2083 ];
2084
2085 for url in malicious_urls {
2086 let id = derive_server_id_from_url(url);
2087 let joined = base_dir.join(id.as_str());
2088 assert!(
2089 joined.starts_with(&base_dir),
2090 "joining derived id {:?} (from {url:?}) onto {base_dir:?} escaped it: {joined:?}",
2091 id.as_str()
2092 );
2093 }
2094 }
2095
2096 #[test]
2097 fn test_derive_server_id_from_url_normalizes_case() {
2098 assert_eq!(
2099 derive_server_id_from_url("https://API.Example.COM/MCP").as_str(),
2100 "api-example-com-mcp"
2101 );
2102 }
2103
2104 #[test]
2105 fn test_derive_server_id_from_url_truncates_to_length_limit() {
2106 let long_path = "a".repeat(200);
2107 let id = derive_server_id_from_url(&format!("https://example.com/{long_path}"));
2108 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2109 }
2110
2111 #[test]
2112 fn test_derive_server_id_from_url_falls_back_when_empty() {
2113 let id = derive_server_id_from_url("https://...");
2117 assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2118 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2119 }
2120
2121 #[test]
2122 fn test_derive_server_id_from_url_falls_back_on_unparseable_url() {
2123 for unparseable in ["not a url at all", "", "://", "!!!"] {
2127 let id = derive_server_id_from_url(unparseable);
2128 assert_eq!(
2129 id.as_str(),
2130 FALLBACK_SERVER_ID_SLUG,
2131 "input {unparseable:?} should fall back to the default slug"
2132 );
2133 }
2134 }
2135
2136 #[test]
2143 fn test_derive_server_id_from_url_unparseable_credential_bearing_url_leaks_nothing() {
2144 let id = derive_server_id_from_url("https://user:pass@evil.com:99999/x");
2145 assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2146 assert!(!id.as_str().contains("user"));
2147 assert!(!id.as_str().contains("pass"));
2148 assert!(!id.as_str().contains("evil"));
2149 }
2150
2151 #[test]
2152 fn test_derive_server_id_from_url_always_passes_validate_server_id() {
2153 let urls = [
2154 "https://api.githubcopilot.com/mcp/",
2155 "https://example.com/sse",
2156 "https://user:token@host.example.com/mcp?query=1#frag",
2157 "https://HOST.EXAMPLE.COM/Path/With/Mixed_Case",
2158 "https://127.0.0.1:8443/mcp",
2159 "https://example.com/../../escape",
2160 "https://",
2161 "not-a-url",
2162 ];
2163 for url in urls {
2164 let id = derive_server_id_from_url(url);
2165 assert!(
2166 mcp_execution_skill::validate_server_id(id.as_str()).is_ok(),
2167 "derived id {:?} from url {url:?} must satisfy validate_server_id",
2168 id.as_str()
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn test_build_server_config_http_id_passes_validate_server_id() {
2175 let (id, _config) = build_server_config(
2176 http_transport("https://user:token@api.example.com/mcp/../secret", vec![]),
2177 None,
2178 None,
2179 )
2180 .unwrap();
2181
2182 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2183 assert!(!id.as_str().contains("token"));
2184 }
2185
2186 #[test]
2191 fn test_derive_server_id_from_path_or_name_rejects_parent_traversal() {
2192 let id = derive_server_id_from_path_or_name("../../../../etc/passwd");
2193 assert!(!id.as_str().contains(".."));
2194 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2195 }
2196
2197 #[test]
2198 fn test_derive_server_id_from_path_or_name_rejects_absolute_path() {
2199 let id = derive_server_id_from_path_or_name("/etc/cron.d/evil");
2200 assert!(!id.as_str().starts_with('/'));
2201 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2202 }
2203
2204 #[test]
2205 fn test_derive_server_id_from_path_or_name_join_never_escapes_base_dir() {
2206 let base_dir = PathBuf::from("/home/user/.claude/servers");
2211 let malicious_inputs = [
2212 "../../../../etc/passwd",
2213 "/etc/cron.d/evil",
2214 "/../../escape",
2215 "..",
2216 "./../escape",
2217 ];
2218
2219 for input in malicious_inputs {
2220 let id = derive_server_id_from_path_or_name(input);
2221 let joined = base_dir.join(id.as_str());
2222 assert!(
2223 joined.starts_with(&base_dir),
2224 "joining derived id {:?} (from {input:?}) onto {base_dir:?} escaped it: {joined:?}",
2225 id.as_str()
2226 );
2227 }
2228 }
2229
2230 #[test]
2231 fn test_derive_server_id_from_path_or_name_preserves_ordinary_commands() {
2232 assert_eq!(
2235 derive_server_id_from_path_or_name("github-mcp-server").as_str(),
2236 "github-mcp-server"
2237 );
2238 assert_eq!(
2239 derive_server_id_from_path_or_name("docker").as_str(),
2240 "docker"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_derive_server_id_from_path_or_name_strips_path_components() {
2246 let id = derive_server_id_from_path_or_name("/usr/local/bin/mcp-server");
2249 assert_eq!(id.as_str(), "usr-local-bin-mcp-server");
2250 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2251 }
2252
2253 #[test]
2254 fn test_build_server_config_stdio_traversal_command_never_escapes_base_dir() {
2255 let base_dir = PathBuf::from("/home/user/.claude/servers");
2263 for command in ["../../../../etc/passwd", "/bin/sh"] {
2264 let (id, _config) =
2265 build_server_config(stdio_transport(command, vec![], vec![], None), None, None)
2266 .unwrap();
2267
2268 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2269 let joined = base_dir.join(id.as_str());
2270 assert!(
2271 joined.starts_with(&base_dir),
2272 "joining derived id {:?} (from command {command:?}) escaped {base_dir:?}: {joined:?}",
2273 id.as_str()
2274 );
2275 }
2276 }
2277
2278 #[cfg(unix)]
2288 static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2289
2290 #[cfg(unix)]
2302 #[test]
2303 fn test_list_and_get_mcp_server_success_via_default_path() {
2304 let _guard = HOME_ENV_LOCK.lock().unwrap();
2305
2306 let temp = tempfile::TempDir::new().unwrap();
2307 let claude_dir = temp.path().join(".claude");
2308 std::fs::create_dir_all(&claude_dir).unwrap();
2309 std::fs::write(
2310 claude_dir.join("mcp.json"),
2311 r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#,
2312 )
2313 .unwrap();
2314
2315 let original_home = std::env::var_os("HOME");
2316 unsafe {
2319 std::env::set_var("HOME", temp.path());
2320 }
2321
2322 let list_result = list_mcp_servers();
2323 let get_result = get_mcp_server("github");
2324
2325 unsafe {
2327 match &original_home {
2328 Some(home) => std::env::set_var("HOME", home),
2329 None => std::env::remove_var("HOME"),
2330 }
2331 }
2332
2333 let servers = list_result.expect("list_mcp_servers must resolve the default path");
2334 assert_eq!(servers.len(), 1);
2335 assert_eq!(servers[0].0, "github");
2336
2337 let (id, _config, entry) =
2338 get_result.expect("get_mcp_server must find the configured server");
2339 assert_eq!(id.as_str(), "github");
2340 assert!(matches!(
2341 entry.transport,
2342 McpTransport::Stdio { ref command, .. } if command == "node"
2343 ));
2344 }
2345
2346 #[cfg(unix)]
2353 #[test]
2354 fn test_get_mcp_server_accepts_non_slug_shaped_config_key() {
2355 let _guard = HOME_ENV_LOCK.lock().unwrap();
2356
2357 let temp = tempfile::TempDir::new().unwrap();
2358 let claude_dir = temp.path().join(".claude");
2359 std::fs::create_dir_all(&claude_dir).unwrap();
2360 std::fs::write(
2361 claude_dir.join("mcp.json"),
2362 r#"{"mcpServers": {"claude_ai_Gmail": {"command": "node", "args": ["server.js"]}}}"#,
2363 )
2364 .unwrap();
2365
2366 let original_home = std::env::var_os("HOME");
2367 unsafe {
2370 std::env::set_var("HOME", temp.path());
2371 }
2372
2373 let get_result = get_mcp_server("claude_ai_Gmail");
2374
2375 unsafe {
2377 match &original_home {
2378 Some(home) => std::env::set_var("HOME", home),
2379 None => std::env::remove_var("HOME"),
2380 }
2381 }
2382
2383 let (id, _config, _entry) =
2384 get_result.expect("get_mcp_server must not reject a non-slug-shaped mcp.json key");
2385 assert_eq!(id.as_str(), "claude_ai_Gmail");
2386 }
2387
2388 #[cfg(unix)]
2393 #[test]
2394 fn test_get_mcp_server_entry_finds_entry_that_fails_config_validation() {
2395 let _guard = HOME_ENV_LOCK.lock().unwrap();
2396
2397 let temp = tempfile::TempDir::new().unwrap();
2398 let claude_dir = temp.path().join(".claude");
2399 std::fs::create_dir_all(&claude_dir).unwrap();
2400 std::fs::write(
2401 claude_dir.join("mcp.json"),
2402 r#"{"mcpServers": {"badscheme": {"type": "http", "url": "not-a-url"}}}"#,
2403 )
2404 .unwrap();
2405
2406 let original_home = std::env::var_os("HOME");
2407 unsafe {
2410 std::env::set_var("HOME", temp.path());
2411 }
2412
2413 let entry_result = get_mcp_server_entry("badscheme");
2414
2415 unsafe {
2417 match &original_home {
2418 Some(home) => std::env::set_var("HOME", home),
2419 None => std::env::remove_var("HOME"),
2420 }
2421 }
2422
2423 let (id, entry) = entry_result.expect(
2424 "get_mcp_server_entry must find the entry even though its url fails validation",
2425 );
2426 assert_eq!(id.as_str(), "badscheme");
2427 let config_err = build_core_config(&entry).expect_err(
2428 "the entry's url is expected to fail build_core_config's scheme validation",
2429 );
2430 let message = config_err.to_string();
2435 assert!(
2436 !message.to_lowercase().contains("not found"),
2437 "build_core_config's error must not read like a not-found message, got: {message}"
2438 );
2439 }
2440}