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/// `pub(crate)` (rather than private) so callers that must distinguish "config file itself is
387/// missing/malformed" from "the config loaded but doesn't have this server" — `server
388/// validate` (#479) — can call this and [`lookup_server_entry`] separately instead of treating
389/// both failure modes as the same generic error the way [`get_mcp_server_entry`] does.
390///
391/// # Errors
392///
393/// Returns an error if the home directory cannot be determined, the file
394/// cannot be read, or the JSON is malformed.
395pub(crate) fn load_mcp_config() -> Result<McpConfig> {
396    let home = dirs::home_dir().context("failed to get home directory")?;
397    load_mcp_config_from(&home.join(".claude").join("mcp.json"))
398}
399
400/// Lists all servers defined in the given `mcp.json` file.
401///
402/// Returns an empty list when the file does not exist — the primary testable
403/// entry point for the "fresh machine" code path (no config file yet).
404///
405/// # Errors
406///
407/// Returns an error if the file exists but cannot be read or parsed.
408fn list_mcp_servers_from(path: &Path) -> Result<Vec<(String, McpServerEntry)>> {
409    if !path.exists() {
410        return Ok(Vec::new());
411    }
412    let config = load_mcp_config_from(path)?;
413    Ok(config.mcp_servers.into_iter().collect())
414}
415
416/// Lists all servers defined in `~/.claude/mcp.json`.
417///
418/// Returns an empty list when the config file does not exist so that
419/// `server list` shows a clear empty result rather than hard-failing.
420///
421/// Delegates to [`list_mcp_servers_from`] after resolving the default path.
422///
423/// Callers may print an entry's `transport` field directly:
424/// [`McpTransport`]'s `Debug` impl redacts `headers`/`env` values (keeping
425/// keys), so doing so can never leak a secret read from `mcp.json`.
426///
427/// # Errors
428///
429/// Returns an error if the home directory cannot be determined, or the config
430/// file exists but cannot be read or parsed.
431pub(crate) fn list_mcp_servers() -> Result<Vec<(String, McpServerEntry)>> {
432    let home = dirs::home_dir().context("failed to get home directory")?;
433    list_mcp_servers_from(&home.join(".claude").join("mcp.json"))
434}
435
436/// Retrieves a named server from `~/.claude/mcp.json`.
437///
438/// # Arguments
439///
440/// * `name` - Server name as defined under `mcpServers` in `mcp.json`
441///
442/// # Returns
443///
444/// A tuple of `(ServerId, ServerConfig, McpServerEntry)`:
445/// - [`ServerId`] — typed server identifier
446/// - [`ServerConfig`] — ready-to-use connection config for `Introspector`
447/// - [`McpServerEntry`] — raw entry for display purposes
448///
449/// # Errors
450///
451/// Returns an error if the config file is missing, malformed, the named
452/// server is not present, or the entry fails [`build_core_config`]'s
453/// security validation. Callers that must distinguish "entry absent" from
454/// "entry present but invalid" (e.g. to route the latter through a
455/// format-aware error path instead of a raw `Err`) should call
456/// [`get_mcp_server_entry`] and [`build_core_config`] separately instead.
457///
458/// Deliberately does *not* validate `name` against
459/// [`validate_server_id`](mcp_execution_skill::validate_server_id): this
460/// function is shared by `introspect` and `server list`/`server show`, which
461/// have no need for `name` to be a filesystem-safe slug — only `generate`
462/// turns it into a directory name, so that check lives at `generate`'s own
463/// sink (`resolve_server_dir_name`) instead. Enforcing the stricter
464/// `[a-z0-9-]` charset here would hard-fail `introspect --from-config` for
465/// entirely legitimate `mcp.json` keys that aren't already in that charset
466/// (e.g. `claude_ai_Gmail`). [`ServerId::new`]'s own baseline invariants
467/// (non-empty, no `..`/path separator, and — since issue #433 — every
468/// character UTS #39 `Identifier_Status=Allowed`) still apply
469/// unconditionally, since they're enforced at construction rather than
470/// being opt-in. An `mcp.json` key containing a character outside that
471/// Unicode-identifier-safe set (e.g. a space) is no longer usable as a
472/// `ServerId` at all, and this function surfaces that as an ordinary
473/// `ServerId::new` error rather than a silent pass-through.
474pub(crate) fn get_mcp_server(name: &str) -> Result<(ServerId, ServerConfig, McpServerEntry)> {
475    let (server_id, entry) = get_mcp_server_entry(name)?;
476    let server_config = build_core_config(&entry)?;
477    Ok((server_id, server_config, entry))
478}
479
480/// Looks up a named server's raw [`McpServerEntry`] in `~/.claude/mcp.json`, without running
481/// `build_core_config`'s security validation.
482///
483/// Split out from `get_mcp_server` so callers whose own error handling depends on knowing
484/// whether the entry is present at all — regardless of whether it would later pass validation —
485/// can perform that check first. `server info`/`server validate` need this: previously, calling
486/// `get_mcp_server` made an entry present but failing URL-scheme (or other) validation
487/// indistinguishable from an entry that does not exist at all, since both surfaced through the
488/// same `with_context` "not found" wrapping.
489///
490/// # Errors
491///
492/// Returns an error if the config file is missing or malformed, or the named server is not
493/// present.
494pub(crate) fn get_mcp_server_entry(name: &str) -> Result<(ServerId, McpServerEntry)> {
495    let config = load_mcp_config()?;
496    lookup_server_entry(&config, name)
497}
498
499/// Looks up `name` within an already-loaded [`McpConfig`], returning its [`ServerId`] and entry.
500///
501/// Split out from [`get_mcp_server_entry`] so callers that need "config file itself is
502/// missing/malformed" and "the config loaded but doesn't have this server" to produce distinct
503/// messages — `server validate` (#479) — can call [`load_mcp_config`] and this function
504/// separately instead of collapsing both failure modes into one generic error.
505///
506/// # Errors
507///
508/// Returns an error if the named server is not present in `config`, or its name is not a valid
509/// [`ServerId`].
510pub(crate) fn lookup_server_entry(
511    config: &McpConfig,
512    name: &str,
513) -> Result<(ServerId, McpServerEntry)> {
514    let entry = config
515        .mcp_servers
516        .get(name)
517        .with_context(|| {
518            format!(
519                "server '{name}' not found in ~/.claude/mcp.json\n\
520                 Hint: ensure the server is defined in ~/.claude/mcp.json under \"mcpServers\""
521            )
522        })?
523        .clone();
524
525    let server_id = ServerId::new(name).with_context(|| {
526        format!("server '{name}' in ~/.claude/mcp.json is not a valid server id")
527    })?;
528    Ok((server_id, entry))
529}
530
531/// Loads server configuration from `~/.claude/mcp.json` by server name.
532///
533/// Convenience wrapper around the crate-internal server lookup that drops
534/// the raw entry.
535///
536/// # Arguments
537///
538/// * `name` - Server name from `mcp.json` (e.g., `"github"`)
539///
540/// # Errors
541///
542/// Returns an error if the config file is missing, malformed, or the server
543/// name is not present.
544pub(crate) fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
545    let (id, config, _) = get_mcp_server(name)?;
546    Ok((id, config))
547}
548
549/// Applies transport-specific settings onto a fresh [`ServerConfig`] builder.
550///
551/// The single place where [`ServerConfig::builder()`] is invoked; both the
552/// `mcp.json` path ([`build_core_config`]) and the CLI-flag path
553/// ([`build_server_config`]) funnel through this.
554fn builder_for_transport(transport: McpTransport) -> ServerConfigBuilder {
555    match transport {
556        McpTransport::Stdio {
557            command,
558            args,
559            env,
560            cwd,
561        } => {
562            let mut builder = ServerConfig::builder().command(command);
563            if !args.is_empty() {
564                builder = builder.args(args);
565            }
566            for (key, value) in env {
567                builder = builder.env(key, value);
568            }
569            if let Some(dir) = cwd {
570                builder = builder.cwd(dir);
571            }
572            builder
573        }
574        McpTransport::Http { url, headers } => {
575            let mut builder = ServerConfig::builder().http_transport(url);
576            for (key, value) in headers {
577                builder = builder.header(key, value);
578            }
579            builder
580        }
581        McpTransport::Sse { url, headers } => {
582            let mut builder = ServerConfig::builder().sse_transport(url);
583            for (key, value) in headers {
584                builder = builder.header(key, value);
585            }
586            builder
587        }
588    }
589}
590
591/// Builds a core [`ServerConfig`] from an [`McpServerEntry`].
592///
593/// `pub(crate)` rather than private: `commands::server`'s `list_servers` also
594/// needs it, to build the same `(ServerId, ServerConfig)` pair
595/// [`get_mcp_server`] builds internally, without re-reading `mcp.json` for
596/// every entry it already has in hand.
597///
598/// # Errors
599///
600/// Returns an error if the entry fails [`ServerConfigBuilder::build`]'s
601/// security validation (e.g. a shell metacharacter or forbidden environment
602/// variable in a hand-edited `mcp.json`).
603pub(crate) fn build_core_config(entry: &McpServerEntry) -> Result<ServerConfig> {
604    let mut builder = builder_for_transport(entry.transport.clone());
605
606    if let Some(secs) = entry.connect_timeout_secs {
607        builder = builder.connect_timeout(Duration::from_secs(secs));
608    }
609
610    if let Some(secs) = entry.discover_timeout_secs {
611        builder = builder.discover_timeout(Duration::from_secs(secs));
612    }
613
614    Ok(builder.build()?)
615}
616
617/// Parses a single `KEY=VALUE` CLI argument (used for `--env` and `--header`).
618///
619/// Security: `s` routinely carries secrets (tokens, API keys) in the value
620/// portion, so it must never be echoed into an error message verbatim —
621/// mirrors the discipline in `mcp_execution_core::command::validate_header_value_string`.
622/// Every error here is a `CoreError::InvalidArgument` (rather than a bare
623/// anyhow string) so it classifies as `ExitCode::INVALID_INPUT` downstream
624/// in `runner::classify_exit_code`.
625fn parse_key_value(s: &str, kind: &str) -> Result<(String, String)> {
626    // No `=` at all: the whole string could itself be the secret with no
627    // discernible key, so it is never echoed, not even its length (which
628    // would narrow the secret's type/format for free in CI logs).
629    let Some((key, value)) = s.split_once('=') else {
630        return Err(CoreError::InvalidArgument(format!(
631            "invalid {kind} format: no '=' separator found (expected KEY=VALUE)"
632        ))
633        .into());
634    };
635    if key.is_empty() {
636        return Err(CoreError::InvalidArgument(format!(
637            "invalid {kind} format: key cannot be empty (expected KEY=VALUE)"
638        ))
639        .into());
640    }
641    // A real header/env key never legitimately contains whitespace, `:`, or
642    // control characters. Their presence is the signature of the `=` having
643    // matched somewhere inside the value instead of acting as the separator
644    // — e.g. a header written `Name: Value` by mistake, where the value
645    // happens to contain `=` (base64 padding, a JWT). Reject without echoing
646    // `key`, since in that scenario it *is* the secret.
647    if key
648        .chars()
649        .any(|c| c.is_whitespace() || c == ':' || c.is_control())
650    {
651        return Err(CoreError::InvalidArgument(format!(
652            "invalid {kind} format: text before '=' contains characters that are never valid \
653             in a key, suggesting '=' matched inside a value rather than as the separator; \
654             refusing to echo it since it may contain a secret (expected KEY=VALUE)"
655        ))
656        .into());
657    }
658    Ok((key.to_string(), value.to_string()))
659}
660
661/// CLI-flag mirror of [`McpTransport`], holding the raw, unvalidated
662/// `Option`/`Vec<String>` values clap hands back.
663///
664/// Every variant is a legal state by construction — there is no all-`None`
665/// or "both http and sse" shape to represent, unlike the flat flag surface
666/// this type is built from. In the real CLI path, values come from
667/// [`TryFrom<ServerFlags> for ServerSource`](crate::cli::ServerFlags), the
668/// single place "exactly one transport selected" is enforced (backed by
669/// clap's `server_source` argument group at parse time); since this type and
670/// its fields are `pub`, a direct caller can also construct one by hand
671/// (e.g. as a library), which is exactly why every variant already being
672/// well-formed matters. `TryFrom<TransportArgs> for McpTransport` does the
673/// `KEY=VALUE` parsing for environment variables and headers.
674///
675/// # Examples
676///
677/// ```
678/// use mcp_execution_cli::commands::common::TransportArgs;
679///
680/// let transport = TransportArgs::Stdio {
681///     command: "github-mcp-server".to_string(),
682///     args: vec!["stdio".to_string()],
683///     env: vec![],
684///     cwd: None,
685/// };
686/// assert!(matches!(transport, TransportArgs::Stdio { .. }));
687/// ```
688///
689/// Debug output redacts `env`/`headers` entries wholesale, not just a value
690/// half — these are raw, unparsed `KEY=VALUE` strings, and per
691/// `parse_key_value`'s own doc comment the whole string may be the secret
692/// with no discernible key:
693///
694/// ```
695/// use mcp_execution_cli::commands::common::TransportArgs;
696///
697/// let transport = TransportArgs::Http {
698///     url: "https://api.example.com/mcp".to_string(),
699///     headers: vec!["Authorization=Bearer sk-secret".to_string()],
700/// };
701///
702/// let debug_output = format!("{transport:?}");
703/// assert!(!debug_output.contains("sk-secret"));
704/// assert!(!debug_output.contains("Authorization"));
705/// assert!(debug_output.contains("<redacted>"));
706/// ```
707#[derive(Clone)]
708pub enum TransportArgs {
709    /// Stdio transport (default): raw CLI flags.
710    Stdio {
711        /// Command to execute (binary name or path).
712        command: String,
713        /// Arguments to pass to the command.
714        args: Vec<String>,
715        /// Environment variables in `KEY=VALUE` format.
716        env: Vec<String>,
717        /// Working directory for the server process.
718        cwd: Option<String>,
719    },
720    /// HTTP transport: raw CLI flags.
721    Http {
722        /// Server endpoint URL.
723        url: String,
724        /// HTTP headers in `KEY=VALUE` format.
725        headers: Vec<String>,
726    },
727    /// SSE transport: raw CLI flags.
728    Sse {
729        /// Server endpoint URL.
730        url: String,
731        /// HTTP headers in `KEY=VALUE` format.
732        headers: Vec<String>,
733    },
734}
735
736impl fmt::Debug for TransportArgs {
737    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
738        match self {
739            Self::Stdio {
740                command,
741                args,
742                env,
743                cwd,
744            } => f
745                .debug_struct("Stdio")
746                .field("command", &sanitize_path_for_error(Path::new(command)))
747                .field("args", &RedactedItems(args))
748                .field("env", &RedactedItems(env))
749                .field(
750                    "cwd",
751                    &cwd.as_deref()
752                        .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
753                )
754                .finish(),
755            Self::Http { url, headers } => f
756                .debug_struct("Http")
757                .field("url", &RedactedUrl(url))
758                .field("headers", &RedactedItems(headers))
759                .finish(),
760            Self::Sse { url, headers } => f
761                .debug_struct("Sse")
762                .field("url", &RedactedUrl(url))
763                .field("headers", &RedactedItems(headers))
764                .finish(),
765        }
766    }
767}
768
769impl TryFrom<TransportArgs> for McpTransport {
770    type Error = anyhow::Error;
771
772    fn try_from(args: TransportArgs) -> Result<Self> {
773        match args {
774            TransportArgs::Stdio {
775                command,
776                args,
777                env,
778                cwd,
779            } => {
780                let env = env
781                    .iter()
782                    .map(|s| parse_key_value(s, "environment variable"))
783                    .collect::<Result<HashMap<_, _>>>()?;
784                Ok(Self::Stdio {
785                    command,
786                    args,
787                    env,
788                    cwd: cwd.map(PathBuf::from),
789                })
790            }
791            TransportArgs::Http { url, headers } => {
792                let headers = headers
793                    .iter()
794                    .map(|s| parse_key_value(s, "header"))
795                    .collect::<Result<HashMap<_, _>>>()?;
796                Ok(Self::Http { url, headers })
797            }
798            TransportArgs::Sse { url, headers } => {
799                let headers = headers
800                    .iter()
801                    .map(|s| parse_key_value(s, "header"))
802                    .collect::<Result<HashMap<_, _>>>()?;
803                Ok(Self::Sse { url, headers })
804            }
805        }
806    }
807}
808
809/// Builds `ServerConfig` from CLI transport arguments.
810///
811/// # Arguments
812///
813/// * `transport` - The selected transport and its raw CLI flags, from a
814///   [`ServerSource::Flags`] arm.
815/// * `connect_timeout_secs` - Connection (handshake) timeout override, in
816///   seconds. Same semantics as `mcp.json`'s `connectTimeoutSecs`: must be
817///   greater than zero and at most 600 seconds, enforced by
818///   [`ServerConfigBuilder::build`](mcp_execution_core::ServerConfigBuilder::build)
819///   at construction time.
820/// * `discover_timeout_secs` - Tool discovery timeout override, in seconds.
821///   Same semantics as `mcp.json`'s `discoverTimeoutSecs`.
822///
823/// # Errors
824///
825/// Returns an error if environment variables or headers are not in
826/// `KEY=VALUE` format, or if the resulting [`ServerConfig`] fails security
827/// validation (shell metacharacters, forbidden environment variables,
828/// invalid URL scheme, unsafe headers, or out-of-bounds timeouts).
829pub(crate) fn build_server_config(
830    transport: TransportArgs,
831    connect_timeout_secs: Option<u64>,
832    discover_timeout_secs: Option<u64>,
833) -> Result<(ServerId, ServerConfig)> {
834    let server_id = match &transport {
835        TransportArgs::Stdio { command, .. } => derive_server_id_from_path_or_name(command),
836        TransportArgs::Http { url, .. } | TransportArgs::Sse { url, .. } => {
837            derive_server_id_from_url(url)
838        }
839    };
840
841    let mut builder = builder_for_transport(McpTransport::try_from(transport)?);
842
843    if let Some(secs) = connect_timeout_secs {
844        builder = builder.connect_timeout(Duration::from_secs(secs));
845    }
846
847    if let Some(secs) = discover_timeout_secs {
848        builder = builder.discover_timeout(Duration::from_secs(secs));
849    }
850
851    Ok((server_id, builder.build()?))
852}
853
854/// Fully-resolved "how do I reach this server" selection for `introspect` and
855/// `generate`.
856///
857/// The output of [`TryFrom<ServerFlags> for
858/// ServerSource`](crate::cli::ServerFlags), converted from clap's parsed
859/// argv once the `server_source` argument group has already guaranteed
860/// exactly one selector was set. Unlike the former `RawServerArgs` landing
861/// zone, every value of this type is a legal state: `--from-config` and the
862/// timeout overrides are folded into the same enum because they belong to
863/// the same exclusivity group (a `from_config` selection can never carry a
864/// meaningless `connect_timeout_secs`/`discover_timeout_secs` override, since
865/// those live on the `Flags` arm only).
866///
867/// # Examples
868///
869/// ```
870/// use mcp_execution_cli::commands::common::{ServerSource, TransportArgs};
871///
872/// let source = ServerSource::Flags {
873///     transport: TransportArgs::Stdio {
874///         command: "github-mcp-server".to_string(),
875///         args: vec!["stdio".to_string()],
876///         env: vec![],
877///         cwd: None,
878///     },
879///     connect_timeout_secs: None,
880///     discover_timeout_secs: None,
881/// };
882///
883/// assert!(matches!(source, ServerSource::Flags { .. }));
884/// ```
885///
886/// Debug output redacts via [`TransportArgs`]'s own redacting `Debug` impl:
887///
888/// ```
889/// use mcp_execution_cli::commands::common::{ServerSource, TransportArgs};
890///
891/// let source = ServerSource::Flags {
892///     transport: TransportArgs::Http {
893///         url: "https://api.example.com/mcp".to_string(),
894///         headers: vec!["Authorization=Bearer sk-secret".to_string()],
895///     },
896///     connect_timeout_secs: None,
897///     discover_timeout_secs: None,
898/// };
899///
900/// let debug_output = format!("{source:?}");
901/// assert!(!debug_output.contains("sk-secret"));
902/// ```
903#[derive(Debug, Clone)]
904pub enum ServerSource {
905    /// Load server configuration from `~/.claude/mcp.json` by name.
906    Config {
907        /// Server name as defined under `mcpServers` in `mcp.json`.
908        name: String,
909    },
910    /// Build server configuration directly from CLI transport flags.
911    Flags {
912        /// Selected transport and its raw CLI flags.
913        transport: TransportArgs,
914        /// Connection (handshake) timeout override, in seconds.
915        connect_timeout_secs: Option<u64>,
916        /// Tool discovery timeout override, in seconds.
917        discover_timeout_secs: Option<u64>,
918    },
919}
920
921/// Resolves a server's [`ServerId`] and [`ServerConfig`] from an
922/// already-validated [`ServerSource`], either loading `~/.claude/mcp.json`
923/// (`ServerSource::Config`) or building directly from CLI transport flags
924/// (`ServerSource::Flags`).
925///
926/// The single place `generate` and `introspect` share for this "config file
927/// vs. CLI flags" branch, since both commands accept the identical flag
928/// surface.
929///
930/// # Errors
931///
932/// Returns an error if `source` is `Config` and the named server is missing
933/// from the config file or the file is malformed, or if `source` is `Flags`
934/// and the resulting [`ServerConfig`] fails security validation.
935pub(crate) fn resolve_server_config(source: ServerSource) -> Result<(ServerId, ServerConfig)> {
936    match source {
937        ServerSource::Config { name } => {
938            debug!("Loading server configuration from ~/.claude/mcp.json: {name}");
939            load_server_from_config(&name)
940        }
941        ServerSource::Flags {
942            transport,
943            connect_timeout_secs,
944            discover_timeout_secs,
945        } => build_server_config(transport, connect_timeout_secs, discover_timeout_secs),
946    }
947}
948
949/// Lowercases `input`, collapses every run of characters outside
950/// `[a-z0-9-]` to a single `-`, and trims leading/trailing `-`, producing a
951/// filesystem- and `validate_server_id`-safe [`ServerId`] slug.
952///
953/// Shared by [`derive_server_id_from_url`] (applied to a URL's host+path) and
954/// [`derive_server_id_from_path_or_name`] (applied directly to a stdio
955/// command or `--name` override). Since path separators (`/`, `\`) and `.`
956/// are outside the whitelist, they can never survive into the slug — a
957/// leading `/` or a `..` segment is dropped entirely rather than preserved,
958/// which is what makes this safe to use directly on untrusted filesystem-path-shaped
959/// input. Falls back to [`FALLBACK_SERVER_ID_SLUG`] if the result would
960/// otherwise be empty, and truncates to `MAX_SERVER_ID_LENGTH`.
961fn slugify(input: &str) -> ServerId {
962    let mut slug = String::with_capacity(input.len());
963    for ch in input.chars() {
964        let lower = ch.to_ascii_lowercase();
965        if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
966            slug.push(lower);
967        } else if slug.chars().next_back().is_some_and(|last| last != '-') {
968            slug.push('-');
969        }
970    }
971
972    let slug = slug.trim_matches('-');
973    let slug = &slug[..slug.len().min(MAX_SERVER_ID_LENGTH)];
974    let slug = slug.trim_end_matches('-');
975
976    // The whitelist loop above only ever produces `[a-z0-9-]` characters (or the constant
977    // fallback), so the result is always non-empty and free of `..`/path separators, and every
978    // character is ASCII alphanumeric or `-` — all UTS #39 `Identifier_Status=Allowed` — so this
979    // always satisfies `ServerId::new`'s full invariant (issue #433).
980    ServerId::new(if slug.is_empty() {
981        FALLBACK_SERVER_ID_SLUG
982    } else {
983        slug
984    })
985    .expect("slugify only ever produces a valid path-segment ServerId")
986}
987
988/// Derives a filesystem- and `validate_server_id`-safe [`ServerId`] slug from
989/// an Http/Sse transport URL.
990///
991/// Using the raw URL as the id (the previous behavior) is unsafe once Http/Sse
992/// configs can actually reach `generate`: the id flows into a directory name
993/// under `~/.claude/servers/{id}/` and into generated `tool.ts` literals, so a
994/// raw URL there breaks `mcp_execution_skill::validate_server_id`'s
995/// lowercase/digit/hyphen requirement, can smuggle `..` path segments through
996/// `PathBuf::join`, and — if the URL carries `user:token@host` userinfo —
997/// leaks the credential into a directory name and generated source.
998///
999/// Only `host` and `path` are used (never `userinfo`, so credentials are
1000/// structurally excluded) before delegating to [`slugify`].
1001fn derive_server_id_from_url(url: &str) -> ServerId {
1002    // On parse failure, fall through to the empty-slug case below rather than
1003    // sanitizing the raw string: a URL that failed to parse is about to be
1004    // rejected by `validate_url_scheme`/the connection attempt anyway, and
1005    // preserving any part of it here would defeat the credential-exclusion
1006    // guarantee above for inputs like `https://user:pass@evil.com:99999/x`
1007    // (a mistyped port is a realistic `Url::parse` failure, not just an
1008    // adversarial one).
1009    let host_and_path = Url::parse(url)
1010        .ok()
1011        .map(|parsed| format!("{}{}", parsed.host_str().unwrap_or_default(), parsed.path()))
1012        .unwrap_or_default();
1013
1014    slugify(&host_and_path)
1015}
1016
1017/// Derives a filesystem- and `validate_server_id`-safe [`ServerId`] slug from
1018/// a stdio transport command or a `--name` override.
1019///
1020/// `command`/`name` are attacker-influenced (a CLI argument, or free text an
1021/// operator might paste from a shared script) and — unlike an Http/Sse URL —
1022/// commonly *are* legitimate filesystem paths (e.g. `./bin/my-server` or
1023/// `/usr/local/bin/mcp-server`). Constructing `ServerId` directly from them
1024/// (the previous behavior) is unsafe because the id flows unmodified into a
1025/// directory name under `~/.claude/servers/{id}/`
1026/// (`base_dir.join(&server_dir_name)`): a leading `/` makes `PathBuf::join`
1027/// discard `base_dir` entirely, and `..` segments walk back out of it.
1028/// Delegates to [`slugify`], which strips path separators and `..` by
1029/// construction.
1030pub(crate) fn derive_server_id_from_path_or_name(raw: &str) -> ServerId {
1031    slugify(raw)
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use std::io::Write;
1038
1039    /// Creates a temporary mcp.json file for testing.
1040    fn create_test_config(content: &str) -> tempfile::NamedTempFile {
1041        let mut file = tempfile::NamedTempFile::new().unwrap();
1042        file.write_all(content.as_bytes()).unwrap();
1043        file.flush().unwrap();
1044        file
1045    }
1046
1047    fn stdio_transport(
1048        command: &str,
1049        args: Vec<&str>,
1050        env: Vec<&str>,
1051        cwd: Option<&str>,
1052    ) -> TransportArgs {
1053        TransportArgs::Stdio {
1054            command: command.to_string(),
1055            args: args.into_iter().map(String::from).collect(),
1056            env: env.into_iter().map(String::from).collect(),
1057            cwd: cwd.map(String::from),
1058        }
1059    }
1060
1061    fn http_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1062        TransportArgs::Http {
1063            url: url.to_string(),
1064            headers: headers.into_iter().map(String::from).collect(),
1065        }
1066    }
1067
1068    fn sse_transport(url: &str, headers: Vec<&str>) -> TransportArgs {
1069        TransportArgs::Sse {
1070            url: url.to_string(),
1071            headers: headers.into_iter().map(String::from).collect(),
1072        }
1073    }
1074
1075    #[test]
1076    fn test_load_mcp_config_from_valid() {
1077        let json = r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#;
1078        let file = create_test_config(json);
1079
1080        let config = load_mcp_config_from(file.path()).unwrap();
1081        assert_eq!(config.mcp_servers.len(), 1);
1082        assert!(config.mcp_servers.contains_key("github"));
1083    }
1084
1085    #[test]
1086    fn test_load_mcp_config_from_empty_servers() {
1087        // mcp_servers defaults to empty map when key is absent
1088        let json = r"{}";
1089        let file = create_test_config(json);
1090
1091        let config = load_mcp_config_from(file.path()).unwrap();
1092        assert!(config.mcp_servers.is_empty());
1093    }
1094
1095    #[test]
1096    fn test_load_mcp_config_from_minimal_server() {
1097        // Server with only command (args and env should default), no "type" key
1098        let json = r#"{"mcpServers": {"minimal": {"command": "python"}}}"#;
1099        let file = create_test_config(json);
1100
1101        let config = load_mcp_config_from(file.path()).unwrap();
1102        let entry = &config.mcp_servers["minimal"];
1103        match &entry.transport {
1104            McpTransport::Stdio {
1105                command, args, env, ..
1106            } => {
1107                assert_eq!(command, "python");
1108                assert!(args.is_empty());
1109                assert!(env.is_empty());
1110            }
1111            other => panic!("expected Stdio transport, got {other:?}"),
1112        }
1113    }
1114
1115    #[test]
1116    fn test_load_mcp_config_from_multiple_servers() {
1117        let json = r#"{
1118            "mcpServers": {
1119                "server1": {"command": "node", "args": ["s1.js"]},
1120                "server2": {"command": "python", "args": ["s2.py"]}
1121            }
1122        }"#;
1123        let file = create_test_config(json);
1124
1125        let config = load_mcp_config_from(file.path()).unwrap();
1126        assert_eq!(config.mcp_servers.len(), 2);
1127        assert!(config.mcp_servers.contains_key("server1"));
1128        assert!(config.mcp_servers.contains_key("server2"));
1129    }
1130
1131    #[test]
1132    fn test_load_mcp_config_from_not_found() {
1133        let result = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"));
1134        assert!(result.is_err());
1135        assert!(result.unwrap_err().to_string().contains("failed to read"));
1136    }
1137
1138    #[test]
1139    fn test_load_mcp_config_from_malformed_json() {
1140        let file = create_test_config("not valid json");
1141        let result = load_mcp_config_from(file.path());
1142        assert!(result.is_err());
1143        assert!(result.unwrap_err().to_string().contains("parse MCP config"));
1144    }
1145
1146    // ── config-load vs. name-lookup error distinction (#479) ──
1147
1148    #[test]
1149    fn test_lookup_server_entry_absent_name_errors_not_found() {
1150        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1151        let file = create_test_config(json);
1152        let config = load_mcp_config_from(file.path()).unwrap();
1153
1154        let result = lookup_server_entry(&config, "nonexistent");
1155        assert!(result.is_err());
1156        assert!(
1157            result
1158                .unwrap_err()
1159                .to_string()
1160                .contains("not found in ~/.claude/mcp.json")
1161        );
1162    }
1163
1164    #[test]
1165    fn test_config_load_and_lookup_errors_are_distinguishable() {
1166        // Regression test for #479: `server validate` used to unconditionally label every
1167        // `get_mcp_server_entry` failure as "not found", even when the real problem was that
1168        // `~/.claude/mcp.json` itself was missing or malformed. Config-load failures (missing
1169        // file, malformed JSON) and a genuinely absent server name must produce distinct,
1170        // non-overlapping messages when handled as the two separate steps `load_mcp_config`/
1171        // `lookup_server_entry` now allow.
1172        let missing = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"))
1173            .unwrap_err()
1174            .to_string();
1175        assert!(missing.contains("failed to read"));
1176        assert!(!missing.contains("not found in ~/.claude/mcp.json"));
1177
1178        let malformed_file = create_test_config("not valid json");
1179        let malformed = load_mcp_config_from(malformed_file.path())
1180            .unwrap_err()
1181            .to_string();
1182        assert!(malformed.contains("parse MCP config"));
1183        assert!(!malformed.contains("not found in ~/.claude/mcp.json"));
1184
1185        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1186        let ok_file = create_test_config(json);
1187        let config = load_mcp_config_from(ok_file.path()).unwrap();
1188        let not_found = lookup_server_entry(&config, "missing")
1189            .unwrap_err()
1190            .to_string();
1191        assert!(not_found.contains("not found in ~/.claude/mcp.json"));
1192        assert!(!not_found.contains("failed to read"));
1193        assert!(!not_found.contains("parse MCP config"));
1194    }
1195
1196    // ── mixed stdio/http/sse configs (#210) ──
1197
1198    #[test]
1199    fn test_load_mcp_config_mixed_stdio_http_sse() {
1200        let json = r#"{
1201            "mcpServers": {
1202                "local": {"command": "node", "args": ["server.js"]},
1203                "remote-http": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}},
1204                "remote-sse": {"type": "sse", "url": "https://example.com/sse"}
1205            }
1206        }"#;
1207        let file = create_test_config(json);
1208
1209        let config = load_mcp_config_from(file.path()).unwrap();
1210        assert_eq!(config.mcp_servers.len(), 3);
1211
1212        assert!(matches!(
1213            config.mcp_servers["local"].transport,
1214            McpTransport::Stdio { .. }
1215        ));
1216        assert!(matches!(
1217            config.mcp_servers["remote-http"].transport,
1218            McpTransport::Http { .. }
1219        ));
1220        assert!(matches!(
1221            config.mcp_servers["remote-sse"].transport,
1222            McpTransport::Sse { .. }
1223        ));
1224    }
1225
1226    #[test]
1227    fn test_load_mcp_config_http_entry_type_absent_but_url_present() {
1228        // "type" is optional: a bare `url` key alone resolves to Http.
1229        let json = r#"{"mcpServers": {"remote": {"url": "https://api.example.com/mcp"}}}"#;
1230        let file = create_test_config(json);
1231
1232        let config = load_mcp_config_from(file.path()).unwrap();
1233        assert!(matches!(
1234            config.mcp_servers["remote"].transport,
1235            McpTransport::Http { .. }
1236        ));
1237    }
1238
1239    #[test]
1240    fn test_load_mcp_config_http_entry_missing_url_errors_naming_url() {
1241        let json = r#"{"mcpServers": {"remote": {"type": "http"}}}"#;
1242        let file = create_test_config(json);
1243
1244        let result = load_mcp_config_from(file.path());
1245        assert!(result.is_err());
1246        // anyhow's `Display` only prints the outermost context; the field
1247        // name lives in the wrapped serde_json error, so inspect the chain.
1248        assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1249    }
1250
1251    #[test]
1252    fn test_load_mcp_config_entry_with_neither_command_nor_type_errors() {
1253        let json = r#"{"mcpServers": {"broken": {}}}"#;
1254        let file = create_test_config(json);
1255
1256        let result = load_mcp_config_from(file.path());
1257        assert!(result.is_err());
1258        let msg = format!("{:#}", result.unwrap_err());
1259        assert!(msg.contains("command"));
1260        assert!(msg.contains("url"));
1261    }
1262
1263    #[test]
1264    fn test_load_mcp_config_http_entry_with_command_errors() {
1265        let json = r#"{"mcpServers": {"bad": {"type": "http", "url": "https://x.com", "command": "node"}}}"#;
1266        let file = create_test_config(json);
1267
1268        let result = load_mcp_config_from(file.path());
1269        assert!(result.is_err());
1270        assert!(format!("{:#}", result.unwrap_err()).contains("command"));
1271    }
1272
1273    #[test]
1274    fn test_load_mcp_config_stdio_entry_with_url_errors() {
1275        let json = r#"{"mcpServers": {"bad": {"command": "node", "url": "https://x.com"}}}"#;
1276        let file = create_test_config(json);
1277
1278        let result = load_mcp_config_from(file.path());
1279        assert!(result.is_err());
1280        assert!(format!("{:#}", result.unwrap_err()).contains("url"));
1281    }
1282
1283    #[test]
1284    fn test_load_mcp_config_unknown_field_still_parses() {
1285        // Unrecognized keys (owned by other MCP clients sharing the file,
1286        // e.g. Claude Code's "disabled") must warn, not fail the whole file.
1287        let json = r#"{"mcpServers": {"github": {"command": "node", "disabled": false, "description": "x"}}}"#;
1288        let file = create_test_config(json);
1289
1290        let config = load_mcp_config_from(file.path()).unwrap();
1291        assert!(matches!(
1292            config.mcp_servers["github"].transport,
1293            McpTransport::Stdio { .. }
1294        ));
1295    }
1296
1297    #[test]
1298    fn test_build_server_config_stdio() {
1299        let (id, config) = build_server_config(
1300            stdio_transport(
1301                "github-mcp-server",
1302                vec!["stdio"],
1303                vec!["TOKEN=abc123"],
1304                None,
1305            ),
1306            None,
1307            None,
1308        )
1309        .unwrap();
1310
1311        assert_eq!(id.as_str(), "github-mcp-server");
1312        assert_eq!(config.command(), Some("github-mcp-server"));
1313        assert_eq!(config.args(), &["stdio"]);
1314        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1315    }
1316
1317    #[test]
1318    fn test_build_server_config_docker() {
1319        let (id, config) = build_server_config(
1320            stdio_transport(
1321                "docker",
1322                vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"],
1323                vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"],
1324                None,
1325            ),
1326            None,
1327            None,
1328        )
1329        .unwrap();
1330
1331        assert_eq!(id.as_str(), "docker");
1332        assert_eq!(config.command(), Some("docker"));
1333        assert_eq!(
1334            config.args(),
1335            &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
1336        );
1337        assert_eq!(
1338            config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
1339            Some(&"ghp_xxx".to_string())
1340        );
1341    }
1342
1343    #[test]
1344    fn test_build_server_config_http() {
1345        let (id, config) = build_server_config(
1346            http_transport(
1347                "https://api.githubcopilot.com/mcp/",
1348                vec!["Authorization=Bearer token123"],
1349            ),
1350            None,
1351            None,
1352        )
1353        .unwrap();
1354
1355        assert_eq!(id.as_str(), "api-githubcopilot-com-mcp");
1356        assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
1357        assert_eq!(
1358            config.headers().get("Authorization"),
1359            Some(&"Bearer token123".to_string())
1360        );
1361    }
1362
1363    #[test]
1364    fn test_build_server_config_sse() {
1365        let (id, config) = build_server_config(
1366            sse_transport("https://example.com/sse", vec!["X-API-Key=secret"]),
1367            None,
1368            None,
1369        )
1370        .unwrap();
1371
1372        assert_eq!(id.as_str(), "example-com-sse");
1373        assert_eq!(config.url(), Some("https://example.com/sse"));
1374        assert_eq!(
1375            config.headers().get("X-API-Key"),
1376            Some(&"secret".to_string())
1377        );
1378    }
1379
1380    #[test]
1381    fn test_build_server_config_with_cwd() {
1382        let (_, config) = build_server_config(
1383            stdio_transport("server", vec![], vec![], Some("/tmp/workdir")),
1384            None,
1385            None,
1386        )
1387        .unwrap();
1388
1389        assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
1390    }
1391
1392    #[test]
1393    fn test_build_server_config_invalid_env() {
1394        // Regression test for #190: a malformed `--env` value with no `=` is
1395        // itself indistinguishable from a raw secret and must never be
1396        // echoed — checked against the `{:?}` chain, since that's what
1397        // `runner::execute_command` actually prints to stderr.
1398        let secret = "ghp_verySECRETtoken1234567890abcdef";
1399        let result = build_server_config(
1400            stdio_transport("server", vec![], vec![secret], None),
1401            None,
1402            None,
1403        );
1404
1405        assert!(result.is_err());
1406        let err = result.unwrap_err();
1407        assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1408        assert!(
1409            !format!("{err:?}").contains(secret),
1410            "error chain leaked the raw secret: {err:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_build_server_config_invalid_header() {
1416        // Regression test for #190: same guarantee for `--header` values,
1417        // which routinely carry bearer tokens / API keys.
1418        let secret = "Bearer sk-live-supersecretvalue1234567890";
1419        let result = build_server_config(
1420            http_transport("https://example.com", vec![secret]),
1421            None,
1422            None,
1423        );
1424
1425        assert!(result.is_err());
1426        let err = result.unwrap_err();
1427        assert!(format!("{err:?}").contains("expected KEY=VALUE"));
1428        assert!(
1429            !format!("{err:?}").contains(secret),
1430            "error chain leaked the raw secret: {err:?}"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_mcp_transport_debug_redacts_headers_http() {
1436        // Regression test for #229: `McpTransport::Http`/`Sse` carry real
1437        // bearer tokens read from `~/.claude/mcp.json`; the plain derived
1438        // `Debug` used to echo them verbatim. Asserting on the bare
1439        // `sk-...` substring (not just the exact original string) catches
1440        // an impl that truncates instead of replacing the value.
1441        let secret_body = "sk-verySECRETtoken1234567890";
1442        let secret = format!("Bearer {secret_body}");
1443        let transport = McpTransport::Http {
1444            url: "https://api.example.com/mcp".to_string(),
1445            headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1446        };
1447
1448        let debug_output = format!("{transport:?}");
1449        assert!(debug_output.contains("Authorization"));
1450        assert!(debug_output.contains("<redacted>"));
1451        assert!(!debug_output.contains(&secret));
1452        assert!(!debug_output.contains(secret_body));
1453    }
1454
1455    #[test]
1456    fn test_mcp_transport_debug_redacts_headers_sse() {
1457        // Regression test for #229/M1: the `Sse` arm is copy-pasted from
1458        // `Http` and had no dedicated coverage.
1459        let secret_body = "sk-verySECRETtoken1234567890";
1460        let secret = format!("Bearer {secret_body}");
1461        let transport = McpTransport::Sse {
1462            url: "https://api.example.com/sse".to_string(),
1463            headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1464        };
1465
1466        let debug_output = format!("{transport:?}");
1467        assert!(debug_output.contains("Authorization"));
1468        assert!(debug_output.contains("<redacted>"));
1469        assert!(!debug_output.contains(&secret));
1470        assert!(!debug_output.contains(secret_body));
1471    }
1472
1473    #[test]
1474    fn test_mcp_transport_debug_redacts_env() {
1475        let secret_body = "ghp_verySECRETtoken1234567890abcdef";
1476        let transport = McpTransport::Stdio {
1477            command: "node".to_string(),
1478            args: vec![],
1479            env: HashMap::from([("GITHUB_TOKEN".to_string(), secret_body.to_string())]),
1480            cwd: None,
1481        };
1482
1483        let debug_output = format!("{transport:?}");
1484        assert!(debug_output.contains("GITHUB_TOKEN"));
1485        assert!(debug_output.contains("<redacted>"));
1486        assert!(!debug_output.contains(secret_body));
1487    }
1488
1489    #[test]
1490    fn test_mcp_server_entry_debug_redacts_via_transport() {
1491        // `McpServerEntry` derives `Debug`, so it must inherit the
1492        // redaction through `McpTransport`'s custom impl rather than
1493        // needing its own.
1494        let secret_body = "sk-verySECRETtoken1234567890";
1495        let secret = format!("Bearer {secret_body}");
1496        let entry = McpServerEntry {
1497            transport: McpTransport::Http {
1498                url: "https://api.example.com/mcp".to_string(),
1499                headers: HashMap::from([("Authorization".to_string(), secret.clone())]),
1500            },
1501            connect_timeout_secs: None,
1502            discover_timeout_secs: None,
1503        };
1504
1505        let debug_output = format!("{entry:?}");
1506        assert!(debug_output.contains("Authorization"));
1507        assert!(!debug_output.contains(&secret));
1508        assert!(!debug_output.contains(secret_body));
1509    }
1510
1511    #[test]
1512    fn test_server_source_debug_redacts_via_transport() {
1513        // `ServerSource` derives `Debug`, so it must inherit the redaction
1514        // through `TransportArgs`'s custom impl rather than needing its own —
1515        // same pattern as `McpServerEntry` inheriting from `McpTransport`.
1516        let secret_body = "sk-verySECRETtoken1234567890";
1517        let secret = format!("Bearer {secret_body}");
1518        let source = ServerSource::Flags {
1519            transport: TransportArgs::Http {
1520                url: "https://api.example.com/mcp".to_string(),
1521                headers: vec![format!("Authorization={secret}")],
1522            },
1523            connect_timeout_secs: None,
1524            discover_timeout_secs: None,
1525        };
1526
1527        let debug_output = format!("{source:?}");
1528        assert!(!debug_output.contains(&secret));
1529        assert!(!debug_output.contains(secret_body));
1530        assert!(debug_output.contains("<redacted>"));
1531    }
1532
1533    #[test]
1534    fn test_transport_args_debug_redacts_headers_and_env() {
1535        // Regression test for #229/S1: `TransportArgs` is the CLI-flag
1536        // mirror of `McpTransport` and holds raw, unparsed `KEY=VALUE`
1537        // strings; its derived `Debug` used to echo them verbatim.
1538        let secret_body = "sk-verySECRETtoken1234567890";
1539        let header_entry = format!("Authorization=Bearer {secret_body}");
1540        let http = TransportArgs::Http {
1541            url: "https://api.example.com/mcp".to_string(),
1542            headers: vec![header_entry.clone()],
1543        };
1544        let http_debug = format!("{http:?}");
1545        assert!(!http_debug.contains(&header_entry));
1546        assert!(!http_debug.contains(secret_body));
1547        assert!(!http_debug.contains("Authorization"));
1548        assert!(http_debug.contains("<redacted>"));
1549
1550        let sse = TransportArgs::Sse {
1551            url: "https://api.example.com/sse".to_string(),
1552            headers: vec![header_entry.clone()],
1553        };
1554        let sse_debug = format!("{sse:?}");
1555        assert!(!sse_debug.contains(&header_entry));
1556        assert!(!sse_debug.contains(secret_body));
1557
1558        let env_entry = format!("GITHUB_TOKEN={secret_body}");
1559        let stdio = TransportArgs::Stdio {
1560            command: "node".to_string(),
1561            args: vec![],
1562            env: vec![env_entry.clone()],
1563            cwd: None,
1564        };
1565        let stdio_debug = format!("{stdio:?}");
1566        assert!(!stdio_debug.contains(&env_entry));
1567        assert!(!stdio_debug.contains(secret_body));
1568        assert!(!stdio_debug.contains("GITHUB_TOKEN"));
1569        assert!(stdio_debug.contains("<redacted>"));
1570    }
1571
1572    #[test]
1573    fn test_mcp_transport_debug_redacts_args() {
1574        let secret = "sk-live-secret";
1575        let transport = McpTransport::Stdio {
1576            command: "node".to_string(),
1577            args: vec!["--api-key".to_string(), secret.to_string()],
1578            env: HashMap::new(),
1579            cwd: None,
1580        };
1581
1582        let debug_output = format!("{transport:?}");
1583        assert!(!debug_output.contains(secret));
1584    }
1585
1586    #[test]
1587    fn test_mcp_transport_debug_redacts_url_userinfo_and_query() {
1588        let secret = "hunter2";
1589        let http = McpTransport::Http {
1590            url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1591            headers: HashMap::new(),
1592        };
1593        let http_debug = format!("{http:?}");
1594        assert!(!http_debug.contains(secret));
1595        assert!(http_debug.contains("api.example.com/mcp"));
1596
1597        let sse = McpTransport::Sse {
1598            url: format!("https://user:{secret}@api.example.com/sse?token={secret}"),
1599            headers: HashMap::new(),
1600        };
1601        let sse_debug = format!("{sse:?}");
1602        assert!(!sse_debug.contains(secret));
1603        assert!(sse_debug.contains("api.example.com/sse"));
1604    }
1605
1606    #[test]
1607    fn test_transport_args_debug_redacts_args() {
1608        let secret = "sk-live-secret";
1609        let stdio = TransportArgs::Stdio {
1610            command: "node".to_string(),
1611            args: vec!["--api-key".to_string(), secret.to_string()],
1612            env: vec![],
1613            cwd: None,
1614        };
1615
1616        let debug_output = format!("{stdio:?}");
1617        assert!(!debug_output.contains(secret));
1618    }
1619
1620    #[test]
1621    fn test_transport_args_debug_redacts_url_userinfo_and_query() {
1622        let secret = "hunter2";
1623        let http = TransportArgs::Http {
1624            url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1625            headers: vec![],
1626        };
1627        let http_debug = format!("{http:?}");
1628        assert!(!http_debug.contains(secret));
1629        assert!(http_debug.contains("api.example.com/mcp"));
1630    }
1631
1632    #[test]
1633    fn test_raw_mcp_server_entry_debug_redacts_secret_shaped_fields() {
1634        let secret = "sk-live-secret";
1635        let entry = RawMcpServerEntry {
1636            transport_type: Some(TransportTag::Stdio),
1637            command: Some("node".to_string()),
1638            args: vec!["--api-key".to_string(), secret.to_string()],
1639            env: HashMap::from([("GITHUB_TOKEN".to_string(), secret.to_string())]),
1640            cwd: None,
1641            url: None,
1642            headers: HashMap::new(),
1643            connect_timeout_secs: None,
1644            discover_timeout_secs: None,
1645            extra: HashMap::from([(
1646                "someUnknownSecret".to_string(),
1647                serde_json::Value::String(secret.to_string()),
1648            )]),
1649        };
1650
1651        let debug_output = format!("{entry:?}");
1652        assert!(!debug_output.contains(secret));
1653        // Keys stay visible for debugging.
1654        assert!(debug_output.contains("GITHUB_TOKEN"));
1655        assert!(debug_output.contains("someUnknownSecret"));
1656        assert!(debug_output.contains("node"));
1657    }
1658
1659    #[test]
1660    fn test_build_server_config_header_name_value_typo_does_not_leak_secret() {
1661        // Regression test for #190/S1: a header written with the conventional
1662        // `Name: Value` syntax (colon) instead of `Name=Value`, where the
1663        // value contains `=` (e.g. base64 padding), previously put the whole
1664        // secret into the "key" slot. That key then reached
1665        // `mcp_execution_core::command::validate_header_name_string`, whose
1666        // error message assumes header names are never secret and echoes
1667        // them verbatim — leaking the credential one function downstream of
1668        // the original fix.
1669        let secret = "c2VjcmV0dG9rZW4=";
1670        let header = format!("Authorization: Bearer {secret}");
1671        let result = build_server_config(
1672            http_transport("https://example.com", vec![&header]),
1673            None,
1674            None,
1675        );
1676
1677        let err = result.unwrap_err();
1678        assert!(
1679            !format!("{err:?}").contains(secret),
1680            "error chain leaked the raw secret: {err:?}"
1681        );
1682        assert!(
1683            !format!("{err:?}").contains(&header),
1684            "error chain leaked the raw header argument: {err:?}"
1685        );
1686    }
1687
1688    #[test]
1689    fn test_build_server_config_invalid_env_classifies_as_invalid_argument() {
1690        // Regression test for #195/S3: malformed `--env`/`--header` values are
1691        // the most common invalid-input path for `introspect`/`generate`. The
1692        // error must carry a `CoreError::InvalidArgument` so
1693        // `runner::classify_exit_code` maps it to `ExitCode::INVALID_INPUT`
1694        // instead of silently falling through to the generic `ExitCode::ERROR`.
1695        let result = build_server_config(
1696            stdio_transport("server", vec![], vec!["INVALID_FORMAT"], None),
1697            None,
1698            None,
1699        );
1700
1701        let err = result.unwrap_err();
1702        assert!(matches!(
1703            err.downcast_ref::<CoreError>(),
1704            Some(CoreError::InvalidArgument(_))
1705        ));
1706    }
1707
1708    #[test]
1709    fn test_build_server_config_multiple_env_vars() {
1710        let (_, config) = build_server_config(
1711            stdio_transport(
1712                "server",
1713                vec![],
1714                vec!["TOKEN=abc123", "API_KEY=secret456", "DEBUG=true"],
1715                None,
1716            ),
1717            None,
1718            None,
1719        )
1720        .unwrap();
1721
1722        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
1723        assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
1724        assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
1725        assert_eq!(config.env().len(), 3);
1726    }
1727
1728    #[test]
1729    fn test_build_server_config_env_with_special_chars() {
1730        // Test environment variable values containing equals signs
1731        let (_, config) = build_server_config(
1732            stdio_transport(
1733                "server",
1734                vec![],
1735                vec![
1736                    "TOKEN=abc=def=123",
1737                    "URL=https://example.com?key=value",
1738                    "ENCODED=a=b=c=d",
1739                ],
1740                None,
1741            ),
1742            None,
1743            None,
1744        )
1745        .unwrap();
1746
1747        assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
1748        assert_eq!(
1749            config.env().get("URL"),
1750            Some(&"https://example.com?key=value".to_string())
1751        );
1752        assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
1753    }
1754
1755    #[test]
1756    fn test_build_server_config_empty_args_stdio() {
1757        let (id, config) = build_server_config(
1758            stdio_transport("simple-server", vec![], vec![], None),
1759            None,
1760            None,
1761        )
1762        .unwrap();
1763
1764        assert_eq!(id.as_str(), "simple-server");
1765        assert_eq!(config.command(), Some("simple-server"));
1766        assert!(config.args().is_empty());
1767        assert!(config.env().is_empty());
1768    }
1769
1770    #[test]
1771    fn test_build_server_config_http_multiple_headers() {
1772        let (_, config) = build_server_config(
1773            http_transport(
1774                "https://api.example.com",
1775                vec![
1776                    "Authorization=Bearer token123",
1777                    "X-API-Key=secret",
1778                    "Content-Type=application/json",
1779                ],
1780            ),
1781            None,
1782            None,
1783        )
1784        .unwrap();
1785
1786        assert_eq!(
1787            config.headers().get("Authorization"),
1788            Some(&"Bearer token123".to_string())
1789        );
1790        assert_eq!(
1791            config.headers().get("X-API-Key"),
1792            Some(&"secret".to_string())
1793        );
1794        assert_eq!(
1795            config.headers().get("Content-Type"),
1796            Some(&"application/json".to_string())
1797        );
1798        assert_eq!(config.headers().len(), 3);
1799    }
1800
1801    #[test]
1802    fn test_build_server_config_header_with_special_chars() {
1803        // Test header values containing equals signs
1804        let (_, config) = build_server_config(
1805            http_transport(
1806                "https://api.example.com",
1807                vec!["X-Custom=value=with=equals", "X-Query=a=b&c=d"],
1808            ),
1809            None,
1810            None,
1811        )
1812        .unwrap();
1813
1814        assert_eq!(
1815            config.headers().get("X-Custom"),
1816            Some(&"value=with=equals".to_string())
1817        );
1818        assert_eq!(
1819            config.headers().get("X-Query"),
1820            Some(&"a=b&c=d".to_string())
1821        );
1822    }
1823
1824    #[test]
1825    fn test_build_server_config_sse_with_headers() {
1826        let (id, config) = build_server_config(
1827            sse_transport(
1828                "https://sse.example.com/events",
1829                vec!["Authorization=Bearer xyz"],
1830            ),
1831            None,
1832            None,
1833        )
1834        .unwrap();
1835
1836        assert_eq!(id.as_str(), "sse-example-com-events");
1837        assert_eq!(config.url(), Some("https://sse.example.com/events"));
1838        assert_eq!(
1839            config.headers().get("Authorization"),
1840            Some(&"Bearer xyz".to_string())
1841        );
1842    }
1843
1844    #[test]
1845    fn test_build_server_config_empty_value_in_env() {
1846        // Test environment variable with empty value after equals
1847        let (_, config) = build_server_config(
1848            stdio_transport("server", vec![], vec!["EMPTY="], None),
1849            None,
1850            None,
1851        )
1852        .unwrap();
1853
1854        assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
1855    }
1856
1857    #[test]
1858    fn test_build_server_config_empty_value_in_header() {
1859        // Test header with empty value after equals
1860        let (_, config) = build_server_config(
1861            http_transport("https://example.com", vec!["X-Empty="]),
1862            None,
1863            None,
1864        )
1865        .unwrap();
1866
1867        assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
1868    }
1869
1870    #[test]
1871    fn test_build_server_config_complex_docker_scenario() {
1872        let (id, config) = build_server_config(
1873            stdio_transport(
1874                "docker",
1875                vec!["run", "-i", "--rm", "--network=host", "my-image:latest"],
1876                vec!["API_TOKEN=secret123", "LOG_LEVEL=debug"],
1877                Some("/app/workdir"),
1878            ),
1879            None,
1880            None,
1881        )
1882        .unwrap();
1883
1884        assert_eq!(id.as_str(), "docker");
1885        assert_eq!(config.command(), Some("docker"));
1886        assert_eq!(
1887            config.args(),
1888            &["run", "-i", "--rm", "--network=host", "my-image:latest"]
1889        );
1890        assert_eq!(
1891            config.env().get("API_TOKEN"),
1892            Some(&"secret123".to_string())
1893        );
1894        assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
1895        assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
1896    }
1897
1898    #[test]
1899    fn test_build_server_config_empty_key_in_env() {
1900        // Regression test for #190: the pre-fix message echoed the raw `s`
1901        // (e.g. "=secretvalue"), leaking the value even though the key was
1902        // reported empty.
1903        let secret = "topsecretvalue";
1904        let env_arg = format!("={secret}");
1905        let result = build_server_config(
1906            stdio_transport("server", vec![], vec![&env_arg], None),
1907            None,
1908            None,
1909        );
1910
1911        assert!(result.is_err());
1912        let err = result.unwrap_err();
1913        assert!(format!("{err:?}").contains("key cannot be empty"));
1914        assert!(
1915            !format!("{err:?}").contains(secret),
1916            "error chain leaked the raw secret: {err:?}"
1917        );
1918    }
1919
1920    #[test]
1921    fn test_build_server_config_empty_key_in_header() {
1922        let secret = "topsecretheadervalue";
1923        let header_arg = format!("={secret}");
1924        let result = build_server_config(
1925            http_transport("https://example.com", vec![&header_arg]),
1926            None,
1927            None,
1928        );
1929
1930        assert!(result.is_err());
1931        let err = result.unwrap_err();
1932        assert!(format!("{err:?}").contains("key cannot be empty"));
1933        assert!(
1934            !format!("{err:?}").contains(secret),
1935            "error chain leaked the raw secret: {err:?}"
1936        );
1937    }
1938
1939    #[test]
1940    fn test_build_server_config_timeout_override_reaches_core_validation() {
1941        // The manual CLI-flag path must fail identically to the mcp.json path:
1942        // both end up calling the same `ServerConfigBuilder::build`, so a zero
1943        // override must trip the same `connect_timeout` ValidationError — now
1944        // surfaced directly by `build_server_config` itself, since
1945        // `ServerConfig` can no longer be constructed unvalidated (#177).
1946        let result = build_server_config(
1947            stdio_transport("docker", vec![], vec![], None),
1948            Some(0),
1949            None,
1950        );
1951
1952        let err = result.unwrap_err();
1953        let core_err = err.downcast::<mcp_execution_core::Error>().unwrap();
1954        if let mcp_execution_core::Error::ValidationError { field, reason } = core_err {
1955            assert_eq!(field, "connect_timeout");
1956            assert!(reason.contains("greater than zero"));
1957        } else {
1958            panic!("expected ValidationError for connect_timeout");
1959        }
1960    }
1961
1962    #[test]
1963    fn test_build_server_config_timeout_overrides() {
1964        let (_, config) = build_server_config(
1965            stdio_transport("server", vec![], vec![], None),
1966            Some(5),
1967            Some(90),
1968        )
1969        .unwrap();
1970
1971        assert_eq!(config.connect_timeout(), Duration::from_secs(5));
1972        assert_eq!(config.discover_timeout(), Duration::from_secs(90));
1973    }
1974
1975    #[test]
1976    fn test_build_server_config_default_timeouts_without_overrides() {
1977        let (_, config) =
1978            build_server_config(stdio_transport("server", vec![], vec![], None), None, None)
1979                .unwrap();
1980
1981        assert_eq!(config.connect_timeout(), Duration::from_secs(30));
1982        assert_eq!(config.discover_timeout(), Duration::from_secs(30));
1983    }
1984
1985    #[test]
1986    fn test_load_server_from_config_not_found() {
1987        // Should fail because either config doesn't exist or server not in it
1988        let result = load_server_from_config("nonexistent");
1989        assert!(result.is_err());
1990    }
1991
1992    #[test]
1993    fn test_load_mcp_config_no_file() {
1994        // Should fail gracefully when config file doesn't exist
1995        let result = load_mcp_config_from(Path::new("/nonexistent/mcp.json"));
1996
1997        if let Err(error) = result {
1998            let error = error.to_string();
1999            assert!(
2000                error.contains("failed to read MCP config")
2001                    || error.contains("failed to get home directory"),
2002                "Expected config read error or home dir error, got: {error}"
2003            );
2004        }
2005    }
2006
2007    #[test]
2008    fn test_list_mcp_servers_from_missing_file_returns_empty() {
2009        // GAP-1: the primary UX fix for #81 — missing config → empty list, not error.
2010        let result = list_mcp_servers_from(Path::new("/nonexistent/path/mcp.json"));
2011        assert!(result.is_ok());
2012        assert!(result.unwrap().is_empty());
2013    }
2014
2015    #[test]
2016    fn test_list_mcp_servers_from_valid_file() {
2017        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
2018        let file = create_test_config(json);
2019
2020        let servers = list_mcp_servers_from(file.path()).unwrap();
2021        assert_eq!(servers.len(), 1);
2022        assert_eq!(servers[0].0, "github");
2023        assert!(matches!(
2024            servers[0].1.transport,
2025            McpTransport::Stdio { ref command, .. } if command == "node"
2026        ));
2027    }
2028
2029    #[test]
2030    fn test_list_mcp_servers_from_empty_servers_key() {
2031        let json = r#"{"mcpServers": {}}"#;
2032        let file = create_test_config(json);
2033
2034        let servers = list_mcp_servers_from(file.path()).unwrap();
2035        assert!(servers.is_empty());
2036    }
2037
2038    #[test]
2039    fn test_load_mcp_config_without_timeout_keys_uses_defaults() {
2040        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
2041        let file = create_test_config(json);
2042
2043        let config = load_mcp_config_from(file.path()).unwrap();
2044        let entry = &config.mcp_servers["github"];
2045        assert_eq!(entry.connect_timeout_secs, None);
2046        assert_eq!(entry.discover_timeout_secs, None);
2047
2048        let server_config = build_core_config(entry).unwrap();
2049        assert_eq!(server_config.connect_timeout(), Duration::from_secs(30));
2050        assert_eq!(server_config.discover_timeout(), Duration::from_secs(30));
2051    }
2052
2053    #[test]
2054    fn test_load_mcp_config_with_timeout_keys_reaches_server_config() {
2055        let json = r#"{"mcpServers": {"github": {
2056            "command": "node",
2057            "connectTimeoutSecs": 5,
2058            "discoverTimeoutSecs": 90
2059        }}}"#;
2060        let file = create_test_config(json);
2061
2062        let config = load_mcp_config_from(file.path()).unwrap();
2063        let entry = &config.mcp_servers["github"];
2064        assert_eq!(entry.connect_timeout_secs, Some(5));
2065        assert_eq!(entry.discover_timeout_secs, Some(90));
2066
2067        let server_config = build_core_config(entry).unwrap();
2068        assert_eq!(server_config.connect_timeout(), Duration::from_secs(5));
2069        assert_eq!(server_config.discover_timeout(), Duration::from_secs(90));
2070    }
2071
2072    #[test]
2073    fn test_build_core_config_http_entry_reaches_server_config() {
2074        // The mcp.json -> ServerConfig path (what #210 is literally about),
2075        // as opposed to the CLI-flag path already covered by
2076        // `test_build_server_config_http`.
2077        let json = r#"{"mcpServers": {"remote": {"type": "http", "url": "https://api.example.com/mcp", "headers": {"Authorization": "Bearer x"}}}}"#;
2078        let file = create_test_config(json);
2079
2080        let config = load_mcp_config_from(file.path()).unwrap();
2081        let entry = &config.mcp_servers["remote"];
2082
2083        let server_config = build_core_config(entry).unwrap();
2084        assert_eq!(server_config.url(), Some("https://api.example.com/mcp"));
2085        assert_eq!(
2086            server_config.headers().get("Authorization"),
2087            Some(&"Bearer x".to_string())
2088        );
2089    }
2090
2091    #[test]
2092    fn test_build_core_config_stdio_cwd_reaches_server_config() {
2093        let json = r#"{"mcpServers": {"local": {"command": "node", "cwd": "/tmp/workdir"}}}"#;
2094        let file = create_test_config(json);
2095
2096        let config = load_mcp_config_from(file.path()).unwrap();
2097        let entry = &config.mcp_servers["local"];
2098
2099        let server_config = build_core_config(entry).unwrap();
2100        assert_eq!(server_config.cwd(), Some(&PathBuf::from("/tmp/workdir")));
2101    }
2102
2103    #[test]
2104    fn test_load_mcp_config_serde_default_on_missing_mcp_servers() {
2105        // When mcp.json has no mcpServers key, should deserialize to empty map
2106        let json = r#"{"someOtherKey": "value"}"#;
2107        let file = create_test_config(json);
2108
2109        let config = load_mcp_config_from(file.path()).unwrap();
2110        assert!(
2111            config.mcp_servers.is_empty(),
2112            "missing mcpServers key must produce empty map, not error"
2113        );
2114    }
2115
2116    // ── derive_server_id_from_url (review S1: raw-URL server ids are unsafe) ──
2117
2118    #[test]
2119    fn test_derive_server_id_from_url_basic() {
2120        assert_eq!(
2121            derive_server_id_from_url("https://api.githubcopilot.com/mcp/").as_str(),
2122            "api-githubcopilot-com-mcp"
2123        );
2124        assert_eq!(
2125            derive_server_id_from_url("https://example.com/sse").as_str(),
2126            "example-com-sse"
2127        );
2128    }
2129
2130    #[test]
2131    fn test_derive_server_id_from_url_strips_credentials() {
2132        // Userinfo (credentials) must never end up in the derived id: it flows
2133        // into a directory name and generated tool.ts source.
2134        let id = derive_server_id_from_url("https://user:sekrit-token@api.example.com/mcp");
2135        assert!(!id.as_str().contains("sekrit"));
2136        assert!(!id.as_str().contains("user"));
2137        assert_eq!(id.as_str(), "api-example-com-mcp");
2138    }
2139
2140    #[test]
2141    fn test_derive_server_id_from_url_rejects_path_traversal_chars() {
2142        // `..` segments must not survive into the id (which is later joined
2143        // into a filesystem path via PathBuf::join).
2144        let id = derive_server_id_from_url("https://api.example.com/../../etc/passwd");
2145        assert!(!id.as_str().contains(".."));
2146        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2147    }
2148
2149    #[test]
2150    fn test_derive_server_id_from_url_join_never_escapes_base_dir() {
2151        // Literal reproduction of how `generate.rs` uses the id: joined onto
2152        // a base directory. Since the sanitized slug can only ever contain
2153        // `[a-z0-9-]`, `PathBuf::join` can never interpret a component of it
2154        // as `..` or an absolute-path override, regardless of what path
2155        // segments were present in the original URL.
2156        let base_dir = PathBuf::from("/home/user/.claude/servers");
2157        let malicious_urls = [
2158            "https://api.example.com/../../../../etc/passwd",
2159            "https://api.example.com/..%2f..%2fescape",
2160            "https://api.example.com/./././escape",
2161        ];
2162
2163        for url in malicious_urls {
2164            let id = derive_server_id_from_url(url);
2165            let joined = base_dir.join(id.as_str());
2166            assert!(
2167                joined.starts_with(&base_dir),
2168                "joining derived id {:?} (from {url:?}) onto {base_dir:?} escaped it: {joined:?}",
2169                id.as_str()
2170            );
2171        }
2172    }
2173
2174    #[test]
2175    fn test_derive_server_id_from_url_normalizes_case() {
2176        assert_eq!(
2177            derive_server_id_from_url("https://API.Example.COM/MCP").as_str(),
2178            "api-example-com-mcp"
2179        );
2180    }
2181
2182    #[test]
2183    fn test_derive_server_id_from_url_truncates_to_length_limit() {
2184        let long_path = "a".repeat(200);
2185        let id = derive_server_id_from_url(&format!("https://example.com/{long_path}"));
2186        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2187    }
2188
2189    #[test]
2190    fn test_derive_server_id_from_url_falls_back_when_empty() {
2191        // `Url::parse` accepts "..." as a (degenerate but valid) host, so
2192        // this genuinely exercises the "parsed OK, but sanitizes to nothing"
2193        // path, not the parse-failure path covered by the test below.
2194        let id = derive_server_id_from_url("https://...");
2195        assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2196        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2197    }
2198
2199    #[test]
2200    fn test_derive_server_id_from_url_falls_back_on_unparseable_url() {
2201        // On a `Url::parse` failure the raw input is discarded entirely
2202        // (never sanitized-and-reused) — every unparseable URL maps to the
2203        // same fixed fallback slug, regardless of its content.
2204        for unparseable in ["not a url at all", "", "://", "!!!"] {
2205            let id = derive_server_id_from_url(unparseable);
2206            assert_eq!(
2207                id.as_str(),
2208                FALLBACK_SERVER_ID_SLUG,
2209                "input {unparseable:?} should fall back to the default slug"
2210            );
2211        }
2212    }
2213
2214    /// Regression test for the credential leak the second review round found:
2215    /// a URL with a mistyped port (a realistic user typo, not an attack) is a
2216    /// `Url::parse` failure. Before the fix, the fallback sanitized the raw
2217    /// string instead of discarding it, so `user`/`pass` survived into the id
2218    /// — which is logged via `info!("Introspecting server: {}", ..)` before
2219    /// `validate_server_config` ever gets a chance to reject the URL.
2220    #[test]
2221    fn test_derive_server_id_from_url_unparseable_credential_bearing_url_leaks_nothing() {
2222        let id = derive_server_id_from_url("https://user:pass@evil.com:99999/x");
2223        assert_eq!(id.as_str(), FALLBACK_SERVER_ID_SLUG);
2224        assert!(!id.as_str().contains("user"));
2225        assert!(!id.as_str().contains("pass"));
2226        assert!(!id.as_str().contains("evil"));
2227    }
2228
2229    #[test]
2230    fn test_derive_server_id_from_url_always_passes_validate_server_id() {
2231        let urls = [
2232            "https://api.githubcopilot.com/mcp/",
2233            "https://example.com/sse",
2234            "https://user:token@host.example.com/mcp?query=1#frag",
2235            "https://HOST.EXAMPLE.COM/Path/With/Mixed_Case",
2236            "https://127.0.0.1:8443/mcp",
2237            "https://example.com/../../escape",
2238            "https://",
2239            "not-a-url",
2240        ];
2241        for url in urls {
2242            let id = derive_server_id_from_url(url);
2243            assert!(
2244                mcp_execution_skill::validate_server_id(id.as_str()).is_ok(),
2245                "derived id {:?} from url {url:?} must satisfy validate_server_id",
2246                id.as_str()
2247            );
2248        }
2249    }
2250
2251    #[test]
2252    fn test_build_server_config_http_id_passes_validate_server_id() {
2253        let (id, _config) = build_server_config(
2254            http_transport("https://user:token@api.example.com/mcp/../secret", vec![]),
2255            None,
2256            None,
2257        )
2258        .unwrap();
2259
2260        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2261        assert!(!id.as_str().contains("token"));
2262    }
2263
2264    // ── derive_server_id_from_path_or_name / issue #311 (stdio command and
2265    // `--name` override are also joined onto a filesystem base directory and
2266    // must be sanitized the same way `derive_server_id_from_url` already is) ──
2267
2268    #[test]
2269    fn test_derive_server_id_from_path_or_name_rejects_parent_traversal() {
2270        let id = derive_server_id_from_path_or_name("../../../../etc/passwd");
2271        assert!(!id.as_str().contains(".."));
2272        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2273    }
2274
2275    #[test]
2276    fn test_derive_server_id_from_path_or_name_rejects_absolute_path() {
2277        let id = derive_server_id_from_path_or_name("/etc/cron.d/evil");
2278        assert!(!id.as_str().starts_with('/'));
2279        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2280    }
2281
2282    #[test]
2283    fn test_derive_server_id_from_path_or_name_join_never_escapes_base_dir() {
2284        // Literal reproduction of how `generate.rs` uses the id: joined onto
2285        // a base directory via `PathBuf::join`, which discards the base
2286        // entirely if the joined component is absolute. Since the sanitized
2287        // slug can only ever contain `[a-z0-9-]`, that can never happen.
2288        let base_dir = PathBuf::from("/home/user/.claude/servers");
2289        let malicious_inputs = [
2290            "../../../../etc/passwd",
2291            "/etc/cron.d/evil",
2292            "/../../escape",
2293            "..",
2294            "./../escape",
2295        ];
2296
2297        for input in malicious_inputs {
2298            let id = derive_server_id_from_path_or_name(input);
2299            let joined = base_dir.join(id.as_str());
2300            assert!(
2301                joined.starts_with(&base_dir),
2302                "joining derived id {:?} (from {input:?}) onto {base_dir:?} escaped it: {joined:?}",
2303                id.as_str()
2304            );
2305        }
2306    }
2307
2308    #[test]
2309    fn test_derive_server_id_from_path_or_name_preserves_ordinary_commands() {
2310        // Ordinary stdio commands (already lowercase alnum-hyphen) must be
2311        // unaffected by sanitization.
2312        assert_eq!(
2313            derive_server_id_from_path_or_name("github-mcp-server").as_str(),
2314            "github-mcp-server"
2315        );
2316        assert_eq!(
2317            derive_server_id_from_path_or_name("docker").as_str(),
2318            "docker"
2319        );
2320    }
2321
2322    #[test]
2323    fn test_derive_server_id_from_path_or_name_strips_path_components() {
2324        // A legitimate absolute/relative binary path still produces a safe,
2325        // single-segment id rather than being rejected outright.
2326        let id = derive_server_id_from_path_or_name("/usr/local/bin/mcp-server");
2327        assert_eq!(id.as_str(), "usr-local-bin-mcp-server");
2328        assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2329    }
2330
2331    #[test]
2332    fn test_build_server_config_stdio_traversal_command_never_escapes_base_dir() {
2333        // Regression test for #311: a stdio `command` used to flow straight
2334        // into `ServerId::new` unsanitized, then into a directory name under
2335        // `~/.claude/servers/{id}/`. Covers both a relative command
2336        // containing `..` (skips `ServerConfigBuilder`'s absolute-path
2337        // existence check entirely) and a legitimate absolute path (which
2338        // must exist to pass that check, so `/bin/sh` is used) — both are
2339        // realistic stdio `command` shapes.
2340        let base_dir = PathBuf::from("/home/user/.claude/servers");
2341        for command in ["../../../../etc/passwd", "/bin/sh"] {
2342            let (id, _config) =
2343                build_server_config(stdio_transport(command, vec![], vec![], None), None, None)
2344                    .unwrap();
2345
2346            assert!(mcp_execution_skill::validate_server_id(id.as_str()).is_ok());
2347            let joined = base_dir.join(id.as_str());
2348            assert!(
2349                joined.starts_with(&base_dir),
2350                "joining derived id {:?} (from command {command:?}) escaped {base_dir:?}: {joined:?}",
2351                id.as_str()
2352            );
2353        }
2354    }
2355
2356    /// Serializes tests in this module that mutate the `HOME` env var so
2357    /// they cannot race each other when run in the same process (relevant
2358    /// under plain `cargo test`, which runs a crate's tests in one process;
2359    /// the mandated `cargo nextest run` isolates every test in its own
2360    /// process, so this lock is a safety net for the unmandated runner, not
2361    /// a requirement of the mandated one). A separate static from
2362    /// `server.rs`'s own `HOME_ENV_LOCK` — the two don't cross-serialize —
2363    /// which is fine precisely because `cargo nextest run` never runs them
2364    /// concurrently in a shared process to begin with.
2365    #[cfg(unix)]
2366    static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2367
2368    /// Regression test for #276: `list_mcp_servers` and `get_mcp_server`
2369    /// (both downgraded to `pub(crate)`, losing their `# Examples` doctest)
2370    /// were previously only exercised via their `_from`/error-path siblings.
2371    /// This exercises the 0-arg wrappers' actual success path — resolving
2372    /// `~/.claude/mcp.json` via `dirs::home_dir()` and returning a populated,
2373    /// looked-up entry — which no other test in this crate covered.
2374    ///
2375    /// Unix-only, mirroring `server.rs`'s own `HOME`-override tests: on
2376    /// Windows, `dirs::home_dir()` resolves via the `SHGetKnownFolderPath`
2377    /// Win32 API, which reads the real OS user profile and ignores
2378    /// environment variables entirely, so no `HOME` override can redirect it.
2379    #[cfg(unix)]
2380    #[test]
2381    fn test_list_and_get_mcp_server_success_via_default_path() {
2382        let _guard = HOME_ENV_LOCK.lock().unwrap();
2383
2384        let temp = tempfile::TempDir::new().unwrap();
2385        let claude_dir = temp.path().join(".claude");
2386        std::fs::create_dir_all(&claude_dir).unwrap();
2387        std::fs::write(
2388            claude_dir.join("mcp.json"),
2389            r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#,
2390        )
2391        .unwrap();
2392
2393        let original_home = std::env::var_os("HOME");
2394        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2395        // reads or writes `HOME` while the guard is held.
2396        unsafe {
2397            std::env::set_var("HOME", temp.path());
2398        }
2399
2400        let list_result = list_mcp_servers();
2401        let get_result = get_mcp_server("github");
2402
2403        // SAFETY: see above.
2404        unsafe {
2405            match &original_home {
2406                Some(home) => std::env::set_var("HOME", home),
2407                None => std::env::remove_var("HOME"),
2408            }
2409        }
2410
2411        let servers = list_result.expect("list_mcp_servers must resolve the default path");
2412        assert_eq!(servers.len(), 1);
2413        assert_eq!(servers[0].0, "github");
2414
2415        let (id, _config, entry) =
2416            get_result.expect("get_mcp_server must find the configured server");
2417        assert_eq!(id.as_str(), "github");
2418        assert!(matches!(
2419            entry.transport,
2420            McpTransport::Stdio { ref command, .. } if command == "node"
2421        ));
2422    }
2423
2424    /// Regression test for #311 review S4: `get_mcp_server` must accept a
2425    /// legitimate `mcp.json` key that isn't already `[a-z0-9-]` (mixed case,
2426    /// underscores) — it is shared by `introspect`/`server`, which have no
2427    /// need for the id to be a filesystem-safe slug. Only `generate`'s own
2428    /// sink (`resolve_server_dir_name` in `generate.rs`) enforces that
2429    /// constraint, since only `generate` turns the id into a directory name.
2430    #[cfg(unix)]
2431    #[test]
2432    fn test_get_mcp_server_accepts_non_slug_shaped_config_key() {
2433        let _guard = HOME_ENV_LOCK.lock().unwrap();
2434
2435        let temp = tempfile::TempDir::new().unwrap();
2436        let claude_dir = temp.path().join(".claude");
2437        std::fs::create_dir_all(&claude_dir).unwrap();
2438        std::fs::write(
2439            claude_dir.join("mcp.json"),
2440            r#"{"mcpServers": {"claude_ai_Gmail": {"command": "node", "args": ["server.js"]}}}"#,
2441        )
2442        .unwrap();
2443
2444        let original_home = std::env::var_os("HOME");
2445        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2446        // reads or writes `HOME` while the guard is held.
2447        unsafe {
2448            std::env::set_var("HOME", temp.path());
2449        }
2450
2451        let get_result = get_mcp_server("claude_ai_Gmail");
2452
2453        // SAFETY: see above.
2454        unsafe {
2455            match &original_home {
2456                Some(home) => std::env::set_var("HOME", home),
2457                None => std::env::remove_var("HOME"),
2458            }
2459        }
2460
2461        let (id, _config, _entry) =
2462            get_result.expect("get_mcp_server must not reject a non-slug-shaped mcp.json key");
2463        assert_eq!(id.as_str(), "claude_ai_Gmail");
2464    }
2465
2466    /// Regression test for #305/#304: an entry present in `mcp.json` but whose `url` fails
2467    /// `build_core_config`'s scheme validation must still be found by `get_mcp_server_entry` —
2468    /// distinct from `get_mcp_server`, which eagerly runs that validation and previously made
2469    /// this case indistinguishable from a genuinely absent entry to its callers.
2470    #[cfg(unix)]
2471    #[test]
2472    fn test_get_mcp_server_entry_finds_entry_that_fails_config_validation() {
2473        let _guard = HOME_ENV_LOCK.lock().unwrap();
2474
2475        let temp = tempfile::TempDir::new().unwrap();
2476        let claude_dir = temp.path().join(".claude");
2477        std::fs::create_dir_all(&claude_dir).unwrap();
2478        std::fs::write(
2479            claude_dir.join("mcp.json"),
2480            r#"{"mcpServers": {"badscheme": {"type": "http", "url": "not-a-url"}}}"#,
2481        )
2482        .unwrap();
2483
2484        let original_home = std::env::var_os("HOME");
2485        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
2486        // reads or writes `HOME` while the guard is held.
2487        unsafe {
2488            std::env::set_var("HOME", temp.path());
2489        }
2490
2491        let entry_result = get_mcp_server_entry("badscheme");
2492
2493        // SAFETY: see above.
2494        unsafe {
2495            match &original_home {
2496                Some(home) => std::env::set_var("HOME", home),
2497                None => std::env::remove_var("HOME"),
2498            }
2499        }
2500
2501        let (id, entry) = entry_result.expect(
2502            "get_mcp_server_entry must find the entry even though its url fails validation",
2503        );
2504        assert_eq!(id.as_str(), "badscheme");
2505        let config_err = build_core_config(&entry).expect_err(
2506            "the entry's url is expected to fail build_core_config's scheme validation",
2507        );
2508        // Regression coverage for #304: `validate_command` interpolates this error's `Display`
2509        // into its "invalid configuration" message. It must describe the actual validation
2510        // failure, not read like the unrelated "not found" message reserved for a genuinely
2511        // absent entry.
2512        let message = config_err.to_string();
2513        assert!(
2514            !message.to_lowercase().contains("not found"),
2515            "build_core_config's error must not read like a not-found message, got: {message}"
2516        );
2517    }
2518}