Skip to main content

mcp_execution_cli/commands/
common.rs

1//! Common utilities shared across CLI commands.
2//!
3//! Provides shared functionality for building server configurations from CLI arguments
4//! and loading MCP server definitions from `~/.claude/mcp.json`.
5
6use 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
20/// Fallback slug used when a URL sanitizes down to nothing (e.g. no host).
21const FALLBACK_SERVER_ID_SLUG: &str = "http-server";
22
23/// MCP configuration file structure (`~/.claude/mcp.json`).
24///
25/// The `mcp_servers` field defaults to an empty map so that an absent file or
26/// a file containing only `{}` does not produce a deserialization error.
27///
28/// # Examples
29///
30/// ```
31/// use mcp_execution_cli::commands::common::McpConfig;
32/// use std::collections::HashMap;
33///
34/// let config = McpConfig {
35///     mcp_servers: HashMap::new(),
36/// };
37///
38/// assert!(config.mcp_servers.is_empty());
39/// ```
40#[derive(Debug, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct McpConfig {
43    /// Map of server name → server configuration entry.
44    #[serde(default)]
45    pub mcp_servers: HashMap<String, McpServerEntry>,
46}
47
48/// Canonical in-crate representation of an MCP server's transport.
49///
50/// This is the single source of truth for "stdio vs http vs sse", shared by
51/// both the `mcp.json` config path ([`McpServerEntry`]) and the CLI-flag path
52/// ([`TransportArgs`] converts into this via `TryFrom`).
53///
54/// # Examples
55///
56/// ```
57/// use mcp_execution_cli::commands::common::McpTransport;
58/// use std::collections::HashMap;
59///
60/// let transport = McpTransport::Http {
61///     url: "https://api.example.com/mcp".to_string(),
62///     headers: HashMap::new(),
63/// };
64/// assert!(matches!(transport, McpTransport::Http { .. }));
65/// ```
66///
67/// Debug output redacts header/env values (keeping keys), `args` wholesale,
68/// and URL userinfo/query strings, mirroring `mcp_execution_core::ServerConfig`:
69///
70/// ```
71/// use mcp_execution_cli::commands::common::McpTransport;
72/// use std::collections::HashMap;
73///
74/// let transport = McpTransport::Http {
75///     url: "https://api.example.com/mcp".to_string(),
76///     headers: HashMap::from([("Authorization".to_string(), "Bearer sk-secret".to_string())]),
77/// };
78///
79/// let debug_output = format!("{transport:?}");
80/// assert!(debug_output.contains("Authorization"));
81/// assert!(!debug_output.contains("sk-secret"));
82/// ```
83#[derive(Clone)]
84pub enum McpTransport {
85    /// Stdio transport: spawn a subprocess and speak MCP over stdin/stdout.
86    Stdio {
87        /// Command to execute (binary name or absolute path).
88        command: String,
89        /// Arguments to pass to the command.
90        args: Vec<String>,
91        /// Environment variables for the server process.
92        env: HashMap<String, String>,
93        /// Working directory for the server process.
94        cwd: Option<PathBuf>,
95    },
96    /// Streamable HTTP transport.
97    Http {
98        /// Server endpoint URL.
99        url: String,
100        /// HTTP headers sent with every request (e.g. `Authorization`).
101        headers: HashMap<String, String>,
102    },
103    /// Server-Sent Events transport.
104    Sse {
105        /// Server endpoint URL.
106        url: String,
107        /// HTTP headers sent with every request (e.g. `Authorization`).
108        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/// Individual MCP server configuration entry from `mcp.json`.
142///
143/// # Examples
144///
145/// ```
146/// use mcp_execution_cli::commands::common::McpServerEntry;
147/// use std::collections::HashMap;
148///
149/// let entry = McpServerEntry {
150///     transport: mcp_execution_cli::commands::common::McpTransport::Http {
151///         url: "https://api.example.com".to_string(),
152///         headers: HashMap::new(),
153///     },
154///     connect_timeout_secs: Some(30),
155///     discover_timeout_secs: Some(30),
156/// };
157///
158/// assert_eq!(entry.connect_timeout_secs, Some(30));
159/// ```
160#[derive(Debug, Clone)]
161pub struct McpServerEntry {
162    /// The server's transport and its transport-specific settings.
163    pub transport: McpTransport,
164    /// Connection (handshake) timeout in seconds, overriding the 30-second
165    /// default when set. JSON key: `connectTimeoutSecs`.
166    pub connect_timeout_secs: Option<u64>,
167    /// Tool discovery timeout in seconds, overriding the 30-second default
168    /// when set. JSON key: `discoverTimeoutSecs`.
169    pub discover_timeout_secs: Option<u64>,
170}
171
172/// Discriminant for the optional `"type"` field in an `mcp.json` server entry.
173#[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/// Flat, all-optional serde landing zone for a raw `mcp.json` server entry.
192///
193/// Every field is optional so that stdio, http, and sse shapes can share one
194/// deserialization pass; [`McpServerEntry`]'s manual `Deserialize` converts
195/// this via `TryFrom` and raises precise, field-naming errors for
196/// cross-field violations that a derived `Deserialize` can't express (e.g.
197/// "http entries must not set `command`"). Unknown keys land in `extra`
198/// rather than hard-failing, since `~/.claude/mcp.json` is shared with other
199/// MCP clients that store keys this project doesn't model (`disabled`,
200/// `alwaysAllow`, ...).
201///
202/// This is a raw landing zone for the same `mcp.json` data [`McpTransport`]
203/// carries, so its hand-written [`Debug`] impl applies the identical
204/// redaction: `command`/`cwd` sanitized, `args` redacted wholesale, `url`
205/// stripped of userinfo/query, `env`/`headers` values redacted (keys kept),
206/// and `extra` values redacted (keys kept) since they are arbitrary JSON
207/// this project has not validated.
208#[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
228/// Debug-formats an `extra`-style unknown-fields map with keys visible and
229/// every value replaced by [`REDACTED_PLACEHOLDER`].
230///
231/// `extra` holds arbitrary JSON from a shared `mcp.json` this project
232/// doesn't validate — treated as secret-shaped for the same reason
233/// [`RedactedMapValues`] treats `env`/`headers` values that way.
234struct 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
273/// Resolves the `url`/`command` pair for an http-like (`http` or `sse`)
274/// transport tag, rejecting a `command` field and requiring `url`.
275fn 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
367/// Loads MCP configuration from the given path.
368///
369/// This is the primary, testable entry point. [`load_mcp_config`] is a thin
370/// wrapper that resolves the default `~/.claude/mcp.json` location.
371///
372/// # Errors
373///
374/// Returns an error if the file cannot be read or the JSON is malformed.
375fn 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
382/// Loads MCP configuration from `~/.claude/mcp.json`.
383///
384/// Delegates to [`load_mcp_config_from`] after resolving the default path.
385///
386/// # Errors
387///
388/// Returns an error if the home directory cannot be determined, the file
389/// cannot be read, or the JSON is malformed.
390fn 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
395/// Lists all servers defined in the given `mcp.json` file.
396///
397/// Returns an empty list when the file does not exist — the primary testable
398/// entry point for the "fresh machine" code path (no config file yet).
399///
400/// # Errors
401///
402/// Returns an error if the file exists but cannot be read or parsed.
403fn 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
411/// Lists all servers defined in `~/.claude/mcp.json`.
412///
413/// Returns an empty list when the config file does not exist so that
414/// `server list` shows a clear empty result rather than hard-failing.
415///
416/// Delegates to [`list_mcp_servers_from`] after resolving the default path.
417///
418/// Callers may print an entry's `transport` field directly:
419/// [`McpTransport`]'s `Debug` impl redacts `headers`/`env` values (keeping
420/// keys), so doing so can never leak a secret read from `mcp.json`.
421///
422/// # Errors
423///
424/// Returns an error if the home directory cannot be determined, or the config
425/// file exists but cannot be read or parsed.
426pub(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
431/// Retrieves a named server from `~/.claude/mcp.json`.
432///
433/// # Arguments
434///
435/// * `name` - Server name as defined under `mcpServers` in `mcp.json`
436///
437/// # Returns
438///
439/// A tuple of `(ServerId, ServerConfig, McpServerEntry)`:
440/// - [`ServerId`] — typed server identifier
441/// - [`ServerConfig`] — ready-to-use connection config for `Introspector`
442/// - [`McpServerEntry`] — raw entry for display purposes
443///
444/// # Errors
445///
446/// Returns an error if the config file is missing, malformed, the named
447/// server is not present, or the entry fails [`build_core_config`]'s
448/// security validation. Callers that must distinguish "entry absent" from
449/// "entry present but invalid" (e.g. to route the latter through a
450/// format-aware error path instead of a raw `Err`) should call
451/// [`get_mcp_server_entry`] and [`build_core_config`] separately instead.
452///
453/// Deliberately does *not* validate `name` against
454/// [`validate_server_id`](mcp_execution_skill::validate_server_id): this
455/// function is shared by `introspect` and `server list`/`server show`, which
456/// have no need for `name` to be a filesystem-safe slug — only `generate`
457/// turns it into a directory name, so that check lives at `generate`'s own
458/// sink (`resolve_server_dir_name`) instead. Enforcing the stricter
459/// `[a-z0-9-]` charset here would hard-fail `introspect --from-config` for
460/// entirely legitimate `mcp.json` keys that aren't already in that charset
461/// (e.g. `claude_ai_Gmail`). [`ServerId::new`]'s own baseline invariant
462/// (non-empty, no `..`/path separator) still applies unconditionally, since
463/// it's now enforced at construction rather than being an opt-in check.
464pub(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
470/// Looks up a named server's raw [`McpServerEntry`] in `~/.claude/mcp.json`, without running
471/// `build_core_config`'s security validation.
472///
473/// Split out from `get_mcp_server` so callers whose own error handling depends on knowing
474/// whether the entry is present at all — regardless of whether it would later pass validation —
475/// can perform that check first. `server info`/`server validate` need this: previously, calling
476/// `get_mcp_server` made an entry present but failing URL-scheme (or other) validation
477/// indistinguishable from an entry that does not exist at all, since both surfaced through the
478/// same `with_context` "not found" wrapping.
479///
480/// # Errors
481///
482/// Returns an error if the config file is missing or malformed, or the named server is not
483/// present.
484pub(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
504/// Loads server configuration from `~/.claude/mcp.json` by server name.
505///
506/// Convenience wrapper around the crate-internal server lookup that drops
507/// the raw entry.
508///
509/// # Arguments
510///
511/// * `name` - Server name from `mcp.json` (e.g., `"github"`)
512///
513/// # Errors
514///
515/// Returns an error if the config file is missing, malformed, or the server
516/// name is not present.
517pub(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
522/// Applies transport-specific settings onto a fresh [`ServerConfig`] builder.
523///
524/// The single place where [`ServerConfig::builder()`] is invoked; both the
525/// `mcp.json` path ([`build_core_config`]) and the CLI-flag path
526/// ([`build_server_config`]) funnel through this.
527fn 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
564/// Builds a core [`ServerConfig`] from an [`McpServerEntry`].
565///
566/// `pub(crate)` rather than private: `commands::server`'s `list_servers` also
567/// needs it, to build the same `(ServerId, ServerConfig)` pair
568/// [`get_mcp_server`] builds internally, without re-reading `mcp.json` for
569/// every entry it already has in hand.
570///
571/// # Errors
572///
573/// Returns an error if the entry fails [`ServerConfigBuilder::build`]'s
574/// security validation (e.g. a shell metacharacter or forbidden environment
575/// variable in a hand-edited `mcp.json`).
576pub(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
590/// Parses a single `KEY=VALUE` CLI argument (used for `--env` and `--header`).
591///
592/// Security: `s` routinely carries secrets (tokens, API keys) in the value
593/// portion, so it must never be echoed into an error message verbatim —
594/// mirrors the discipline in `mcp_execution_core::command::validate_header_value_string`.
595/// Every error here is a `CoreError::InvalidArgument` (rather than a bare
596/// anyhow string) so it classifies as `ExitCode::INVALID_INPUT` downstream
597/// in `runner::classify_exit_code`.
598fn parse_key_value(s: &str, kind: &str) -> Result<(String, String)> {
599    // No `=` at all: the whole string could itself be the secret with no
600    // discernible key, so it is never echoed, not even its length (which
601    // would narrow the secret's type/format for free in CI logs).
602    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    // A real header/env key never legitimately contains whitespace, `:`, or
615    // control characters. Their presence is the signature of the `=` having
616    // matched somewhere inside the value instead of acting as the separator
617    // — e.g. a header written `Name: Value` by mistake, where the value
618    // happens to contain `=` (base64 padding, a JWT). Reject without echoing
619    // `key`, since in that scenario it *is* the secret.
620    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/// CLI-flag mirror of [`McpTransport`], holding the raw, unvalidated
635/// `Option`/`Vec<String>` values clap hands back.
636///
637/// Every variant is a legal state by construction — there is no all-`None`
638/// or "both http and sse" shape to represent, unlike the flat flag surface
639/// this type is built from. In the real CLI path, values come from
640/// [`TryFrom<ServerFlags> for ServerSource`](crate::cli::ServerFlags), the
641/// single place "exactly one transport selected" is enforced (backed by
642/// clap's `server_source` argument group at parse time); since this type and
643/// its fields are `pub`, a direct caller can also construct one by hand
644/// (e.g. as a library), which is exactly why every variant already being
645/// well-formed matters. `TryFrom<TransportArgs> for McpTransport` does the
646/// `KEY=VALUE` parsing for environment variables and headers.
647///
648/// # Examples
649///
650/// ```
651/// use mcp_execution_cli::commands::common::TransportArgs;
652///
653/// let transport = TransportArgs::Stdio {
654///     command: "github-mcp-server".to_string(),
655///     args: vec!["stdio".to_string()],
656///     env: vec![],
657///     cwd: None,
658/// };
659/// assert!(matches!(transport, TransportArgs::Stdio { .. }));
660/// ```
661///
662/// Debug output redacts `env`/`headers` entries wholesale, not just a value
663/// half — these are raw, unparsed `KEY=VALUE` strings, and per
664/// `parse_key_value`'s own doc comment the whole string may be the secret
665/// with no discernible key:
666///
667/// ```
668/// use mcp_execution_cli::commands::common::TransportArgs;
669///
670/// let transport = TransportArgs::Http {
671///     url: "https://api.example.com/mcp".to_string(),
672///     headers: vec!["Authorization=Bearer sk-secret".to_string()],
673/// };
674///
675/// let debug_output = format!("{transport:?}");
676/// assert!(!debug_output.contains("sk-secret"));
677/// assert!(!debug_output.contains("Authorization"));
678/// assert!(debug_output.contains("<redacted>"));
679/// ```
680#[derive(Clone)]
681pub enum TransportArgs {
682    /// Stdio transport (default): raw CLI flags.
683    Stdio {
684        /// Command to execute (binary name or path).
685        command: String,
686        /// Arguments to pass to the command.
687        args: Vec<String>,
688        /// Environment variables in `KEY=VALUE` format.
689        env: Vec<String>,
690        /// Working directory for the server process.
691        cwd: Option<String>,
692    },
693    /// HTTP transport: raw CLI flags.
694    Http {
695        /// Server endpoint URL.
696        url: String,
697        /// HTTP headers in `KEY=VALUE` format.
698        headers: Vec<String>,
699    },
700    /// SSE transport: raw CLI flags.
701    Sse {
702        /// Server endpoint URL.
703        url: String,
704        /// HTTP headers in `KEY=VALUE` format.
705        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
782/// Builds `ServerConfig` from CLI transport arguments.
783///
784/// # Arguments
785///
786/// * `transport` - The selected transport and its raw CLI flags, from a
787///   [`ServerSource::Flags`] arm.
788/// * `connect_timeout_secs` - Connection (handshake) timeout override, in
789///   seconds. Same semantics as `mcp.json`'s `connectTimeoutSecs`: must be
790///   greater than zero and at most 600 seconds, enforced by
791///   [`ServerConfigBuilder::build`](mcp_execution_core::ServerConfigBuilder::build)
792///   at construction time.
793/// * `discover_timeout_secs` - Tool discovery timeout override, in seconds.
794///   Same semantics as `mcp.json`'s `discoverTimeoutSecs`.
795///
796/// # Errors
797///
798/// Returns an error if environment variables or headers are not in
799/// `KEY=VALUE` format, or if the resulting [`ServerConfig`] fails security
800/// validation (shell metacharacters, forbidden environment variables,
801/// invalid URL scheme, unsafe headers, or out-of-bounds timeouts).
802pub(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/// Fully-resolved "how do I reach this server" selection for `introspect` and
828/// `generate`.
829///
830/// The output of [`TryFrom<ServerFlags> for
831/// ServerSource`](crate::cli::ServerFlags), converted from clap's parsed
832/// argv once the `server_source` argument group has already guaranteed
833/// exactly one selector was set. Unlike the former `RawServerArgs` landing
834/// zone, every value of this type is a legal state: `--from-config` and the
835/// timeout overrides are folded into the same enum because they belong to
836/// the same exclusivity group (a `from_config` selection can never carry a
837/// meaningless `connect_timeout_secs`/`discover_timeout_secs` override, since
838/// those live on the `Flags` arm only).
839///
840/// # Examples
841///
842/// ```
843/// use mcp_execution_cli::commands::common::{ServerSource, TransportArgs};
844///
845/// let source = ServerSource::Flags {
846///     transport: TransportArgs::Stdio {
847///         command: "github-mcp-server".to_string(),
848///         args: vec!["stdio".to_string()],
849///         env: vec![],
850///         cwd: None,
851///     },
852///     connect_timeout_secs: None,
853///     discover_timeout_secs: None,
854/// };
855///
856/// assert!(matches!(source, ServerSource::Flags { .. }));
857/// ```
858///
859/// Debug output redacts via [`TransportArgs`]'s own redacting `Debug` impl:
860///
861/// ```
862/// use mcp_execution_cli::commands::common::{ServerSource, TransportArgs};
863///
864/// let source = ServerSource::Flags {
865///     transport: TransportArgs::Http {
866///         url: "https://api.example.com/mcp".to_string(),
867///         headers: vec!["Authorization=Bearer sk-secret".to_string()],
868///     },
869///     connect_timeout_secs: None,
870///     discover_timeout_secs: None,
871/// };
872///
873/// let debug_output = format!("{source:?}");
874/// assert!(!debug_output.contains("sk-secret"));
875/// ```
876#[derive(Debug, Clone)]
877pub enum ServerSource {
878    /// Load server configuration from `~/.claude/mcp.json` by name.
879    Config {
880        /// Server name as defined under `mcpServers` in `mcp.json`.
881        name: String,
882    },
883    /// Build server configuration directly from CLI transport flags.
884    Flags {
885        /// Selected transport and its raw CLI flags.
886        transport: TransportArgs,
887        /// Connection (handshake) timeout override, in seconds.
888        connect_timeout_secs: Option<u64>,
889        /// Tool discovery timeout override, in seconds.
890        discover_timeout_secs: Option<u64>,
891    },
892}
893
894/// Resolves a server's [`ServerId`] and [`ServerConfig`] from an
895/// already-validated [`ServerSource`], either loading `~/.claude/mcp.json`
896/// (`ServerSource::Config`) or building directly from CLI transport flags
897/// (`ServerSource::Flags`).
898///
899/// The single place `generate` and `introspect` share for this "config file
900/// vs. CLI flags" branch, since both commands accept the identical flag
901/// surface.
902///
903/// # Errors
904///
905/// Returns an error if `source` is `Config` and the named server is missing
906/// from the config file or the file is malformed, or if `source` is `Flags`
907/// and the resulting [`ServerConfig`] fails security validation.
908pub(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
922/// Lowercases `input`, collapses every run of characters outside
923/// `[a-z0-9-]` to a single `-`, and trims leading/trailing `-`, producing a
924/// filesystem- and `validate_server_id`-safe [`ServerId`] slug.
925///
926/// Shared by [`derive_server_id_from_url`] (applied to a URL's host+path) and
927/// [`derive_server_id_from_path_or_name`] (applied directly to a stdio
928/// command or `--name` override). Since path separators (`/`, `\`) and `.`
929/// are outside the whitelist, they can never survive into the slug — a
930/// leading `/` or a `..` segment is dropped entirely rather than preserved,
931/// which is what makes this safe to use directly on untrusted filesystem-path-shaped
932/// input. Falls back to [`FALLBACK_SERVER_ID_SLUG`] if the result would
933/// otherwise be empty, and truncates to `MAX_SERVER_ID_LENGTH`.
934fn 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    // The whitelist loop above only ever produces `[a-z0-9-]` characters (or the constant
950    // fallback), so the result is always non-empty and free of `..`/path separators — always a
951    // valid `ServerId` per `ServerId::new`'s invariant.
952    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
960/// Derives a filesystem- and `validate_server_id`-safe [`ServerId`] slug from
961/// an Http/Sse transport URL.
962///
963/// Using the raw URL as the id (the previous behavior) is unsafe once Http/Sse
964/// configs can actually reach `generate`: the id flows into a directory name
965/// under `~/.claude/servers/{id}/` and into generated `tool.ts` literals, so a
966/// raw URL there breaks `mcp_execution_skill::validate_server_id`'s
967/// lowercase/digit/hyphen requirement, can smuggle `..` path segments through
968/// `PathBuf::join`, and — if the URL carries `user:token@host` userinfo —
969/// leaks the credential into a directory name and generated source.
970///
971/// Only `host` and `path` are used (never `userinfo`, so credentials are
972/// structurally excluded) before delegating to [`slugify`].
973fn derive_server_id_from_url(url: &str) -> ServerId {
974    // On parse failure, fall through to the empty-slug case below rather than
975    // sanitizing the raw string: a URL that failed to parse is about to be
976    // rejected by `validate_url_scheme`/the connection attempt anyway, and
977    // preserving any part of it here would defeat the credential-exclusion
978    // guarantee above for inputs like `https://user:pass@evil.com:99999/x`
979    // (a mistyped port is a realistic `Url::parse` failure, not just an
980    // adversarial one).
981    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
989/// Derives a filesystem- and `validate_server_id`-safe [`ServerId`] slug from
990/// a stdio transport command or a `--name` override.
991///
992/// `command`/`name` are attacker-influenced (a CLI argument, or free text an
993/// operator might paste from a shared script) and — unlike an Http/Sse URL —
994/// commonly *are* legitimate filesystem paths (e.g. `./bin/my-server` or
995/// `/usr/local/bin/mcp-server`). Constructing `ServerId` directly from them
996/// (the previous behavior) is unsafe because the id flows unmodified into a
997/// directory name under `~/.claude/servers/{id}/`
998/// (`base_dir.join(&server_dir_name)`): a leading `/` makes `PathBuf::join`
999/// discard `base_dir` entirely, and `..` segments walk back out of it.
1000/// Delegates to [`slugify`], which strips path separators and `..` by
1001/// construction.
1002pub(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    /// Creates a temporary mcp.json file for testing.
1012    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        // mcp_servers defaults to empty map when key is absent
1060        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        // Server with only command (args and env should default), no "type" key
1070        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    // ── mixed stdio/http/sse configs (#210) ──
1119
1120    #[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        // "type" is optional: a bare `url` key alone resolves to Http.
1151        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        // anyhow's `Display` only prints the outermost context; the field
1169        // name lives in the wrapped serde_json error, so inspect the chain.
1170        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        // Unrecognized keys (owned by other MCP clients sharing the file,
1208        // e.g. Claude Code's "disabled") must warn, not fail the whole file.
1209        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        // Regression test for #190: a malformed `--env` value with no `=` is
1317        // itself indistinguishable from a raw secret and must never be
1318        // echoed — checked against the `{:?}` chain, since that's what
1319        // `runner::execute_command` actually prints to stderr.
1320        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        // Regression test for #190: same guarantee for `--header` values,
1339        // which routinely carry bearer tokens / API keys.
1340        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        // Regression test for #229: `McpTransport::Http`/`Sse` carry real
1359        // bearer tokens read from `~/.claude/mcp.json`; the plain derived
1360        // `Debug` used to echo them verbatim. Asserting on the bare
1361        // `sk-...` substring (not just the exact original string) catches
1362        // an impl that truncates instead of replacing the value.
1363        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        // Regression test for #229/M1: the `Sse` arm is copy-pasted from
1380        // `Http` and had no dedicated coverage.
1381        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        // `McpServerEntry` derives `Debug`, so it must inherit the
1414        // redaction through `McpTransport`'s custom impl rather than
1415        // needing its own.
1416        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        // `ServerSource` derives `Debug`, so it must inherit the redaction
1436        // through `TransportArgs`'s custom impl rather than needing its own —
1437        // same pattern as `McpServerEntry` inheriting from `McpTransport`.
1438        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        // Regression test for #229/S1: `TransportArgs` is the CLI-flag
1458        // mirror of `McpTransport` and holds raw, unparsed `KEY=VALUE`
1459        // strings; its derived `Debug` used to echo them verbatim.
1460        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        // Keys stay visible for debugging.
1576        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        // Regression test for #190/S1: a header written with the conventional
1584        // `Name: Value` syntax (colon) instead of `Name=Value`, where the
1585        // value contains `=` (e.g. base64 padding), previously put the whole
1586        // secret into the "key" slot. That key then reached
1587        // `mcp_execution_core::command::validate_header_name_string`, whose
1588        // error message assumes header names are never secret and echoes
1589        // them verbatim — leaking the credential one function downstream of
1590        // the original fix.
1591        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        // Regression test for #195/S3: malformed `--env`/`--header` values are
1613        // the most common invalid-input path for `introspect`/`generate`. The
1614        // error must carry a `CoreError::InvalidArgument` so
1615        // `runner::classify_exit_code` maps it to `ExitCode::INVALID_INPUT`
1616        // instead of silently falling through to the generic `ExitCode::ERROR`.
1617        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        // Test environment variable values containing equals signs
1653        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        // Test header values containing equals signs
1726        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        // Test environment variable with empty value after equals
1769        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        // Test header with empty value after equals
1782        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        // Regression test for #190: the pre-fix message echoed the raw `s`
1823        // (e.g. "=secretvalue"), leaking the value even though the key was
1824        // reported empty.
1825        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        // The manual CLI-flag path must fail identically to the mcp.json path:
1864        // both end up calling the same `ServerConfigBuilder::build`, so a zero
1865        // override must trip the same `connect_timeout` ValidationError — now
1866        // surfaced directly by `build_server_config` itself, since
1867        // `ServerConfig` can no longer be constructed unvalidated (#177).
1868        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        // Should fail because either config doesn't exist or server not in it
1910        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        // Should fail gracefully when config file doesn't exist
1917        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        // GAP-1: the primary UX fix for #81 — missing config → empty list, not error.
1932        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        // The mcp.json -> ServerConfig path (what #210 is literally about),
1997        // as opposed to the CLI-flag path already covered by
1998        // `test_build_server_config_http`.
1999        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        // When mcp.json has no mcpServers key, should deserialize to empty map
2028        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    // ── derive_server_id_from_url (review S1: raw-URL server ids are unsafe) ──
2039
2040    #[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        // Userinfo (credentials) must never end up in the derived id: it flows
2055        // into a directory name and generated tool.ts source.
2056        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        // `..` segments must not survive into the id (which is later joined
2065        // into a filesystem path via PathBuf::join).
2066        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        // Literal reproduction of how `generate.rs` uses the id: joined onto
2074        // a base directory. Since the sanitized slug can only ever contain
2075        // `[a-z0-9-]`, `PathBuf::join` can never interpret a component of it
2076        // as `..` or an absolute-path override, regardless of what path
2077        // segments were present in the original URL.
2078        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        // `Url::parse` accepts "..." as a (degenerate but valid) host, so
2114        // this genuinely exercises the "parsed OK, but sanitizes to nothing"
2115        // path, not the parse-failure path covered by the test below.
2116        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        // On a `Url::parse` failure the raw input is discarded entirely
2124        // (never sanitized-and-reused) — every unparseable URL maps to the
2125        // same fixed fallback slug, regardless of its content.
2126        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    /// Regression test for the credential leak the second review round found:
2137    /// a URL with a mistyped port (a realistic user typo, not an attack) is a
2138    /// `Url::parse` failure. Before the fix, the fallback sanitized the raw
2139    /// string instead of discarding it, so `user`/`pass` survived into the id
2140    /// — which is logged via `info!("Introspecting server: {}", ..)` before
2141    /// `validate_server_config` ever gets a chance to reject the URL.
2142    #[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    // ── derive_server_id_from_path_or_name / issue #311 (stdio command and
2187    // `--name` override are also joined onto a filesystem base directory and
2188    // must be sanitized the same way `derive_server_id_from_url` already is) ──
2189
2190    #[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        // Literal reproduction of how `generate.rs` uses the id: joined onto
2207        // a base directory via `PathBuf::join`, which discards the base
2208        // entirely if the joined component is absolute. Since the sanitized
2209        // slug can only ever contain `[a-z0-9-]`, that can never happen.
2210        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        // Ordinary stdio commands (already lowercase alnum-hyphen) must be
2233        // unaffected by sanitization.
2234        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        // A legitimate absolute/relative binary path still produces a safe,
2247        // single-segment id rather than being rejected outright.
2248        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        // Regression test for #311: a stdio `command` used to flow straight
2256        // into `ServerId::new` unsanitized, then into a directory name under
2257        // `~/.claude/servers/{id}/`. Covers both a relative command
2258        // containing `..` (skips `ServerConfigBuilder`'s absolute-path
2259        // existence check entirely) and a legitimate absolute path (which
2260        // must exist to pass that check, so `/bin/sh` is used) — both are
2261        // realistic stdio `command` shapes.
2262        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    /// Serializes tests in this module that mutate the `HOME` env var so
2279    /// they cannot race each other when run in the same process (relevant
2280    /// under plain `cargo test`, which runs a crate's tests in one process;
2281    /// the mandated `cargo nextest run` isolates every test in its own
2282    /// process, so this lock is a safety net for the unmandated runner, not
2283    /// a requirement of the mandated one). A separate static from
2284    /// `server.rs`'s own `HOME_ENV_LOCK` — the two don't cross-serialize —
2285    /// which is fine precisely because `cargo nextest run` never runs them
2286    /// concurrently in a shared process to begin with.
2287    #[cfg(unix)]
2288    static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2289
2290    /// Regression test for #276: `list_mcp_servers` and `get_mcp_server`
2291    /// (both downgraded to `pub(crate)`, losing their `# Examples` doctest)
2292    /// were previously only exercised via their `_from`/error-path siblings.
2293    /// This exercises the 0-arg wrappers' actual success path — resolving
2294    /// `~/.claude/mcp.json` via `dirs::home_dir()` and returning a populated,
2295    /// looked-up entry — which no other test in this crate covered.
2296    ///
2297    /// Unix-only, mirroring `server.rs`'s own `HOME`-override tests: on
2298    /// Windows, `dirs::home_dir()` resolves via the `SHGetKnownFolderPath`
2299    /// Win32 API, which reads the real OS user profile and ignores
2300    /// environment variables entirely, so no `HOME` override can redirect it.
2301    #[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        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2317        // reads or writes `HOME` while the guard is held.
2318        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        // SAFETY: see above.
2326        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    /// Regression test for #311 review S4: `get_mcp_server` must accept a
2347    /// legitimate `mcp.json` key that isn't already `[a-z0-9-]` (mixed case,
2348    /// underscores) — it is shared by `introspect`/`server`, which have no
2349    /// need for the id to be a filesystem-safe slug. Only `generate`'s own
2350    /// sink (`resolve_server_dir_name` in `generate.rs`) enforces that
2351    /// constraint, since only `generate` turns the id into a directory name.
2352    #[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        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2368        // reads or writes `HOME` while the guard is held.
2369        unsafe {
2370            std::env::set_var("HOME", temp.path());
2371        }
2372
2373        let get_result = get_mcp_server("claude_ai_Gmail");
2374
2375        // SAFETY: see above.
2376        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    /// Regression test for #305/#304: an entry present in `mcp.json` but whose `url` fails
2389    /// `build_core_config`'s scheme validation must still be found by `get_mcp_server_entry` —
2390    /// distinct from `get_mcp_server`, which eagerly runs that validation and previously made
2391    /// this case indistinguishable from a genuinely absent entry to its callers.
2392    #[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        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2408        // reads or writes `HOME` while the guard is held.
2409        unsafe {
2410            std::env::set_var("HOME", temp.path());
2411        }
2412
2413        let entry_result = get_mcp_server_entry("badscheme");
2414
2415        // SAFETY: see above.
2416        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        // Regression coverage for #304: `validate_command` interpolates this error's `Display`
2431        // into its "invalid configuration" message. It must describe the actual validation
2432        // failure, not read like the unrelated "not found" message reserved for a genuinely
2433        // absent entry.
2434        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}