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
382pub(crate) fn load_mcp_config() -> Result<McpConfig> {
396 let home = dirs::home_dir().context("failed to get home directory")?;
397 load_mcp_config_from(&home.join(".claude").join("mcp.json"))
398}
399
400fn list_mcp_servers_from(path: &Path) -> Result<Vec<(String, McpServerEntry)>> {
409 if !path.exists() {
410 return Ok(Vec::new());
411 }
412 let config = load_mcp_config_from(path)?;
413 Ok(config.mcp_servers.into_iter().collect())
414}
415
416pub(crate) fn list_mcp_servers() -> Result<Vec<(String, McpServerEntry)>> {
432 let home = dirs::home_dir().context("failed to get home directory")?;
433 list_mcp_servers_from(&home.join(".claude").join("mcp.json"))
434}
435
436pub(crate) fn get_mcp_server(name: &str) -> Result<(ServerId, ServerConfig, McpServerEntry)> {
475 let (server_id, entry) = get_mcp_server_entry(name)?;
476 let server_config = build_core_config(&entry)?;
477 Ok((server_id, server_config, entry))
478}
479
480pub(crate) fn get_mcp_server_entry(name: &str) -> Result<(ServerId, McpServerEntry)> {
495 let config = load_mcp_config()?;
496 lookup_server_entry(&config, name)
497}
498
499pub(crate) fn lookup_server_entry(
511 config: &McpConfig,
512 name: &str,
513) -> Result<(ServerId, McpServerEntry)> {
514 let entry = config
515 .mcp_servers
516 .get(name)
517 .with_context(|| {
518 format!(
519 "server '{name}' not found in ~/.claude/mcp.json\n\
520 Hint: ensure the server is defined in ~/.claude/mcp.json under \"mcpServers\""
521 )
522 })?
523 .clone();
524
525 let server_id = ServerId::new(name).with_context(|| {
526 format!("server '{name}' in ~/.claude/mcp.json is not a valid server id")
527 })?;
528 Ok((server_id, entry))
529}
530
531pub(crate) fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
545 let (id, config, _) = get_mcp_server(name)?;
546 Ok((id, config))
547}
548
549fn builder_for_transport(transport: McpTransport) -> ServerConfigBuilder {
555 match transport {
556 McpTransport::Stdio {
557 command,
558 args,
559 env,
560 cwd,
561 } => {
562 let mut builder = ServerConfig::builder().command(command);
563 if !args.is_empty() {
564 builder = builder.args(args);
565 }
566 for (key, value) in env {
567 builder = builder.env(key, value);
568 }
569 if let Some(dir) = cwd {
570 builder = builder.cwd(dir);
571 }
572 builder
573 }
574 McpTransport::Http { url, headers } => {
575 let mut builder = ServerConfig::builder().http_transport(url);
576 for (key, value) in headers {
577 builder = builder.header(key, value);
578 }
579 builder
580 }
581 McpTransport::Sse { url, headers } => {
582 let mut builder = ServerConfig::builder().sse_transport(url);
583 for (key, value) in headers {
584 builder = builder.header(key, value);
585 }
586 builder
587 }
588 }
589}
590
591pub(crate) fn build_core_config(entry: &McpServerEntry) -> Result<ServerConfig> {
604 let mut builder = builder_for_transport(entry.transport.clone());
605
606 if let Some(secs) = entry.connect_timeout_secs {
607 builder = builder.connect_timeout(Duration::from_secs(secs));
608 }
609
610 if let Some(secs) = entry.discover_timeout_secs {
611 builder = builder.discover_timeout(Duration::from_secs(secs));
612 }
613
614 Ok(builder.build()?)
615}
616
617fn parse_key_value(s: &str, kind: &str) -> Result<(String, String)> {
626 let Some((key, value)) = s.split_once('=') else {
630 return Err(CoreError::InvalidArgument(format!(
631 "invalid {kind} format: no '=' separator found (expected KEY=VALUE)"
632 ))
633 .into());
634 };
635 if key.is_empty() {
636 return Err(CoreError::InvalidArgument(format!(
637 "invalid {kind} format: key cannot be empty (expected KEY=VALUE)"
638 ))
639 .into());
640 }
641 if key
648 .chars()
649 .any(|c| c.is_whitespace() || c == ':' || c.is_control())
650 {
651 return Err(CoreError::InvalidArgument(format!(
652 "invalid {kind} format: text before '=' contains characters that are never valid \
653 in a key, suggesting '=' matched inside a value rather than as the separator; \
654 refusing to echo it since it may contain a secret (expected KEY=VALUE)"
655 ))
656 .into());
657 }
658 Ok((key.to_string(), value.to_string()))
659}
660
661#[derive(Clone)]
708pub enum TransportArgs {
709 Stdio {
711 command: String,
713 args: Vec<String>,
715 env: Vec<String>,
717 cwd: Option<String>,
719 },
720 Http {
722 url: String,
724 headers: Vec<String>,
726 },
727 Sse {
729 url: String,
731 headers: Vec<String>,
733 },
734}
735
736impl fmt::Debug for TransportArgs {
737 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
738 match self {
739 Self::Stdio {
740 command,
741 args,
742 env,
743 cwd,
744 } => f
745 .debug_struct("Stdio")
746 .field("command", &sanitize_path_for_error(Path::new(command)))
747 .field("args", &RedactedItems(args))
748 .field("env", &RedactedItems(env))
749 .field(
750 "cwd",
751 &cwd.as_deref()
752 .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
753 )
754 .finish(),
755 Self::Http { url, headers } => f
756 .debug_struct("Http")
757 .field("url", &RedactedUrl(url))
758 .field("headers", &RedactedItems(headers))
759 .finish(),
760 Self::Sse { url, headers } => f
761 .debug_struct("Sse")
762 .field("url", &RedactedUrl(url))
763 .field("headers", &RedactedItems(headers))
764 .finish(),
765 }
766 }
767}
768
769impl TryFrom<TransportArgs> for McpTransport {
770 type Error = anyhow::Error;
771
772 fn try_from(args: TransportArgs) -> Result<Self> {
773 match args {
774 TransportArgs::Stdio {
775 command,
776 args,
777 env,
778 cwd,
779 } => {
780 let env = env
781 .iter()
782 .map(|s| parse_key_value(s, "environment variable"))
783 .collect::<Result<HashMap<_, _>>>()?;
784 Ok(Self::Stdio {
785 command,
786 args,
787 env,
788 cwd: cwd.map(PathBuf::from),
789 })
790 }
791 TransportArgs::Http { url, headers } => {
792 let headers = headers
793 .iter()
794 .map(|s| parse_key_value(s, "header"))
795 .collect::<Result<HashMap<_, _>>>()?;
796 Ok(Self::Http { url, headers })
797 }
798 TransportArgs::Sse { url, headers } => {
799 let headers = headers
800 .iter()
801 .map(|s| parse_key_value(s, "header"))
802 .collect::<Result<HashMap<_, _>>>()?;
803 Ok(Self::Sse { url, headers })
804 }
805 }
806 }
807}
808
809pub(crate) fn build_server_config(
830 transport: TransportArgs,
831 connect_timeout_secs: Option<u64>,
832 discover_timeout_secs: Option<u64>,
833) -> Result<(ServerId, ServerConfig)> {
834 let server_id = match &transport {
835 TransportArgs::Stdio { command, .. } => derive_server_id_from_path_or_name(command),
836 TransportArgs::Http { url, .. } | TransportArgs::Sse { url, .. } => {
837 derive_server_id_from_url(url)
838 }
839 };
840
841 let mut builder = builder_for_transport(McpTransport::try_from(transport)?);
842
843 if let Some(secs) = connect_timeout_secs {
844 builder = builder.connect_timeout(Duration::from_secs(secs));
845 }
846
847 if let Some(secs) = discover_timeout_secs {
848 builder = builder.discover_timeout(Duration::from_secs(secs));
849 }
850
851 Ok((server_id, builder.build()?))
852}
853
854#[derive(Debug, Clone)]
904pub enum ServerSource {
905 Config {
907 name: String,
909 },
910 Flags {
912 transport: TransportArgs,
914 connect_timeout_secs: Option<u64>,
916 discover_timeout_secs: Option<u64>,
918 },
919}
920
921pub(crate) fn resolve_server_config(source: ServerSource) -> Result<(ServerId, ServerConfig)> {
936 match source {
937 ServerSource::Config { name } => {
938 debug!("Loading server configuration from ~/.claude/mcp.json: {name}");
939 load_server_from_config(&name)
940 }
941 ServerSource::Flags {
942 transport,
943 connect_timeout_secs,
944 discover_timeout_secs,
945 } => build_server_config(transport, connect_timeout_secs, discover_timeout_secs),
946 }
947}
948
949fn slugify(input: &str) -> ServerId {
962 let mut slug = String::with_capacity(input.len());
963 for ch in input.chars() {
964 let lower = ch.to_ascii_lowercase();
965 if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
966 slug.push(lower);
967 } else if slug.chars().next_back().is_some_and(|last| last != '-') {
968 slug.push('-');
969 }
970 }
971
972 let slug = slug.trim_matches('-');
973 let slug = &slug[..slug.len().min(MAX_SERVER_ID_LENGTH)];
974 let slug = slug.trim_end_matches('-');
975
976 ServerId::new(if slug.is_empty() {
981 FALLBACK_SERVER_ID_SLUG
982 } else {
983 slug
984 })
985 .expect("slugify only ever produces a valid path-segment ServerId")
986}
987
988fn derive_server_id_from_url(url: &str) -> ServerId {
1002 let host_and_path = Url::parse(url)
1010 .ok()
1011 .map(|parsed| format!("{}{}", parsed.host_str().unwrap_or_default(), parsed.path()))
1012 .unwrap_or_default();
1013
1014 slugify(&host_and_path)
1015}
1016
1017pub(crate) fn derive_server_id_from_path_or_name(raw: &str) -> ServerId {
1031 slugify(raw)
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use std::io::Write;
1038
1039 fn create_test_config(content: &str) -> tempfile::NamedTempFile {
1041 let mut file = tempfile::NamedTempFile::new().unwrap();
1042 file.write_all(content.as_bytes()).unwrap();
1043 file.flush().unwrap();
1044 file
1045 }
1046
1047 fn stdio_transport(
1048 command: &str,
1049 args: Vec<&str>,
1050 env: Vec<&str>,
1051 cwd: Option<&str>,
1052 ) -> TransportArgs {
1053 TransportArgs::Stdio {
1054 command: command.to_string(),
1055 args: args.into_iter().map(String::from).collect(),
1056 env: env.into_iter().map(String::from).collect(),
1057 cwd: cwd.map(String::from),
1058 }
1059 }
1060
1061 fn http_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1062 TransportArgs::Http {
1063 url: url.to_string(),
1064 headers: headers.into_iter().map(String::from).collect(),
1065 }
1066 }
1067
1068 fn sse_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1069 TransportArgs::Sse {
1070 url: url.to_string(),
1071 headers: headers.into_iter().map(String::from).collect(),
1072 }
1073 }
1074
1075 #[test]
1076 fn test_load_mcp_config_from_valid() {
1077 let json = r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#;
1078 let file = create_test_config(json);
1079
1080 let config = load_mcp_config_from(file.path()).unwrap();
1081 assert_eq!(config.mcp_servers.len(), 1);
1082 assert!(config.mcp_servers.contains_key("github"));
1083 }
1084
1085 #[test]
1086 fn test_load_mcp_config_from_empty_servers() {
1087 let json = r"{}";
1089 let file = create_test_config(json);
1090
1091 let config = load_mcp_config_from(file.path()).unwrap();
1092 assert!(config.mcp_servers.is_empty());
1093 }
1094
1095 #[test]
1096 fn test_load_mcp_config_from_minimal_server() {
1097 let json = r#"{"mcpServers": {"minimal": {"command": "python"}}}"#;
1099 let file = create_test_config(json);
1100
1101 let config = load_mcp_config_from(file.path()).unwrap();
1102 let entry = &config.mcp_servers["minimal"];
1103 match &entry.transport {
1104 McpTransport::Stdio {
1105 command, args, env, ..
1106 } => {
1107 assert_eq!(command, "python");
1108 assert!(args.is_empty());
1109 assert!(env.is_empty());
1110 }
1111 other => panic!("expected Stdio transport, got {other:?}"),
1112 }
1113 }
1114
1115 #[test]
1116 fn test_load_mcp_config_from_multiple_servers() {
1117 let json = r#"{
1118 "mcpServers": {
1119 "server1": {"command": "node", "args": ["s1.js"]},
1120 "server2": {"command": "python", "args": ["s2.py"]}
1121 }
1122 }"#;
1123 let file = create_test_config(json);
1124
1125 let config = load_mcp_config_from(file.path()).unwrap();
1126 assert_eq!(config.mcp_servers.len(), 2);
1127 assert!(config.mcp_servers.contains_key("server1"));
1128 assert!(config.mcp_servers.contains_key("server2"));
1129 }
1130
1131 #[test]
1132 fn test_load_mcp_config_from_not_found() {
1133 let result = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"));
1134 assert!(result.is_err());
1135 assert!(result.unwrap_err().to_string().contains("failed to read"));
1136 }
1137
1138 #[test]
1139 fn test_load_mcp_config_from_malformed_json() {
1140 let file = create_test_config("not valid json");
1141 let result = load_mcp_config_from(file.path());
1142 assert!(result.is_err());
1143 assert!(result.unwrap_err().to_string().contains("parse MCP config"));
1144 }
1145
1146 #[test]
1149 fn test_lookup_server_entry_absent_name_errors_not_found() {
1150 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1151 let file = create_test_config(json);
1152 let config = load_mcp_config_from(file.path()).unwrap();
1153
1154 let result = lookup_server_entry(&config, "nonexistent");
1155 assert!(result.is_err());
1156 assert!(
1157 result
1158 .unwrap_err()
1159 .to_string()
1160 .contains("not found in ~/.claude/mcp.json")
1161 );
1162 }
1163
1164 #[test]
1165 fn test_config_load_and_lookup_errors_are_distinguishable() {
1166 let missing = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"))
1173 .unwrap_err()
1174 .to_string();
1175 assert!(missing.contains("failed to read"));
1176 assert!(!missing.contains("not found in ~/.claude/mcp.json"));
1177
1178 let malformed_file = create_test_config("not valid json");
1179 let malformed = load_mcp_config_from(malformed_file.path())
1180 .unwrap_err()
1181 .to_string();
1182 assert!(malformed.contains("parse MCP config"));
1183 assert!(!malformed.contains("not found in ~/.claude/mcp.json"));
1184
1185 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1186 let ok_file = create_test_config(json);
1187 let config = load_mcp_config_from(ok_file.path()).unwrap();
1188 let not_found = lookup_server_entry(&config, "missing")
1189 .unwrap_err()
1190 .to_string();
1191 assert!(not_found.contains("not found in ~/.claude/mcp.json"));
1192 assert!(!not_found.contains("failed to read"));
1193 assert!(!not_found.contains("parse MCP config"));
1194 }
1195
1196 #[test]
1199 fn test_load_mcp_config_mixed_stdio_http_sse() {
1200 let json = r#"{
1201 "mcpServers": {
1202 "local": {"command": "node", "args": ["server.js"]},
1203 "remote-http": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}},
1204 "remote-sse": {"type": "sse", "url": "https://example.com/sse"}
1205 }
1206 }"#;
1207 let file = create_test_config(json);
1208
1209 let config = load_mcp_config_from(file.path()).unwrap();
1210 assert_eq!(config.mcp_servers.len(), 3);
1211
1212 assert!(matches!(
1213 config.mcp_servers["local"].transport,
1214 McpTransport::Stdio { .. }
1215 ));
1216 assert!(matches!(
1217 config.mcp_servers["remote-http"].transport,
1218 McpTransport::Http { .. }
1219 ));
1220 assert!(matches!(
1221 config.mcp_servers["remote-sse"].transport,
1222 McpTransport::Sse { .. }
1223 ));
1224 }
1225
1226 #[test]
1227 fn test_load_mcp_config_http_entry_type_absent_but_url_present() {
1228 let json = r#"{"mcpServers": {"remote": {"url": "https://api.example.com/mcp"}}}"#;
1230 let file = create_test_config(json);
1231
1232 let config = load_mcp_config_from(file.path()).unwrap();
1233 assert!(matches!(
1234 config.mcp_servers["remote"].transport,
1235 McpTransport::Http { .. }
1236 ));
1237 }
1238
1239 #[test]
1240 fn test_load_mcp_config_http_entry_missing_url_errors_naming_url() {
1241 let json = r#"{"mcpServers": {"remote": {"type": "http"}}}"#;
1242 let file = create_test_config(json);
1243
1244 let result = load_mcp_config_from(file.path());
1245 assert!(result.is_err());
1246 assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1249 }
1250
1251 #[test]
1252 fn test_load_mcp_config_entry_with_neither_command_nor_type_errors() {
1253 let json = r#"{"mcpServers": {"broken": {}}}"#;
1254 let file = create_test_config(json);
1255
1256 let result = load_mcp_config_from(file.path());
1257 assert!(result.is_err());
1258 let msg = format!("{:#}", result.unwrap_err());
1259 assert!(msg.contains("command"));
1260 assert!(msg.contains("url"));
1261 }
1262
1263 #[test]
1264 fn test_load_mcp_config_http_entry_with_command_errors() {
1265 let json = r#"{"mcpServers": {"bad": {"type": "http", "url": "https://x.com", "command": "node"}}}"#;
1266 let file = create_test_config(json);
1267
1268 let result = load_mcp_config_from(file.path());
1269 assert!(result.is_err());
1270 assert!(format!("{:#}", result.unwrap_err()).contains("command"));
1271 }
1272
1273 #[test]
1274 fn test_load_mcp_config_stdio_entry_with_url_errors() {
1275 let json = r#"{"mcpServers": {"bad": {"command": "node", "url": "https://x.com"}}}"#;
1276 let file = create_test_config(json);
1277
1278 let result = load_mcp_config_from(file.path());
1279 assert!(result.is_err());
1280 assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1281 }
1282
1283 #[test]
1284 fn test_load_mcp_config_unknown_field_still_parses() {
1285 let json = r#"{"mcpServers": {"github": {"command": "node", "disabled": false, "description": "x"}}}"#;
1288 let file = create_test_config(json);
1289
1290 let config = load_mcp_config_from(file.path()).unwrap();
1291 assert!(matches!(
1292 config.mcp_servers["github"].transport,
1293 McpTransport::Stdio { .. }
1294 ));
1295 }
1296
1297 #[test]
1298 fn test_build_server_config_stdio() {
1299 let (id, config) = build_server_config(
1300 stdio_transport(
1301 "github-mcp-server",
1302 vec!["stdio"],
1303 vec!["TOKEN=abc123"],
1304 None,
1305 ),
1306 None,
1307 None,
1308 )
1309 .unwrap();
1310
1311 assert_eq!(id.as_str(), "github-mcp-server");
1312 assert_eq!(config.command(), Some("github-mcp-server"));
1313 assert_eq!(config.args(), &["stdio"]);
1314 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1315 }
1316
1317 #[test]
1318 fn test_build_server_config_docker() {
1319 let (id, config) = build_server_config(
1320 stdio_transport(
1321 "docker",
1322 vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
1323 vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"],
1324 None,
1325 ),
1326 None,
1327 None,
1328 )
1329 .unwrap();
1330
1331 assert_eq!(id.as_str(), "docker");
1332 assert_eq!(config.command(), Some("docker"));
1333 assert_eq!(
1334 config.args(),
1335 &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
1336 );
1337 assert_eq!(
1338 config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
1339 Some(&"ghp_xxx".to_string())
1340 );
1341 }
1342
1343 #[test]
1344 fn test_build_server_config_http() {
1345 let (id, config) = build_server_config(
1346 http_transport(
1347 "https://api.githubcopilot.com/mcp/",
1348 vec!["Authorization=Bearer token123"],
1349 ),
1350 None,
1351 None,
1352 )
1353 .unwrap();
1354
1355 assert_eq!(id.as_str(), "api-githubcopilot-com-mcp");
1356 assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
1357 assert_eq!(
1358 config.headers().get("Authorization"),
1359 Some(&"Bearer token123".to_string())
1360 );
1361 }
1362
1363 #[test]
1364 fn test_build_server_config_sse() {
1365 let (id, config) = build_server_config(
1366 sse_transport("https://example.com/sse", vec!["X-API-Key=secret"]),
1367 None,
1368 None,
1369 )
1370 .unwrap();
1371
1372 assert_eq!(id.as_str(), "example-com-sse");
1373 assert_eq!(config.url(), Some("https://example.com/sse"));
1374 assert_eq!(
1375 config.headers().get("X-API-Key"),
1376 Some(&"secret".to_string())
1377 );
1378 }
1379
1380 #[test]
1381 fn test_build_server_config_with_cwd() {
1382 let (_, config) = build_server_config(
1383 stdio_transport("server", vec![], vec![], Some("/tmp/workdir")),
1384 None,
1385 None,
1386 )
1387 .unwrap();
1388
1389 assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
1390 }
1391
1392 #[test]
1393 fn test_build_server_config_invalid_env() {
1394 let secret = "ghp_verySECRETtoken1234567890abcdef";
1399 let result = build_server_config(
1400 stdio_transport("server", vec![], vec![secret], None),
1401 None,
1402 None,
1403 );
1404
1405 assert!(result.is_err());
1406 let err = result.unwrap_err();
1407 assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1408 assert!(
1409 !format!("{err:?}").contains(secret),
1410 "error chain leaked the raw secret: {err:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_build_server_config_invalid_header() {
1416 let secret = "Bearer sk-live-supersecretvalue1234567890";
1419 let result = build_server_config(
1420 http_transport("https://example.com", vec![secret]),
1421 None,
1422 None,
1423 );
1424
1425 assert!(result.is_err());
1426 let err = result.unwrap_err();
1427 assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1428 assert!(
1429 !format!("{err:?}").contains(secret),
1430 "error chain leaked the raw secret: {err:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_mcp_transport_debug_redacts_headers_http() {
1436 let secret_body = "sk-verySECRETtoken1234567890";
1442 let secret = format!("Bearer {secret_body}");
1443 let transport = McpTransport::Http {
1444 url: "https://api.example.com/mcp".to_string(),
1445 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1446 };
1447
1448 let debug_output = format!("{transport:?}");
1449 assert!(debug_output.contains("Authorization"));
1450 assert!(debug_output.contains("<redacted>"));
1451 assert!(!debug_output.contains(&secret));
1452 assert!(!debug_output.contains(secret_body));
1453 }
1454
1455 #[test]
1456 fn test_mcp_transport_debug_redacts_headers_sse() {
1457 let secret_body = "sk-verySECRETtoken1234567890";
1460 let secret = format!("Bearer {secret_body}");
1461 let transport = McpTransport::Sse {
1462 url: "https://api.example.com/sse".to_string(),
1463 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1464 };
1465
1466 let debug_output = format!("{transport:?}");
1467 assert!(debug_output.contains("Authorization"));
1468 assert!(debug_output.contains("<redacted>"));
1469 assert!(!debug_output.contains(&secret));
1470 assert!(!debug_output.contains(secret_body));
1471 }
1472
1473 #[test]
1474 fn test_mcp_transport_debug_redacts_env() {
1475 let secret_body = "ghp_verySECRETtoken1234567890abcdef";
1476 let transport = McpTransport::Stdio {
1477 command: "node".to_string(),
1478 args: vec![],
1479 env: HashMap::from([("GITHUB_TOKEN".to_string(), secret_body.to_string())]),
1480 cwd: None,
1481 };
1482
1483 let debug_output = format!("{transport:?}");
1484 assert!(debug_output.contains("GITHUB_TOKEN"));
1485 assert!(debug_output.contains("<redacted>"));
1486 assert!(!debug_output.contains(secret_body));
1487 }
1488
1489 #[test]
1490 fn test_mcp_server_entry_debug_redacts_via_transport() {
1491 let secret_body = "sk-verySECRETtoken1234567890";
1495 let secret = format!("Bearer {secret_body}");
1496 let entry = McpServerEntry {
1497 transport: McpTransport::Http {
1498 url: "https://api.example.com/mcp".to_string(),
1499 headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1500 },
1501 connect_timeout_secs: None,
1502 discover_timeout_secs: None,
1503 };
1504
1505 let debug_output = format!("{entry:?}");
1506 assert!(debug_output.contains("Authorization"));
1507 assert!(!debug_output.contains(&secret));
1508 assert!(!debug_output.contains(secret_body));
1509 }
1510
1511 #[test]
1512 fn test_server_source_debug_redacts_via_transport() {
1513 let secret_body = "sk-verySECRETtoken1234567890";
1517 let secret = format!("Bearer {secret_body}");
1518 let source = ServerSource::Flags {
1519 transport: TransportArgs::Http {
1520 url: "https://api.example.com/mcp".to_string(),
1521 headers: vec![format!("Authorization={secret}")],
1522 },
1523 connect_timeout_secs: None,
1524 discover_timeout_secs: None,
1525 };
1526
1527 let debug_output = format!("{source:?}");
1528 assert!(!debug_output.contains(&secret));
1529 assert!(!debug_output.contains(secret_body));
1530 assert!(debug_output.contains("<redacted>"));
1531 }
1532
1533 #[test]
1534 fn test_transport_args_debug_redacts_headers_and_env() {
1535 let secret_body = "sk-verySECRETtoken1234567890";
1539 let header_entry = format!("Authorization=Bearer {secret_body}");
1540 let http = TransportArgs::Http {
1541 url: "https://api.example.com/mcp".to_string(),
1542 headers: vec![header_entry.clone()],
1543 };
1544 let http_debug = format!("{http:?}");
1545 assert!(!http_debug.contains(&header_entry));
1546 assert!(!http_debug.contains(secret_body));
1547 assert!(!http_debug.contains("Authorization"));
1548 assert!(http_debug.contains("<redacted>"));
1549
1550 let sse = TransportArgs::Sse {
1551 url: "https://api.example.com/sse".to_string(),
1552 headers: vec![header_entry.clone()],
1553 };
1554 let sse_debug = format!("{sse:?}");
1555 assert!(!sse_debug.contains(&header_entry));
1556 assert!(!sse_debug.contains(secret_body));
1557
1558 let env_entry = format!("GITHUB_TOKEN={secret_body}");
1559 let stdio = TransportArgs::Stdio {
1560 command: "node".to_string(),
1561 args: vec![],
1562 env: vec![env_entry.clone()],
1563 cwd: None,
1564 };
1565 let stdio_debug = format!("{stdio:?}");
1566 assert!(!stdio_debug.contains(&env_entry));
1567 assert!(!stdio_debug.contains(secret_body));
1568 assert!(!stdio_debug.contains("GITHUB_TOKEN"));
1569 assert!(stdio_debug.contains("<redacted>"));
1570 }
1571
1572 #[test]
1573 fn test_mcp_transport_debug_redacts_args() {
1574 let secret = "sk-live-secret";
1575 let transport = McpTransport::Stdio {
1576 command: "node".to_string(),
1577 args: vec!["--api-key".to_string(), secret.to_string()],
1578 env: HashMap::new(),
1579 cwd: None,
1580 };
1581
1582 let debug_output = format!("{transport:?}");
1583 assert!(!debug_output.contains(secret));
1584 }
1585
1586 #[test]
1587 fn test_mcp_transport_debug_redacts_url_userinfo_and_query() {
1588 let secret = "hunter2";
1589 let http = McpTransport::Http {
1590 url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1591 headers: HashMap::new(),
1592 };
1593 let http_debug = format!("{http:?}");
1594 assert!(!http_debug.contains(secret));
1595 assert!(http_debug.contains("api.example.com/mcp"));
1596
1597 let sse = McpTransport::Sse {
1598 url: format!("https://user:{secret}@api.example.com/sse?token={secret}"),
1599 headers: HashMap::new(),
1600 };
1601 let sse_debug = format!("{sse:?}");
1602 assert!(!sse_debug.contains(secret));
1603 assert!(sse_debug.contains("api.example.com/sse"));
1604 }
1605
1606 #[test]
1607 fn test_transport_args_debug_redacts_args() {
1608 let secret = "sk-live-secret";
1609 let stdio = TransportArgs::Stdio {
1610 command: "node".to_string(),
1611 args: vec!["--api-key".to_string(), secret.to_string()],
1612 env: vec![],
1613 cwd: None,
1614 };
1615
1616 let debug_output = format!("{stdio:?}");
1617 assert!(!debug_output.contains(secret));
1618 }
1619
1620 #[test]
1621 fn test_transport_args_debug_redacts_url_userinfo_and_query() {
1622 let secret = "hunter2";
1623 let http = TransportArgs::Http {
1624 url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1625 headers: vec![],
1626 };
1627 let http_debug = format!("{http:?}");
1628 assert!(!http_debug.contains(secret));
1629 assert!(http_debug.contains("api.example.com/mcp"));
1630 }
1631
1632 #[test]
1633 fn test_raw_mcp_server_entry_debug_redacts_secret_shaped_fields() {
1634 let secret = "sk-live-secret";
1635 let entry = RawMcpServerEntry {
1636 transport_type: Some(TransportTag::Stdio),
1637 command: Some("node".to_string()),
1638 args: vec!["--api-key".to_string(), secret.to_string()],
1639 env: HashMap::from([("GITHUB_TOKEN".to_string(), secret.to_string())]),
1640 cwd: None,
1641 url: None,
1642 headers: HashMap::new(),
1643 connect_timeout_secs: None,
1644 discover_timeout_secs: None,
1645 extra: HashMap::from([(
1646 "someUnknownSecret".to_string(),
1647 serde_json::Value::String(secret.to_string()),
1648 )]),
1649 };
1650
1651 let debug_output = format!("{entry:?}");
1652 assert!(!debug_output.contains(secret));
1653 assert!(debug_output.contains("GITHUB_TOKEN"));
1655 assert!(debug_output.contains("someUnknownSecret"));
1656 assert!(debug_output.contains("node"));
1657 }
1658
1659 #[test]
1660 fn test_build_server_config_header_name_value_typo_does_not_leak_secret() {
1661 let secret = "c2VjcmV0dG9rZW4=";
1670 let header = format!("Authorization: Bearer {secret}");
1671 let result = build_server_config(
1672 http_transport("https://example.com", vec![&header]),
1673 None,
1674 None,
1675 );
1676
1677 let err = result.unwrap_err();
1678 assert!(
1679 !format!("{err:?}").contains(secret),
1680 "error chain leaked the raw secret: {err:?}"
1681 );
1682 assert!(
1683 !format!("{err:?}").contains(&header),
1684 "error chain leaked the raw header argument: {err:?}"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_build_server_config_invalid_env_classifies_as_invalid_argument() {
1690 let result = build_server_config(
1696 stdio_transport("server", vec![], vec!["INVALID_FORMAT"], None),
1697 None,
1698 None,
1699 );
1700
1701 let err = result.unwrap_err();
1702 assert!(matches!(
1703 err.downcast_ref::<CoreError>(),
1704 Some(CoreError::InvalidArgument(_))
1705 ));
1706 }
1707
1708 #[test]
1709 fn test_build_server_config_multiple_env_vars() {
1710 let (_, config) = build_server_config(
1711 stdio_transport(
1712 "server",
1713 vec![],
1714 vec!["TOKEN=abc123", "API_KEY=secret456", "DEBUG=true"],
1715 None,
1716 ),
1717 None,
1718 None,
1719 )
1720 .unwrap();
1721
1722 assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1723 assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
1724 assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
1725 assert_eq!(config.env().len(), 3);
1726 }
1727
1728 #[test]
1729 fn test_build_server_config_env_with_special_chars() {
1730 let (_, config) = build_server_config(
1732 stdio_transport(
1733 "server",
1734 vec![],
1735 vec![
1736 "TOKEN=abc=def=123",
1737 "URL=https://example.com?key=value",
1738 "ENCODED=a=b=c=d",
1739 ],
1740 None,
1741 ),
1742 None,
1743 None,
1744 )
1745 .unwrap();
1746
1747 assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
1748 assert_eq!(
1749 config.env().get("URL"),
1750 Some(&"https://example.com?key=value".to_string())
1751 );
1752 assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
1753 }
1754
1755 #[test]
1756 fn test_build_server_config_empty_args_stdio() {
1757 let (id, config) = build_server_config(
1758 stdio_transport("simple-server", vec![], vec![], None),
1759 None,
1760 None,
1761 )
1762 .unwrap();
1763
1764 assert_eq!(id.as_str(), "simple-server");
1765 assert_eq!(config.command(), Some("simple-server"));
1766 assert!(config.args().is_empty());
1767 assert!(config.env().is_empty());
1768 }
1769
1770 #[test]
1771 fn test_build_server_config_http_multiple_headers() {
1772 let (_, config) = build_server_config(
1773 http_transport(
1774 "https://api.example.com",
1775 vec![
1776 "Authorization=Bearer token123",
1777 "X-API-Key=secret",
1778 "Content-Type=application/json",
1779 ],
1780 ),
1781 None,
1782 None,
1783 )
1784 .unwrap();
1785
1786 assert_eq!(
1787 config.headers().get("Authorization"),
1788 Some(&"Bearer token123".to_string())
1789 );
1790 assert_eq!(
1791 config.headers().get("X-API-Key"),
1792 Some(&"secret".to_string())
1793 );
1794 assert_eq!(
1795 config.headers().get("Content-Type"),
1796 Some(&"application/json".to_string())
1797 );
1798 assert_eq!(config.headers().len(), 3);
1799 }
1800
1801 #[test]
1802 fn test_build_server_config_header_with_special_chars() {
1803 let (_, config) = build_server_config(
1805 http_transport(
1806 "https://api.example.com",
1807 vec!["X-Custom=value=with=equals", "X-Query=a=b&c=d"],
1808 ),
1809 None,
1810 None,
1811 )
1812 .unwrap();
1813
1814 assert_eq!(
1815 config.headers().get("X-Custom"),
1816 Some(&"value=with=equals".to_string())
1817 );
1818 assert_eq!(
1819 config.headers().get("X-Query"),
1820 Some(&"a=b&c=d".to_string())
1821 );
1822 }
1823
1824 #[test]
1825 fn test_build_server_config_sse_with_headers() {
1826 let (id, config) = build_server_config(
1827 sse_transport(
1828 "https://sse.example.com/events",
1829 vec!["Authorization=Bearer xyz"],
1830 ),
1831 None,
1832 None,
1833 )
1834 .unwrap();
1835
1836 assert_eq!(id.as_str(), "sse-example-com-events");
1837 assert_eq!(config.url(), Some("https://sse.example.com/events"));
1838 assert_eq!(
1839 config.headers().get("Authorization"),
1840 Some(&"Bearer xyz".to_string())
1841 );
1842 }
1843
1844 #[test]
1845 fn test_build_server_config_empty_value_in_env() {
1846 let (_, config) = build_server_config(
1848 stdio_transport("server", vec![], vec!["EMPTY="], None),
1849 None,
1850 None,
1851 )
1852 .unwrap();
1853
1854 assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
1855 }
1856
1857 #[test]
1858 fn test_build_server_config_empty_value_in_header() {
1859 let (_, config) = build_server_config(
1861 http_transport("https://example.com", vec!["X-Empty="]),
1862 None,
1863 None,
1864 )
1865 .unwrap();
1866
1867 assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
1868 }
1869
1870 #[test]
1871 fn test_build_server_config_complex_docker_scenario() {
1872 let (id, config) = build_server_config(
1873 stdio_transport(
1874 "docker",
1875 vec!["run", "-i", "--rm", "--network=host", "my-image:latest"],
1876 vec!["API_TOKEN=secret123", "LOG_LEVEL=debug"],
1877 Some("/app/workdir"),
1878 ),
1879 None,
1880 None,
1881 )
1882 .unwrap();
1883
1884 assert_eq!(id.as_str(), "docker");
1885 assert_eq!(config.command(), Some("docker"));
1886 assert_eq!(
1887 config.args(),
1888 &["run", "-i", "--rm", "--network=host", "my-image:latest"]
1889 );
1890 assert_eq!(
1891 config.env().get("API_TOKEN"),
1892 Some(&"secret123".to_string())
1893 );
1894 assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
1895 assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
1896 }
1897
1898 #[test]
1899 fn test_build_server_config_empty_key_in_env() {
1900 let secret = "topsecretvalue";
1904 let env_arg = format!("={secret}");
1905 let result = build_server_config(
1906 stdio_transport("server", vec![], vec![&env_arg], None),
1907 None,
1908 None,
1909 );
1910
1911 assert!(result.is_err());
1912 let err = result.unwrap_err();
1913 assert!(format!("{err:?}").contains("key cannot be empty"));
1914 assert!(
1915 !format!("{err:?}").contains(secret),
1916 "error chain leaked the raw secret: {err:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_build_server_config_empty_key_in_header() {
1922 let secret = "topsecretheadervalue";
1923 let header_arg = format!("={secret}");
1924 let result = build_server_config(
1925 http_transport("https://example.com", vec![&header_arg]),
1926 None,
1927 None,
1928 );
1929
1930 assert!(result.is_err());
1931 let err = result.unwrap_err();
1932 assert!(format!("{err:?}").contains("key cannot be empty"));
1933 assert!(
1934 !format!("{err:?}").contains(secret),
1935 "error chain leaked the raw secret: {err:?}"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_build_server_config_timeout_override_reaches_core_validation() {
1941 let result = build_server_config(
1947 stdio_transport("docker", vec![], vec![], None),
1948 Some(0),
1949 None,
1950 );
1951
1952 let err = result.unwrap_err();
1953 let core_err = err.downcast::<mcp_execution_core::Error>().unwrap();
1954 if let mcp_execution_core::Error::ValidationError { field, reason } = core_err {
1955 assert_eq!(field, "connect_timeout");
1956 assert!(reason.contains("greater than zero"));
1957 } else {
1958 panic!("expected ValidationError for connect_timeout");
1959 }
1960 }
1961
1962 #[test]
1963 fn test_build_server_config_timeout_overrides() {
1964 let (_, config) = build_server_config(
1965 stdio_transport("server", vec![], vec![], None),
1966 Some(5),
1967 Some(90),
1968 )
1969 .unwrap();
1970
1971 assert_eq!(config.connect_timeout(), Duration::from_secs(5));
1972 assert_eq!(config.discover_timeout(), Duration::from_secs(90));
1973 }
1974
1975 #[test]
1976 fn test_build_server_config_default_timeouts_without_overrides() {
1977 let (_, config) =
1978 build_server_config(stdio_transport("server", vec![], vec![], None), None, None)
1979 .unwrap();
1980
1981 assert_eq!(config.connect_timeout(), Duration::from_secs(30));
1982 assert_eq!(config.discover_timeout(), Duration::from_secs(30));
1983 }
1984
1985 #[test]
1986 fn test_load_server_from_config_not_found() {
1987 let result = load_server_from_config("nonexistent");
1989 assert!(result.is_err());
1990 }
1991
1992 #[test]
1993 fn test_load_mcp_config_no_file() {
1994 let result = load_mcp_config_from(Path::new("/nonexistent/mcp.json"));
1996
1997 if let Err(error) = result {
1998 let error = error.to_string();
1999 assert!(
2000 error.contains("failed to read MCP config")
2001 || error.contains("failed to get home directory"),
2002 "Expected config read error or home dir error, got: {error}"
2003 );
2004 }
2005 }
2006
2007 #[test]
2008 fn test_list_mcp_servers_from_missing_file_returns_empty() {
2009 let result = list_mcp_servers_from(Path::new("/nonexistent/path/mcp.json"));
2011 assert!(result.is_ok());
2012 assert!(result.unwrap().is_empty());
2013 }
2014
2015 #[test]
2016 fn test_list_mcp_servers_from_valid_file() {
2017 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
2018 let file = create_test_config(json);
2019
2020 let servers = list_mcp_servers_from(file.path()).unwrap();
2021 assert_eq!(servers.len(), 1);
2022 assert_eq!(servers[0].0, "github");
2023 assert!(matches!(
2024 servers[0].1.transport,
2025 McpTransport::Stdio { ref command, .. } if command == "node"
2026 ));
2027 }
2028
2029 #[test]
2030 fn test_list_mcp_servers_from_empty_servers_key() {
2031 let json = r#"{"mcpServers": {}}"#;
2032 let file = create_test_config(json);
2033
2034 let servers = list_mcp_servers_from(file.path()).unwrap();
2035 assert!(servers.is_empty());
2036 }
2037
2038 #[test]
2039 fn test_load_mcp_config_without_timeout_keys_uses_defaults() {
2040 let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
2041 let file = create_test_config(json);
2042
2043 let config = load_mcp_config_from(file.path()).unwrap();
2044 let entry = &config.mcp_servers["github"];
2045 assert_eq!(entry.connect_timeout_secs, None);
2046 assert_eq!(entry.discover_timeout_secs, None);
2047
2048 let server_config = build_core_config(entry).unwrap();
2049 assert_eq!(server_config.connect_timeout(), Duration::from_secs(30));
2050 assert_eq!(server_config.discover_timeout(), Duration::from_secs(30));
2051 }
2052
2053 #[test]
2054 fn test_load_mcp_config_with_timeout_keys_reaches_server_config() {
2055 let json = r#"{"mcpServers": {"github": {
2056 "command": "node",
2057 "connectTimeoutSecs": 5,
2058 "discoverTimeoutSecs": 90
2059 }}}"#;
2060 let file = create_test_config(json);
2061
2062 let config = load_mcp_config_from(file.path()).unwrap();
2063 let entry = &config.mcp_servers["github"];
2064 assert_eq!(entry.connect_timeout_secs, Some(5));
2065 assert_eq!(entry.discover_timeout_secs, Some(90));
2066
2067 let server_config = build_core_config(entry).unwrap();
2068 assert_eq!(server_config.connect_timeout(), Duration::from_secs(5));
2069 assert_eq!(server_config.discover_timeout(), Duration::from_secs(90));
2070 }
2071
2072 #[test]
2073 fn test_build_core_config_http_entry_reaches_server_config() {
2074 let json = r#"{"mcpServers": {"remote": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}}}}"#;
2078 let file = create_test_config(json);
2079
2080 let config = load_mcp_config_from(file.path()).unwrap();
2081 let entry = &config.mcp_servers["remote"];
2082
2083 let server_config = build_core_config(entry).unwrap();
2084 assert_eq!(server_config.url(), Some("https://api.example.com/mcp"));
2085 assert_eq!(
2086 server_config.headers().get("Authorization"),
2087 Some(&"Bearer x".to_string())
2088 );
2089 }
2090
2091 #[test]
2092 fn test_build_core_config_stdio_cwd_reaches_server_config() {
2093 let json = r#"{"mcpServers": {"local": {"command": "node", "cwd": "/tmp/workdir"}}}"#;
2094 let file = create_test_config(json);
2095
2096 let config = load_mcp_config_from(file.path()).unwrap();
2097 let entry = &config.mcp_servers["local"];
2098
2099 let server_config = build_core_config(entry).unwrap();
2100 assert_eq!(server_config.cwd(), Some(&PathBuf::from("/tmp/workdir")));
2101 }
2102
2103 #[test]
2104 fn test_load_mcp_config_serde_default_on_missing_mcp_servers() {
2105 let json = r#"{"someOtherKey": "value"}"#;
2107 let file = create_test_config(json);
2108
2109 let config = load_mcp_config_from(file.path()).unwrap();
2110 assert!(
2111 config.mcp_servers.is_empty(),
2112 "missing mcpServers key must produce empty map, not error"
2113 );
2114 }
2115
2116 #[test]
2119 fn test_derive_server_id_from_url_basic() {
2120 assert_eq!(
2121 derive_server_id_from_url("https://api.githubcopilot.com/mcp/").as_str(),
2122 "api-githubcopilot-com-mcp"
2123 );
2124 assert_eq!(
2125 derive_server_id_from_url("https://example.com/sse").as_str(),
2126 "example-com-sse"
2127 );
2128 }
2129
2130 #[test]
2131 fn test_derive_server_id_from_url_strips_credentials() {
2132 let id = derive_server_id_from_url("https://user:sekrit-token@api.example.com/mcp");
2135 assert!(!id.as_str().contains("sekrit"));
2136 assert!(!id.as_str().contains("user"));
2137 assert_eq!(id.as_str(), "api-example-com-mcp");
2138 }
2139
2140 #[test]
2141 fn test_derive_server_id_from_url_rejects_path_traversal_chars() {
2142 let id = derive_server_id_from_url("https://api.example.com/../../etc/passwd");
2145 assert!(!id.as_str().contains(".."));
2146 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2147 }
2148
2149 #[test]
2150 fn test_derive_server_id_from_url_join_never_escapes_base_dir() {
2151 let base_dir = PathBuf::from("/home/user/.claude/servers");
2157 let malicious_urls = [
2158 "https://api.example.com/../../../../etc/passwd",
2159 "https://api.example.com/..%2f..%2fescape",
2160 "https://api.example.com/./././escape",
2161 ];
2162
2163 for url in malicious_urls {
2164 let id = derive_server_id_from_url(url);
2165 let joined = base_dir.join(id.as_str());
2166 assert!(
2167 joined.starts_with(&base_dir),
2168 "joining derived id {:?} (from {url:?}) onto {base_dir:?} escaped it: {joined:?}",
2169 id.as_str()
2170 );
2171 }
2172 }
2173
2174 #[test]
2175 fn test_derive_server_id_from_url_normalizes_case() {
2176 assert_eq!(
2177 derive_server_id_from_url("https://API.Example.COM/MCP").as_str(),
2178 "api-example-com-mcp"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_derive_server_id_from_url_truncates_to_length_limit() {
2184 let long_path = "a".repeat(200);
2185 let id = derive_server_id_from_url(&format!("https://example.com/{long_path}"));
2186 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2187 }
2188
2189 #[test]
2190 fn test_derive_server_id_from_url_falls_back_when_empty() {
2191 let id = derive_server_id_from_url("https://...");
2195 assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2196 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2197 }
2198
2199 #[test]
2200 fn test_derive_server_id_from_url_falls_back_on_unparseable_url() {
2201 for unparseable in ["not a url at all", "", "://", "!!!"] {
2205 let id = derive_server_id_from_url(unparseable);
2206 assert_eq!(
2207 id.as_str(),
2208 FALLBACK_SERVER_ID_SLUG,
2209 "input {unparseable:?} should fall back to the default slug"
2210 );
2211 }
2212 }
2213
2214 #[test]
2221 fn test_derive_server_id_from_url_unparseable_credential_bearing_url_leaks_nothing() {
2222 let id = derive_server_id_from_url("https://user:pass@evil.com:99999/x");
2223 assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2224 assert!(!id.as_str().contains("user"));
2225 assert!(!id.as_str().contains("pass"));
2226 assert!(!id.as_str().contains("evil"));
2227 }
2228
2229 #[test]
2230 fn test_derive_server_id_from_url_always_passes_validate_server_id() {
2231 let urls = [
2232 "https://api.githubcopilot.com/mcp/",
2233 "https://example.com/sse",
2234 "https://user:token@host.example.com/mcp?query=1#frag",
2235 "https://HOST.EXAMPLE.COM/Path/With/Mixed_Case",
2236 "https://127.0.0.1:8443/mcp",
2237 "https://example.com/../../escape",
2238 "https://",
2239 "not-a-url",
2240 ];
2241 for url in urls {
2242 let id = derive_server_id_from_url(url);
2243 assert!(
2244 mcp_execution_skill::validate_server_id(id.as_str()).is_ok(),
2245 "derived id {:?} from url {url:?} must satisfy validate_server_id",
2246 id.as_str()
2247 );
2248 }
2249 }
2250
2251 #[test]
2252 fn test_build_server_config_http_id_passes_validate_server_id() {
2253 let (id, _config) = build_server_config(
2254 http_transport("https://user:token@api.example.com/mcp/../secret", vec![]),
2255 None,
2256 None,
2257 )
2258 .unwrap();
2259
2260 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2261 assert!(!id.as_str().contains("token"));
2262 }
2263
2264 #[test]
2269 fn test_derive_server_id_from_path_or_name_rejects_parent_traversal() {
2270 let id = derive_server_id_from_path_or_name("../../../../etc/passwd");
2271 assert!(!id.as_str().contains(".."));
2272 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2273 }
2274
2275 #[test]
2276 fn test_derive_server_id_from_path_or_name_rejects_absolute_path() {
2277 let id = derive_server_id_from_path_or_name("/etc/cron.d/evil");
2278 assert!(!id.as_str().starts_with('/'));
2279 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2280 }
2281
2282 #[test]
2283 fn test_derive_server_id_from_path_or_name_join_never_escapes_base_dir() {
2284 let base_dir = PathBuf::from("/home/user/.claude/servers");
2289 let malicious_inputs = [
2290 "../../../../etc/passwd",
2291 "/etc/cron.d/evil",
2292 "/../../escape",
2293 "..",
2294 "./../escape",
2295 ];
2296
2297 for input in malicious_inputs {
2298 let id = derive_server_id_from_path_or_name(input);
2299 let joined = base_dir.join(id.as_str());
2300 assert!(
2301 joined.starts_with(&base_dir),
2302 "joining derived id {:?} (from {input:?}) onto {base_dir:?} escaped it: {joined:?}",
2303 id.as_str()
2304 );
2305 }
2306 }
2307
2308 #[test]
2309 fn test_derive_server_id_from_path_or_name_preserves_ordinary_commands() {
2310 assert_eq!(
2313 derive_server_id_from_path_or_name("github-mcp-server").as_str(),
2314 "github-mcp-server"
2315 );
2316 assert_eq!(
2317 derive_server_id_from_path_or_name("docker").as_str(),
2318 "docker"
2319 );
2320 }
2321
2322 #[test]
2323 fn test_derive_server_id_from_path_or_name_strips_path_components() {
2324 let id = derive_server_id_from_path_or_name("/usr/local/bin/mcp-server");
2327 assert_eq!(id.as_str(), "usr-local-bin-mcp-server");
2328 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2329 }
2330
2331 #[test]
2332 fn test_build_server_config_stdio_traversal_command_never_escapes_base_dir() {
2333 let base_dir = PathBuf::from("/home/user/.claude/servers");
2341 for command in ["../../../../etc/passwd", "/bin/sh"] {
2342 let (id, _config) =
2343 build_server_config(stdio_transport(command, vec![], vec![], None), None, None)
2344 .unwrap();
2345
2346 assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2347 let joined = base_dir.join(id.as_str());
2348 assert!(
2349 joined.starts_with(&base_dir),
2350 "joining derived id {:?} (from command {command:?}) escaped {base_dir:?}: {joined:?}",
2351 id.as_str()
2352 );
2353 }
2354 }
2355
2356 #[cfg(unix)]
2366 static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2367
2368 #[cfg(unix)]
2380 #[test]
2381 fn test_list_and_get_mcp_server_success_via_default_path() {
2382 let _guard = HOME_ENV_LOCK.lock().unwrap();
2383
2384 let temp = tempfile::TempDir::new().unwrap();
2385 let claude_dir = temp.path().join(".claude");
2386 std::fs::create_dir_all(&claude_dir).unwrap();
2387 std::fs::write(
2388 claude_dir.join("mcp.json"),
2389 r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#,
2390 )
2391 .unwrap();
2392
2393 let original_home = std::env::var_os("HOME");
2394 unsafe {
2397 std::env::set_var("HOME", temp.path());
2398 }
2399
2400 let list_result = list_mcp_servers();
2401 let get_result = get_mcp_server("github");
2402
2403 unsafe {
2405 match &original_home {
2406 Some(home) => std::env::set_var("HOME", home),
2407 None => std::env::remove_var("HOME"),
2408 }
2409 }
2410
2411 let servers = list_result.expect("list_mcp_servers must resolve the default path");
2412 assert_eq!(servers.len(), 1);
2413 assert_eq!(servers[0].0, "github");
2414
2415 let (id, _config, entry) =
2416 get_result.expect("get_mcp_server must find the configured server");
2417 assert_eq!(id.as_str(), "github");
2418 assert!(matches!(
2419 entry.transport,
2420 McpTransport::Stdio { ref command, .. } if command == "node"
2421 ));
2422 }
2423
2424 #[cfg(unix)]
2431 #[test]
2432 fn test_get_mcp_server_accepts_non_slug_shaped_config_key() {
2433 let _guard = HOME_ENV_LOCK.lock().unwrap();
2434
2435 let temp = tempfile::TempDir::new().unwrap();
2436 let claude_dir = temp.path().join(".claude");
2437 std::fs::create_dir_all(&claude_dir).unwrap();
2438 std::fs::write(
2439 claude_dir.join("mcp.json"),
2440 r#"{"mcpServers": {"claude_ai_Gmail": {"command": "node", "args": ["server.js"]}}}"#,
2441 )
2442 .unwrap();
2443
2444 let original_home = std::env::var_os("HOME");
2445 unsafe {
2448 std::env::set_var("HOME", temp.path());
2449 }
2450
2451 let get_result = get_mcp_server("claude_ai_Gmail");
2452
2453 unsafe {
2455 match &original_home {
2456 Some(home) => std::env::set_var("HOME", home),
2457 None => std::env::remove_var("HOME"),
2458 }
2459 }
2460
2461 let (id, _config, _entry) =
2462 get_result.expect("get_mcp_server must not reject a non-slug-shaped mcp.json key");
2463 assert_eq!(id.as_str(), "claude_ai_Gmail");
2464 }
2465
2466 #[cfg(unix)]
2471 #[test]
2472 fn test_get_mcp_server_entry_finds_entry_that_fails_config_validation() {
2473 let _guard = HOME_ENV_LOCK.lock().unwrap();
2474
2475 let temp = tempfile::TempDir::new().unwrap();
2476 let claude_dir = temp.path().join(".claude");
2477 std::fs::create_dir_all(&claude_dir).unwrap();
2478 std::fs::write(
2479 claude_dir.join("mcp.json"),
2480 r#"{"mcpServers": {"badscheme": {"type": "http", "url": "not-a-url"}}}"#,
2481 )
2482 .unwrap();
2483
2484 let original_home = std::env::var_os("HOME");
2485 unsafe {
2488 std::env::set_var("HOME", temp.path());
2489 }
2490
2491 let entry_result = get_mcp_server_entry("badscheme");
2492
2493 unsafe {
2495 match &original_home {
2496 Some(home) => std::env::set_var("HOME", home),
2497 None => std::env::remove_var("HOME"),
2498 }
2499 }
2500
2501 let (id, entry) = entry_result.expect(
2502 "get_mcp_server_entry must find the entry even though its url fails validation",
2503 );
2504 assert_eq!(id.as_str(), "badscheme");
2505 let config_err = build_core_config(&entry).expect_err(
2506 "the entry's url is expected to fail build_core_config's scheme validation",
2507 );
2508 let message = config_err.to_string();
2513 assert!(
2514 !message.to_lowercase().contains("not found"),
2515 "build_core_config's error must not read like a not-found message, got: {message}"
2516 );
2517 }
2518}