Skip to main content

mcp_execution_cli/commands/
server.rs

1//! Server command implementation.
2//!
3//! Manages MCP server listing, inspection, and validation using
4//! `~/.claude/mcp.json` as the single source of truth for server definitions.
5
6use crate::actions::ServerAction;
7use crate::commands::common::{
8    McpServerEntry, McpTransport, build_core_config, get_mcp_server_entry, list_mcp_servers,
9    load_mcp_config, lookup_server_entry,
10};
11use crate::formatters::escape_error_text;
12use anyhow::{Context, Result};
13use mcp_execution_core::ServerConfig;
14use mcp_execution_core::ServerId;
15use mcp_execution_core::cli::{ExitCode, OutputFormat};
16use mcp_execution_core::{REDACTED_PLACEHOLDER, RedactedUrl, sanitize_path_for_error};
17use mcp_execution_introspector::Introspector;
18use serde::Serialize;
19use std::path::Path;
20use std::time::Duration;
21use tracing::{info, warn};
22use url::Url;
23
24/// Maximum time `server list` waits for a single http/sse availability
25/// check, independent of (and shorter than) the entry's own configured
26/// `connect_timeout_secs`/`discover_timeout_secs`.
27///
28/// `list` enumerates every configured server and users expect it to stay
29/// responsive — especially with several servers configured — even though
30/// checks already run concurrently (see [`list_servers`]). Three seconds is
31/// generous enough for a typical cross-network MCP handshake while keeping a
32/// single slow or firewalled entry from making the whole command visibly
33/// hang. `server validate <name>`/`server info <name>` do not use this
34/// bound: they are explicit, single-target commands where a user consciously
35/// waits for a definitive answer using the entry's full configured timeout.
36const LIST_AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(3);
37
38/// Status of a configured server.
39///
40/// The precise check behind this depends on the call site: `server list`
41/// uses `transport_available` (PATH lookup for stdio; URL well-formedness
42/// plus a bounded MCP introspection attempt for http/sse — see
43/// `LIST_AVAILABILITY_TIMEOUT`). `server info`/`server validate` instead
44/// reflect whether a full MCP introspection handshake succeeded, waiting out
45/// the entry's full configured `connect_timeout_secs`/`discover_timeout_secs`.
46///
47/// For **http/sse**, `list` and `info`/`validate` share the exact same
48/// connection path (`Introspector::discover_server`), so they can no longer
49/// disagree about *how* a transport is reached (proxying, IPv6) — only about
50/// *how long* the check is allowed to run. `list`'s bounded check is a
51/// time-boxed, best-effort signal across every configured server; `server
52/// validate <name>`/`server info <name>` are the authoritative check for one
53/// specific server. A server that is merely slow (past `list`'s short bound
54/// but within its own configured timeout) can therefore show `unavailable`
55/// in `list` and `available` in `validate`/`info`. This is an intentional,
56/// documented trade-off — distinct from #280, which was an unconditional
57/// *wrong* answer, not a bounded, best-effort one.
58///
59/// For **stdio**, this equivalence does not hold: `list` still performs only
60/// a PATH lookup while `info`/`validate` perform a full handshake, so the
61/// two can disagree about more than timing (pre-existing behavior, unrelated
62/// to #280's http/sse scope).
63///
64/// # Examples
65///
66/// ```
67/// use mcp_execution_cli::commands::server::ServerStatus;
68///
69/// assert_eq!(
70///     serde_json::to_string(&ServerStatus::Available).unwrap(),
71///     "\"available\""
72/// );
73/// assert_eq!(
74///     serde_json::to_string(&ServerStatus::Unavailable).unwrap(),
75///     "\"unavailable\""
76/// );
77/// ```
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "lowercase")]
80pub enum ServerStatus {
81    /// `list`: command in PATH / URL well-formed and reachable. `info`:
82    /// introspection succeeded.
83    Available,
84    /// `list`: command missing / URL malformed or unreachable. `info`:
85    /// introspection failed.
86    Unavailable,
87}
88
89/// Represents a configured server entry for output.
90///
91/// # Examples
92///
93/// ```
94/// use mcp_execution_cli::commands::server::{ServerEntry, ServerStatus};
95///
96/// let entry = ServerEntry {
97///     id: "github".to_string(),
98///     command: "github-mcp-server".to_string(),
99///     status: ServerStatus::Available,
100/// };
101///
102/// assert_eq!(entry.id, "github");
103/// ```
104#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
105pub struct ServerEntry {
106    /// Server identifier.
107    pub id: String,
108    /// Command used to start the server.
109    pub command: String,
110    /// Current server status. For stdio, a PATH lookup. For http/sse, a
111    /// well-formedness pre-check followed by the same MCP introspection
112    /// handshake `server info`/`server validate` use — but bounded to
113    /// `LIST_AVAILABILITY_TIMEOUT` rather than the entry's full configured
114    /// timeout, so this is a time-bounded, best-effort signal. Run `server
115    /// validate <name>` for an authoritative answer on one specific server.
116    pub status: ServerStatus,
117}
118
119/// List of configured servers.
120///
121/// # Examples
122///
123/// ```
124/// use mcp_execution_cli::commands::server::{ServerEntry, ServerList, ServerStatus};
125///
126/// let list = ServerList {
127///     servers: vec![
128///         ServerEntry {
129///             id: "github".to_string(),
130///             command: "github-mcp-server".to_string(),
131///             status: ServerStatus::Available,
132///         }
133///     ],
134/// };
135///
136/// assert_eq!(list.servers.len(), 1);
137/// ```
138#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
139pub struct ServerList {
140    /// All configured servers.
141    pub servers: Vec<ServerEntry>,
142}
143
144/// Detailed server information for output.
145///
146/// # Examples
147///
148/// ```
149/// use mcp_execution_cli::commands::server::{ServerInfo, ServerStatus, ToolSummary};
150///
151/// let info = ServerInfo {
152///     id: "github".to_string(),
153///     name: "GitHub MCP".to_string(),
154///     version: "1.0.0".to_string(),
155///     command: "github-mcp-server".to_string(),
156///     status: ServerStatus::Available,
157///     tools: vec![],
158///     capabilities: vec!["tools".to_string()],
159/// };
160///
161/// assert_eq!(info.id, "github");
162/// ```
163#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
164pub struct ServerInfo {
165    /// Server identifier.
166    pub id: String,
167    /// Server name from introspection.
168    pub name: String,
169    /// Server version.
170    pub version: String,
171    /// Command used to start the server.
172    pub command: String,
173    /// Current server status.
174    pub status: ServerStatus,
175    /// Available tools.
176    pub tools: Vec<ToolSummary>,
177    /// Server capabilities.
178    pub capabilities: Vec<String>,
179}
180
181/// Tool summary for output.
182///
183/// # Examples
184///
185/// ```
186/// use mcp_execution_cli::commands::server::ToolSummary;
187///
188/// let tool = ToolSummary {
189///     name: "search".to_string(),
190///     description: "Search repositories".to_string(),
191/// };
192///
193/// assert_eq!(tool.name, "search");
194/// ```
195#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
196pub struct ToolSummary {
197    /// Tool name.
198    pub name: String,
199    /// Tool description.
200    pub description: String,
201}
202
203/// Validation result for a server command.
204///
205/// # Examples
206///
207/// ```
208/// use mcp_execution_cli::commands::server::ValidationResult;
209///
210/// let result = ValidationResult {
211///     command: "server".to_string(),
212///     valid: true,
213///     message: "Command is valid".to_string(),
214/// };
215///
216/// assert!(result.valid);
217/// ```
218#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
219pub struct ValidationResult {
220    /// The validated command.
221    pub command: String,
222    /// Whether the command is valid.
223    pub valid: bool,
224    /// Validation message.
225    pub message: String,
226}
227
228/// Runs the server command.
229///
230/// Manages server listing, detailed info, and validation.
231/// All server definitions are loaded from `~/.claude/mcp.json`.
232///
233/// # Arguments
234///
235/// * `action` - Server management action (List, Info, or Validate)
236/// * `output_format` - Output format (json, text, pretty)
237///
238/// # Errors
239///
240/// Returns an error if:
241/// - The configuration file cannot be read or is malformed
242/// - For the `Info` action, the named server is not found in the configuration
243/// - Output formatting fails (serialization error)
244///
245/// Note: For the `Validate` action, an unknown server name is reported via
246/// `ExitCode::ERROR` rather than returning `Err`. Server introspection failures
247/// (for both `Info` and `Validate`) are also caught internally and reported via
248/// `ExitCode::ERROR`. So is an entry that is present but fails security validation (e.g. an
249/// invalid URL scheme, #305/#304) — only a genuinely absent entry propagates as `Err`.
250///
251/// # Examples
252///
253/// ```no_run
254/// use mcp_execution_cli::commands::server;
255/// use mcp_execution_core::cli::{ExitCode, OutputFormat};
256///
257/// # #[tokio::main]
258/// # async fn main() {
259/// let result = server::run(
260///     mcp_execution_cli::ServerAction::List,
261///     OutputFormat::Json
262/// ).await;
263/// assert!(result.is_ok());
264/// # }
265/// ```
266pub async fn run(action: ServerAction, output_format: OutputFormat) -> Result<ExitCode> {
267    info!("Server action: {:?}", action);
268    info!("Output format: {}", output_format);
269
270    match action {
271        ServerAction::List => list_servers(output_format).await,
272        ServerAction::Info { server } => show_server_info(server, output_format).await,
273        ServerAction::Validate { command } => validate_command(command, output_format).await,
274    }
275}
276
277/// Lists all servers configured in `~/.claude/mcp.json`.
278///
279/// Returns an empty list (not an error) when the config file does not exist.
280///
281/// For every http/sse entry, this performs a real, bounded MCP handshake
282/// against the remote server (see `LIST_AVAILABILITY_TIMEOUT`), not a purely
283/// local check — this has real network cost and, per known
284/// `mcp-execution-introspector` limitations, can leave an orphaned session
285/// on the remote server per invocation.
286async fn list_servers(output_format: OutputFormat) -> Result<ExitCode> {
287    let servers = list_mcp_servers()
288        .context("failed to read server configuration from ~/.claude/mcp.json")?;
289
290    if servers.is_empty() {
291        info!("No MCP servers configured in ~/.claude/mcp.json");
292        let server_list = ServerList {
293            servers: Vec::new(),
294        };
295        return crate::formatters::emit(&server_list, output_format, ExitCode::SUCCESS);
296    }
297
298    // Each server's status check may include a full MCP introspection
299    // attempt (see `transport_available`); run them concurrently so `list`'s
300    // total latency is bounded by the slowest single check, not their sum.
301    let checks = servers.into_iter().map(|(name, entry)| async move {
302        let command = build_command_string(&entry);
303        let status = if transport_available(&name, &entry).await {
304            ServerStatus::Available
305        } else {
306            ServerStatus::Unavailable
307        };
308
309        ServerEntry {
310            id: name,
311            command,
312            status,
313        }
314    });
315    let entries = futures_util::future::join_all(checks).await;
316
317    let server_list = ServerList { servers: entries };
318    crate::formatters::emit(&server_list, output_format, ExitCode::SUCCESS)
319}
320
321/// Shows detailed information about a specific server.
322///
323/// Connects to the server and introspects its capabilities, tools, and status.
324///
325/// An entry whose `url` (or other field) fails [`build_core_config`]'s security validation is
326/// reported the same way as an entry that is well-formed but unreachable — a structured
327/// `"status": "unavailable"` [`ServerInfo`] through `output_format`, not a raw, unformatted error
328/// (#305). [`get_mcp_server_entry`]'s own errors — a missing/malformed `~/.claude/mcp.json` as
329/// well as a genuinely absent server name — are not caught here and still propagate as `Err`
330/// (unlike `server validate`, which distinguishes these cases into its own structured
331/// `ValidationResult`, #479).
332async fn show_server_info(server: String, output_format: OutputFormat) -> Result<ExitCode> {
333    let (server_id, entry) = get_mcp_server_entry(&server)?;
334    let command = build_command_string(&entry);
335
336    let server_config = match build_core_config(&entry) {
337        Ok(config) => config,
338        Err(e) => {
339            warn!(
340                "Server '{}' has an invalid configuration: {}",
341                server,
342                escape_error_text(&e.to_string())
343            );
344            return crate::formatters::emit(
345                &unavailable_server_info(server, command),
346                output_format,
347                ExitCode::ERROR,
348            );
349        }
350    };
351
352    info!("Introspecting server '{}'...", server);
353
354    let mut introspector = Introspector::new();
355    match introspector
356        .discover_server(server_id, &server_config)
357        .await
358    {
359        Ok(introspected) => {
360            let mut capabilities = Vec::new();
361            if introspected.capabilities.supports_tools {
362                capabilities.push("tools".to_string());
363            }
364            if introspected.capabilities.supports_resources {
365                capabilities.push("resources".to_string());
366            }
367            if introspected.capabilities.supports_prompts {
368                capabilities.push("prompts".to_string());
369            }
370
371            let tools = introspected
372                .tools
373                .iter()
374                .map(|t| ToolSummary {
375                    name: t.name.as_str().to_string(),
376                    description: t.description.clone(),
377                })
378                .collect();
379
380            let server_info = ServerInfo {
381                id: server,
382                name: introspected.name,
383                version: introspected.version,
384                command,
385                status: ServerStatus::Available,
386                tools,
387                capabilities,
388            };
389
390            crate::formatters::emit(&server_info, output_format, ExitCode::SUCCESS)
391        }
392        Err(e) => {
393            warn!(
394                "Failed to introspect server '{}': {}",
395                server,
396                escape_error_text(&e.to_string())
397            );
398
399            crate::formatters::emit(
400                &unavailable_server_info(server, command),
401                output_format,
402                ExitCode::ERROR,
403            )
404        }
405    }
406}
407
408/// Builds the `"status": "unavailable"` [`ServerInfo`] shared by `show_server_info`'s two failure
409/// branches — invalid configuration and failed introspection — so both report through the same
410/// structured shape.
411fn unavailable_server_info(server: String, command: String) -> ServerInfo {
412    ServerInfo {
413        id: server.clone(),
414        name: server,
415        version: "unknown".to_string(),
416        command,
417        status: ServerStatus::Unavailable,
418        tools: Vec::new(),
419        capabilities: Vec::new(),
420    }
421}
422
423/// Validates a server by checking its command and attempting introspection.
424///
425/// The server must be configured in `~/.claude/mcp.json`. An entry that is present but fails
426/// [`build_core_config`]'s security validation (e.g. an invalid URL scheme) is reported with a
427/// message describing that specific problem, not the "not found" message reserved for a
428/// genuinely absent entry (#304).
429///
430/// Config loading and name lookup are performed as two separate steps (via [`load_mcp_config`]
431/// and [`lookup_server_entry`], rather than the combined [`get_mcp_server_entry`]) so a missing
432/// or malformed `~/.claude/mcp.json` produces its own message instead of being collapsed into
433/// "server not found" — mirroring how `generate --from-config`/`server info`/`server list`
434/// report the same underlying conditions (#479).
435async fn validate_command(server_name: String, output_format: OutputFormat) -> Result<ExitCode> {
436    let config = match load_mcp_config() {
437        Ok(config) => config,
438        Err(e) => {
439            let result = ValidationResult {
440                command: server_name,
441                valid: false,
442                message: format!("Failed to read server configuration: {e}"),
443            };
444            return crate::formatters::emit(&result, output_format, ExitCode::ERROR);
445        }
446    };
447
448    let (server_id, entry) = match lookup_server_entry(&config, &server_name) {
449        Ok(result) => result,
450        Err(e) => {
451            let result = ValidationResult {
452                command: server_name,
453                valid: false,
454                message: format!("Server not found in configuration: {e}"),
455            };
456            return crate::formatters::emit(&result, output_format, ExitCode::ERROR);
457        }
458    };
459
460    let command = build_command_string(&entry);
461    info!("Validating server '{}'...", server_name);
462
463    // Exhaustive over `McpTransport` with no `_` arm: adding a new transport
464    // variant must fail to compile here rather than silently skip the
465    // precheck (that asymmetry with the exhaustive match below is what let
466    // #280 slip through).
467    let precheck_failure = match &entry.transport {
468        McpTransport::Stdio {
469            command: bin_command,
470            ..
471        } => (!check_command_exists(bin_command))
472            .then(|| format!("Command '{bin_command}' not found in PATH")),
473        McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
474            (!url_well_formed(url)).then(|| url_precheck_message(url))
475        }
476    };
477
478    if let Some(message) = precheck_failure {
479        let result = ValidationResult {
480            command: command.clone(),
481            valid: false,
482            message,
483        };
484        return crate::formatters::emit(&result, output_format, ExitCode::ERROR);
485    }
486
487    // The precheck above catches the common malformed-URL/missing-command cases, but
488    // `build_core_config` runs additional security validation (e.g. header safety, timeout
489    // bounds) the precheck does not duplicate. A failure here is still "entry present, invalid
490    // configuration" rather than "entry not found", so it gets its own message rather than
491    // falling through to `get_mcp_server_entry`'s not-found wrapping.
492    let server_config = match build_core_config(&entry) {
493        Ok(config) => config,
494        Err(e) => {
495            let result = ValidationResult {
496                command,
497                valid: false,
498                message: format!("Server '{server_name}' has an invalid configuration: {e}"),
499            };
500            return crate::formatters::emit(&result, output_format, ExitCode::ERROR);
501        }
502    };
503
504    let mut introspector = Introspector::new();
505    match introspector
506        .discover_server(server_id, &server_config)
507        .await
508    {
509        Ok(_) => {
510            let result = ValidationResult {
511                command,
512                valid: true,
513                message: format!(
514                    "Server '{server_name}' is available and responds to MCP protocol"
515                ),
516            };
517            crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
518        }
519        Err(e) => {
520            warn!(
521                "Failed to introspect server '{}' during validation: {}",
522                server_name,
523                escape_error_text(&e.to_string())
524            );
525            let message = match &entry.transport {
526                McpTransport::Stdio { .. } => format!(
527                    "Server '{server_name}' command exists but failed to respond to MCP protocol"
528                ),
529                McpTransport::Http { .. } | McpTransport::Sse { .. } => {
530                    format!("Server '{server_name}' endpoint failed to respond to MCP protocol")
531                }
532            };
533            let result = ValidationResult {
534                command,
535                valid: false,
536                message,
537            };
538            crate::formatters::emit(&result, output_format, ExitCode::ERROR)
539        }
540    }
541}
542
543/// Builds a displayable command string from a server entry.
544///
545/// Stdio entries render as `command args…`; http/sse entries render as the
546/// endpoint URL. This feeds `server list`/`server info`/`server validate`
547/// output, which is printed unconditionally (not gated behind `--verbose`),
548/// so every field is redacted the same way `ServerConfig`'s own `Debug` impl
549/// redacts them (#346): `command` is routed through
550/// [`sanitize_path_for_error`] (home directory/username scrub — not a
551/// secret, but an absolute path leaks the OS username); `args` are replaced
552/// wholesale with [`REDACTED_PLACEHOLDER`] since an argument routinely holds
553/// an entire secret (e.g. `--api-key sk-...`) with no key/value half worth
554/// preserving; `url` is redacted via [`RedactedUrl`], which strips userinfo
555/// credentials and any query string while keeping scheme/host/path
556/// readable. Unlike `ServerConfig`'s `Debug` impl, `args` render as a
557/// space-joined, shell-shaped string (`REDACTED_PLACEHOLDER` per entry)
558/// rather than [`mcp_execution_core::RedactedItems`]'s `Debug`-list syntax
559/// (`["<redacted>", ...]`) — this string lands verbatim in `--format json`
560/// output, where embedding Rust `Debug` syntax inside a JSON string would be
561/// needlessly awkward for machine consumers.
562fn build_command_string(entry: &McpServerEntry) -> String {
563    match &entry.transport {
564        McpTransport::Stdio { command, args, .. } => {
565            let command = sanitize_path_for_error(Path::new(command));
566            if args.is_empty() {
567                command
568            } else {
569                let redacted_args = vec![REDACTED_PLACEHOLDER; args.len()].join(" ");
570                format!("{command} {redacted_args}")
571            }
572        }
573        McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
574            format!("{:?}", RedactedUrl(url))
575        }
576    }
577}
578
579/// Builds the "URL is not well-formed" precheck failure message used by
580/// [`validate_command`], redacting `url` via [`RedactedUrl`] so a malformed URL that still
581/// carries userinfo credentials (e.g. a mistyped port on an otherwise valid
582/// `https://user:pass@host` URL) never leaks them into `server validate`'s unconditional
583/// `ValidationResult::message` output (#346 S1: this precheck message was the one call site the
584/// original fix missed — it sits above `build_command_string`, not inside it).
585///
586/// Extracted into its own function so the redaction can be unit-tested directly, since this
587/// crate has no harness to capture the `println!`-only command output (see the `#[cfg(test)]`
588/// module's other notes on that limitation).
589fn url_precheck_message(url: &str) -> String {
590    format!(
591        "URL '{:?}' is not well-formed (expected http:// or https:// with a host)",
592        RedactedUrl(url)
593    )
594}
595
596/// Returns `true` if the given command binary is available in PATH.
597fn check_command_exists(command: &str) -> bool {
598    which::which(command).is_ok()
599}
600
601/// Returns `true` if `url` is a well-formed `http://`/`https://` URL with a host.
602///
603/// Delegates the scheme check to
604/// [`mcp_execution_core::validate_url_scheme`] rather than re-deriving it
605/// from `url::Url::parse` (which normalizes whitespace and other input
606/// `validate_url_scheme` does not) — two disagreeing URL-validity checks
607/// across `mcp-core` and `mcp-cli` for the same transport is the same defect
608/// class as the transport-mismatch this module already guards against. The
609/// combined check can therefore never accept a URL `validate_url_scheme`
610/// (and, transitively, `server validate`/`generate`) would reject; the host
611/// check on top is strictly additional and only makes this stricter, never
612/// more permissive.
613fn url_well_formed(url: &str) -> bool {
614    mcp_execution_core::validate_url_scheme(url).is_ok()
615        && Url::parse(url).is_ok_and(|parsed| parsed.host().is_some())
616}
617
618/// Returns `true` if the entry's transport is ready to attempt a connection.
619///
620/// Stdio checks PATH for the command. Http/Sse first checks that the URL is
621/// well-formed, then attempts the same MCP introspection handshake `server
622/// info`/`server validate` use via [`Introspector::discover_server`] — the
623/// same connection path, so it automatically honors the entry's configured
624/// `connect_timeout_secs`/`discover_timeout_secs`, IPv6 literals, and any
625/// proxy handling the underlying transport applies, with nothing
626/// re-implemented or kept in sync by hand here. Unlike `server info`/`server
627/// validate`, this attempt is additionally bounded by the short
628/// `LIST_AVAILABILITY_TIMEOUT`, since `list` checks every configured server
629/// and must stay responsive: a server that is merely slower than that bound
630/// (but still within its own configured timeout) is reported unavailable
631/// here even though `validate`/`info` would eventually report it available.
632///
633/// `name` is only used to build the [`ServerId`] passed to the introspector
634/// for the http/sse case; it plays no role in the stdio PATH check.
635async fn transport_available(name: &str, entry: &McpServerEntry) -> bool {
636    match &entry.transport {
637        McpTransport::Stdio { command, .. } => check_command_exists(command),
638        McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
639            if !url_well_formed(url) {
640                return false;
641            }
642            let Ok(server_config) = build_core_config(entry) else {
643                return false;
644            };
645            discover_within(name, &server_config, LIST_AVAILABILITY_TIMEOUT).await
646        }
647    }
648}
649
650/// Attempts [`Introspector::discover_server`], bounding it to `timeout`.
651///
652/// Returns `false` on a timeout exactly as it would for any other discovery
653/// error — `list` has no need to distinguish "too slow" from "refused" or
654/// "unreachable", since [`transport_available`]'s doc already establishes
655/// that a bounded `unavailable` here is not a definitive answer.
656///
657/// Extracted into its own function so tests can exercise the timeout branch
658/// with a duration far shorter than [`LIST_AVAILABILITY_TIMEOUT`], without
659/// waiting multiple real seconds.
660async fn discover_within(name: &str, config: &ServerConfig, timeout: Duration) -> bool {
661    let Ok(server_id) = ServerId::new(name) else {
662        return false;
663    };
664    tokio::time::timeout(
665        timeout,
666        Introspector::new().discover_server(server_id, config),
667    )
668    .await
669    .is_ok_and(|result| result.is_ok())
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use std::collections::HashMap;
676
677    #[test]
678    fn test_server_status_serializes_lowercase() {
679        assert_eq!(
680            serde_json::to_string(&ServerStatus::Available).unwrap(),
681            "\"available\""
682        );
683        assert_eq!(
684            serde_json::to_string(&ServerStatus::Unavailable).unwrap(),
685            "\"unavailable\""
686        );
687    }
688
689    #[test]
690    fn test_build_command_string_no_args() {
691        let entry = McpServerEntry {
692            transport: McpTransport::Stdio {
693                command: "node".to_string(),
694                args: Vec::new(),
695                env: HashMap::default(),
696                cwd: None,
697            },
698            connect_timeout_secs: None,
699            discover_timeout_secs: None,
700        };
701        assert_eq!(build_command_string(&entry), "node");
702    }
703
704    /// #346 — args are redacted wholesale (mirroring `ServerConfig`'s `Debug` impl), since an
705    /// argument can itself be an entire secret with no key/value split to preserve half of.
706    /// Asserts the exact rendering (not just secret absence, per critic M3: a `retain`-style
707    /// bug that silently dropped args instead of redacting them would otherwise still pass).
708    #[test]
709    fn test_build_command_string_with_args() {
710        let entry = McpServerEntry {
711            transport: McpTransport::Stdio {
712                command: "node".to_string(),
713                args: vec!["/path/to/server.js".to_string(), "--verbose".to_string()],
714                env: HashMap::default(),
715                cwd: None,
716            },
717            connect_timeout_secs: None,
718            discover_timeout_secs: None,
719        };
720        let command = build_command_string(&entry);
721        assert_eq!(
722            command,
723            format!("node {REDACTED_PLACEHOLDER} {REDACTED_PLACEHOLDER}")
724        );
725        assert!(!command.contains("/path/to/server.js"));
726        assert!(!command.contains("--verbose"));
727    }
728
729    /// #346 regression: a stdio arg carrying an entire secret (e.g. `--api-key sk-...`) must
730    /// never appear in `server list`/`server info`/`server validate` output, which is printed
731    /// unconditionally. Counts placeholders (critic M3) rather than only asserting the secret's
732    /// absence, so silently dropping args instead of redacting them would fail this test too.
733    #[test]
734    fn test_build_command_string_redacts_secret_arg() {
735        let secret = "sk-live-secret-arg-value";
736        let entry = McpServerEntry {
737            transport: McpTransport::Stdio {
738                command: "docker".to_string(),
739                args: vec!["--api-key".to_string(), secret.to_string()],
740                env: HashMap::default(),
741                cwd: None,
742            },
743            connect_timeout_secs: None,
744            discover_timeout_secs: None,
745        };
746        let command = build_command_string(&entry);
747        assert!(command.starts_with("docker "));
748        assert!(!command.contains(secret));
749        assert_eq!(command.matches(REDACTED_PLACEHOLDER).count(), 2);
750    }
751
752    /// #346 M2: `command` routes through the same [`sanitize_path_for_error`] scrub
753    /// `ServerConfig`/`McpTransport`/`Transport` all apply, so an absolute stdio command path
754    /// under the home directory doesn't leak the OS username into unconditional output.
755    #[test]
756    fn test_build_command_string_sanitizes_command_home_path() {
757        let home = dirs::home_dir().expect("home dir available in this environment");
758        let entry = McpServerEntry {
759            transport: McpTransport::Stdio {
760                command: home.join("bin/mcp-server").to_string_lossy().into_owned(),
761                args: Vec::new(),
762                env: HashMap::default(),
763                cwd: None,
764            },
765            connect_timeout_secs: None,
766            discover_timeout_secs: None,
767        };
768        let command = build_command_string(&entry);
769        assert_eq!(
770            command,
771            format!(
772                "~{}bin{}mcp-server",
773                std::path::MAIN_SEPARATOR,
774                std::path::MAIN_SEPARATOR
775            )
776        );
777    }
778
779    #[test]
780    fn test_build_command_string_http() {
781        let entry = McpServerEntry {
782            transport: McpTransport::Http {
783                url: "https://api.example.com/mcp".to_string(),
784                headers: HashMap::default(),
785            },
786            connect_timeout_secs: None,
787            discover_timeout_secs: None,
788        };
789        assert_eq!(build_command_string(&entry), "https://api.example.com/mcp");
790    }
791
792    /// #346 regression: userinfo credentials and a `?token=`-style query string in a
793    /// http/sse `url` must never appear in `server list`/`server info`/`server validate`
794    /// output; host/path stay readable.
795    #[test]
796    fn test_build_command_string_redacts_secret_url() {
797        let secret = "hunter2";
798        let entry = McpServerEntry {
799            transport: McpTransport::Http {
800                url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
801                headers: HashMap::default(),
802            },
803            connect_timeout_secs: None,
804            discover_timeout_secs: None,
805        };
806        let command = build_command_string(&entry);
807        assert!(!command.contains(secret));
808        assert!(command.contains("api.example.com/mcp"));
809    }
810
811    /// #346 M3: a `url` that [`RedactedUrl`] cannot parse (e.g. malformed scheme) falls back to
812    /// redacting the whole string, so `server list`'s Command column shows only the placeholder
813    /// with no host at all — documented here so that fallback isn't silently un-exercised.
814    #[test]
815    fn test_build_command_string_unparseable_url_redacts_entirely() {
816        let entry = McpServerEntry {
817            transport: McpTransport::Http {
818                url: "not-a-url".to_string(),
819                headers: HashMap::default(),
820            },
821            connect_timeout_secs: None,
822            discover_timeout_secs: None,
823        };
824        assert_eq!(build_command_string(&entry), REDACTED_PLACEHOLDER);
825    }
826
827    /// #346 S1 regression: `validate_command`'s precheck failure message used to interpolate
828    /// the raw `url`, so a malformed URL that still carried userinfo credentials (e.g. a
829    /// mistyped port) leaked them into `ValidationResult::message`, which is printed
830    /// unconditionally — even though `build_command_string`'s `command` field was already
831    /// redacted, producing the redacted and unredacted forms of the same secret side by side.
832    #[test]
833    fn test_url_precheck_message_redacts_credentials() {
834        let secret = "hunter2";
835        let message =
836            url_precheck_message(&format!("https://alice:{secret}@api.example.com:99999/mcp"));
837        assert!(!message.contains(secret));
838        assert!(message.contains("api.example.com"));
839    }
840
841    #[tokio::test]
842    async fn test_transport_available_http_well_formed_but_unreachable_false() {
843        // Regression test for #280 (S1): the issue's own repro was a
844        // well-formed URL nothing listens on, and `list` reported it
845        // "available" anyway. Well-formedness alone must no longer be
846        // sufficient: `transport_available` now attempts real MCP
847        // introspection, which fails to even connect here (nothing listens
848        // on this port), so the entry must report unavailable.
849        let entry = McpServerEntry {
850            transport: McpTransport::Http {
851                url: "http://127.0.0.1:1/mcp".to_string(),
852                headers: HashMap::default(),
853            },
854            connect_timeout_secs: None,
855            discover_timeout_secs: None,
856        };
857        assert!(!transport_available("unreachable", &entry).await);
858    }
859
860    #[tokio::test]
861    async fn test_discover_within_times_out_on_unresponsive_endpoint() {
862        // Regression coverage for the `list`-latency fix: a single slow or
863        // black-holed entry must not make `discover_within` (and therefore
864        // `list`) wait out the entry's full configured connect/discover
865        // timeout. 192.0.2.1 is TEST-NET-1 (RFC 5737), reserved for
866        // documentation and guaranteed non-routable, so this never depends
867        // on real network conditions: whether the attempt times out or fails
868        // outright (e.g. "no route to host"), the result is `false` either
869        // way — what this test actually pins down is that a short `timeout`
870        // argument bounds the wait, so this never risks sleeping multiple
871        // real seconds like the entry's own default timeouts would.
872        let config = ServerConfig::builder()
873            .http_transport("http://192.0.2.1:9/mcp".to_string())
874            .build()
875            .unwrap();
876
877        let start = std::time::Instant::now();
878        let available = discover_within("timeout-test", &config, Duration::from_millis(200)).await;
879        let elapsed = start.elapsed();
880
881        assert!(!available);
882        assert!(
883            elapsed < Duration::from_secs(2),
884            "expected the short timeout to cut this attempt short, took {elapsed:?}"
885        );
886    }
887
888    #[tokio::test]
889    async fn test_transport_available_http_malformed_false() {
890        let entry = McpServerEntry {
891            transport: McpTransport::Http {
892                url: "not-a-url".to_string(),
893                headers: HashMap::default(),
894            },
895            connect_timeout_secs: None,
896            discover_timeout_secs: None,
897        };
898        assert!(!transport_available("badhttp", &entry).await);
899    }
900
901    #[tokio::test]
902    async fn test_transport_available_sse_malformed_false() {
903        let entry = McpServerEntry {
904            transport: McpTransport::Sse {
905                url: "ftp://example.com".to_string(),
906                headers: HashMap::default(),
907            },
908            connect_timeout_secs: None,
909            discover_timeout_secs: None,
910        };
911        assert!(!transport_available("badsse", &entry).await);
912    }
913
914    #[test]
915    fn test_url_well_formed_http_valid() {
916        assert!(url_well_formed("http://example.com"));
917    }
918
919    #[test]
920    fn test_url_well_formed_https_valid() {
921        assert!(url_well_formed("https://example.com/mcp"));
922    }
923
924    #[test]
925    fn test_url_well_formed_wrong_scheme_ftp() {
926        assert!(!url_well_formed("ftp://example.com"));
927    }
928
929    #[test]
930    fn test_url_well_formed_wrong_scheme_file() {
931        // `file://` URLs parse without error but carry no host, so this is
932        // also covered by the "no host" branch — kept as a separate case
933        // since a wrong-scheme rejection and a missing-host rejection are
934        // logically distinct failure modes that happen to coincide here.
935        assert!(!url_well_formed("file:///etc/passwd"));
936    }
937
938    #[test]
939    fn test_url_well_formed_rejects_leading_whitespace() {
940        // Regression test for #280 (S2): the `url` crate strips leading
941        // whitespace per WHATWG, but `mcp_execution_core::validate_url_scheme`
942        // does not, so a whitespace-padded URL used to pass `list`'s check
943        // while `server validate`/`generate` rejected the identical value
944        // inside `build_core_config`. Delegating the scheme check to
945        // `validate_url_scheme` closes that gap.
946        assert!(!url_well_formed("  https://example.com/mcp"));
947    }
948
949    #[test]
950    fn test_url_well_formed_rejects_trailing_control_chars() {
951        // Same S2 divergence as the leading-whitespace case, but on the
952        // trailing side and with a tab/newline rather than a plain space —
953        // the exact combination the critic measured against `url` 2.5.8's
954        // WHATWG-compliant trimming.
955        assert!(!url_well_formed("\thttps://example.com/mcp\n"));
956    }
957
958    #[test]
959    fn test_url_well_formed_no_host() {
960        // Per the WHATWG URL spec, `http`/`https` require a non-empty
961        // authority, so this fails to parse at all rather than parsing with
962        // an empty host — `url_well_formed` must treat a parse failure the
963        // same as a parsed-but-hostless URL.
964        assert!(!url_well_formed("http://"));
965    }
966
967    #[test]
968    fn test_url_well_formed_malformed_no_scheme() {
969        assert!(!url_well_formed("not-a-url"));
970    }
971
972    #[test]
973    fn test_url_well_formed_empty_string() {
974        assert!(!url_well_formed(""));
975    }
976
977    #[test]
978    fn test_check_command_exists() {
979        assert!(check_command_exists("ls"));
980        assert!(!check_command_exists(
981            "this_command_definitely_does_not_exist_12345"
982        ));
983    }
984
985    #[test]
986    fn test_server_entry_serialization() {
987        let entry = ServerEntry {
988            id: "test".to_string(),
989            command: "test-cmd".to_string(),
990            status: ServerStatus::Available,
991        };
992
993        let json = serde_json::to_string(&entry).unwrap();
994        assert!(json.contains("test"));
995        assert!(json.contains("test-cmd"));
996        assert!(json.contains("available"));
997    }
998
999    #[test]
1000    fn test_server_list_serialization() {
1001        let list = ServerList {
1002            servers: vec![ServerEntry {
1003                id: "test".to_string(),
1004                command: "test-cmd".to_string(),
1005                status: ServerStatus::Available,
1006            }],
1007        };
1008
1009        let json = serde_json::to_string(&list).unwrap();
1010        assert!(json.contains("servers"));
1011        assert!(json.contains("test"));
1012    }
1013
1014    #[test]
1015    fn test_server_info_serialization() {
1016        let info = ServerInfo {
1017            id: "test".to_string(),
1018            name: "Test Server".to_string(),
1019            version: "1.0.0".to_string(),
1020            command: "test-cmd".to_string(),
1021            status: ServerStatus::Available,
1022            tools: vec![ToolSummary {
1023                name: "test_tool".to_string(),
1024                description: "A test tool".to_string(),
1025            }],
1026            capabilities: vec!["tools".to_string()],
1027        };
1028
1029        let json = serde_json::to_string(&info).unwrap();
1030        assert!(json.contains("test"));
1031        assert!(json.contains("Test Server"));
1032        assert!(json.contains("capabilities"));
1033        assert!(json.contains("tools"));
1034    }
1035
1036    #[test]
1037    fn test_tool_summary_serialization() {
1038        let tool = ToolSummary {
1039            name: "send_message".to_string(),
1040            description: "Sends a message".to_string(),
1041        };
1042
1043        let json = serde_json::to_string(&tool).unwrap();
1044        assert!(json.contains("send_message"));
1045        assert!(json.contains("Sends a message"));
1046    }
1047
1048    /// Serializes tests that mutate the `HOME` env var so they cannot race
1049    /// each other when run in the same process (e.g. under plain `cargo
1050    /// test`, unlike `cargo nextest`, which isolates each test in its own
1051    /// process). An async-aware mutex, since the guard must stay held across
1052    /// the `.await` of the code under test.
1053    #[cfg(unix)]
1054    static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1055
1056    // Unix-only: redirects `dirs::home_dir()` by mutating `HOME`, which
1057    // `dirs` only consults on Unix. On Windows `dirs::home_dir()` resolves
1058    // via `SHGetKnownFolderPath(FOLDERID_Profile)`, a Win32 API that reads
1059    // the real OS user profile and ignores environment variables entirely
1060    // — no env var override can redirect it.
1061    #[cfg(unix)]
1062    #[tokio::test]
1063    async fn test_show_server_info_not_found_error_not_duplicated() {
1064        // Regression test for #164: the "not found" message from
1065        // `get_mcp_server` must propagate unwrapped, not get re-wrapped by
1066        // an equivalent, less complete `with_context` in `show_server_info`.
1067        //
1068        // Hermetic: HOME is pointed at a temp dir with a controlled
1069        // mcp.json that defines an unrelated server, so the "not found"
1070        // branch inside `get_mcp_server` is deterministically reached
1071        // regardless of the executing machine's real HOME. Without this, a
1072        // clean CI runner with no `~/.claude/mcp.json` at all would instead
1073        // fail earlier at the "read config file" step, and the regression
1074        // this test exists to catch would never actually be exercised.
1075        let _guard = HOME_ENV_LOCK.lock().await;
1076
1077        let temp = tempfile::TempDir::new().unwrap();
1078        let claude_dir = temp.path().join(".claude");
1079        std::fs::create_dir_all(&claude_dir).unwrap();
1080        std::fs::write(
1081            claude_dir.join("mcp.json"),
1082            r#"{"mcpServers": {"unrelated": {"command": "node"}}}"#,
1083        )
1084        .unwrap();
1085
1086        let original_home = std::env::var_os("HOME");
1087        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
1088        // reads or writes `HOME` while the guard is held.
1089        unsafe {
1090            std::env::set_var("HOME", temp.path());
1091        }
1092
1093        let result = run(
1094            ServerAction::Info {
1095                server: "nonexistent-server".to_string(),
1096            },
1097            OutputFormat::Json,
1098        )
1099        .await;
1100
1101        // SAFETY: see above.
1102        unsafe {
1103            match &original_home {
1104                Some(home) => std::env::set_var("HOME", home),
1105                None => std::env::remove_var("HOME"),
1106            }
1107        }
1108
1109        assert!(result.is_err());
1110        let message = format!("{:#}", result.unwrap_err());
1111        assert_eq!(
1112            message.matches("not found in ~/.claude/mcp.json").count(),
1113            1,
1114            "expected exactly one not-found message in the error chain, got: {message}"
1115        );
1116    }
1117
1118    /// Points `dirs::home_dir()` at `home_dir` for the duration of the
1119    /// closure, serialized against other `HOME`-mutating tests via
1120    /// `HOME_ENV_LOCK`, and restores the original value afterwards even if
1121    /// the closure's future returns an error.
1122    #[cfg(unix)]
1123    async fn with_home_pointed_at<F, Fut, T>(home_dir: &std::path::Path, f: F) -> T
1124    where
1125        F: FnOnce() -> Fut,
1126        Fut: std::future::Future<Output = T>,
1127    {
1128        let _guard = HOME_ENV_LOCK.lock().await;
1129
1130        let original_home = std::env::var_os("HOME");
1131        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
1132        // reads or writes `HOME` while the guard is held.
1133        unsafe {
1134            std::env::set_var("HOME", home_dir);
1135        }
1136
1137        let result = f().await;
1138
1139        // SAFETY: see above.
1140        unsafe {
1141            match &original_home {
1142                Some(home) => std::env::set_var("HOME", home),
1143                None => std::env::remove_var("HOME"),
1144            }
1145        }
1146
1147        result
1148    }
1149
1150    /// Writes `mcp.json` with the given raw content under a fresh temp dir's
1151    /// `.claude/` subdirectory, returning the temp dir (kept alive by the
1152    /// caller for the test's duration).
1153    #[cfg(unix)]
1154    fn write_test_mcp_config(content: &str) -> tempfile::TempDir {
1155        let temp = tempfile::TempDir::new().unwrap();
1156        let claude_dir = temp.path().join(".claude");
1157        std::fs::create_dir_all(&claude_dir).unwrap();
1158        std::fs::write(claude_dir.join("mcp.json"), content).unwrap();
1159        temp
1160    }
1161
1162    // Regression coverage for #280: `validate_command`'s pre-introspection
1163    // check used to unconditionally treat http/sse transports as available,
1164    // so a malformed URL would only be caught deep inside introspection (or
1165    // not at all). These exercise `run(ServerAction::Validate { .. })`
1166    // end-to-end against a real temp `mcp.json`, asserting on the returned
1167    // `ExitCode` since the human-readable message is only ever printed to
1168    // stdout, which this crate has no harness to capture (see handoff notes).
1169
1170    #[cfg(unix)]
1171    #[tokio::test]
1172    async fn test_validate_command_malformed_http_url_early_exit() {
1173        let temp = write_test_mcp_config(
1174            r#"{"mcpServers": {"badhttp": {"type": "http", "url": "not-a-url"}}}"#,
1175        );
1176
1177        let result = with_home_pointed_at(temp.path(), || {
1178            run(
1179                ServerAction::Validate {
1180                    command: "badhttp".to_string(),
1181                },
1182                OutputFormat::Json,
1183            )
1184        })
1185        .await;
1186
1187        assert_eq!(result.unwrap(), ExitCode::ERROR);
1188    }
1189
1190    #[cfg(unix)]
1191    #[tokio::test]
1192    async fn test_validate_command_malformed_sse_url_early_exit() {
1193        let temp = write_test_mcp_config(
1194            r#"{"mcpServers": {"badsse": {"type": "sse", "url": "ftp://example.com"}}}"#,
1195        );
1196
1197        let result = with_home_pointed_at(temp.path(), || {
1198            run(
1199                ServerAction::Validate {
1200                    command: "badsse".to_string(),
1201                },
1202                OutputFormat::Json,
1203            )
1204        })
1205        .await;
1206
1207        assert_eq!(result.unwrap(), ExitCode::ERROR);
1208    }
1209
1210    #[cfg(unix)]
1211    #[tokio::test]
1212    async fn test_validate_command_stdio_missing_command_early_exit() {
1213        let temp = write_test_mcp_config(
1214            r#"{"mcpServers": {"badstdio": {"command": "this_command_definitely_does_not_exist_12345"}}}"#,
1215        );
1216
1217        let result = with_home_pointed_at(temp.path(), || {
1218            run(
1219                ServerAction::Validate {
1220                    command: "badstdio".to_string(),
1221                },
1222                OutputFormat::Json,
1223            )
1224        })
1225        .await;
1226
1227        assert_eq!(result.unwrap(), ExitCode::ERROR);
1228    }
1229
1230    #[cfg(unix)]
1231    #[tokio::test]
1232    async fn test_validate_command_well_formed_but_unreachable_http_url_fails_post_introspection() {
1233        // Well-formed per `url_well_formed` (http scheme, present host), so
1234        // this skips the early-exit branch and reaches real introspection,
1235        // which fails because nothing listens on port 1 (a privileged port
1236        // no test server binds to) — proving the http/sse post-introspection
1237        // failure branch is reachable, not dead code.
1238        let temp = write_test_mcp_config(
1239            r#"{"mcpServers": {"unreachable": {"type": "http", "url": "http://127.0.0.1:1/mcp"}}}"#,
1240        );
1241
1242        let result = with_home_pointed_at(temp.path(), || {
1243            run(
1244                ServerAction::Validate {
1245                    command: "unreachable".to_string(),
1246                },
1247                OutputFormat::Json,
1248            )
1249        })
1250        .await;
1251
1252        assert_eq!(result.unwrap(), ExitCode::ERROR);
1253    }
1254
1255    #[cfg(unix)]
1256    #[tokio::test]
1257    async fn test_validate_command_scheme_failure_does_not_reach_precheck_bypass() {
1258        // Regression test for #304: an entry that passes the `url_well_formed` precheck (a
1259        // syntactically fine https URL with a host) but fails `build_core_config`'s deeper
1260        // security validation (here: a zero connect timeout) must still resolve as
1261        // "entry present, invalid configuration" — not silently skip validation and proceed to
1262        // introspection, and not report a "not found" message either.
1263        let temp = write_test_mcp_config(
1264            r#"{"mcpServers": {"badtimeout": {"type": "http", "url": "https://example.com/mcp", "connectTimeoutSecs": 0}}}"#,
1265        );
1266
1267        let result = with_home_pointed_at(temp.path(), || {
1268            run(
1269                ServerAction::Validate {
1270                    command: "badtimeout".to_string(),
1271                },
1272                OutputFormat::Json,
1273            )
1274        })
1275        .await;
1276
1277        assert_eq!(result.unwrap(), ExitCode::ERROR);
1278    }
1279
1280    #[cfg(unix)]
1281    #[tokio::test]
1282    async fn test_show_server_info_invalid_url_scheme_reports_structured_unavailable_not_raw_error()
1283    {
1284        // Regression test for #305: `server info` on an entry whose `url` fails scheme
1285        // validation must return the structured `"status": "unavailable"` `ServerInfo` output
1286        // through the normal `ExitCode` path, like the well-formed-but-unreachable case — not
1287        // propagate a raw, unformatted `anyhow` error via `?`.
1288        let temp = write_test_mcp_config(
1289            r#"{"mcpServers": {"http-malformed": {"type": "http", "url": "not-a-url"}}}"#,
1290        );
1291
1292        let result = with_home_pointed_at(temp.path(), || {
1293            run(
1294                ServerAction::Info {
1295                    server: "http-malformed".to_string(),
1296                },
1297                OutputFormat::Json,
1298            )
1299        })
1300        .await;
1301
1302        assert_eq!(
1303            result.expect("must return Ok(ExitCode::ERROR), not propagate a raw Err"),
1304            ExitCode::ERROR
1305        );
1306    }
1307
1308    #[test]
1309    fn test_unavailable_server_info_reports_unavailable_status() {
1310        // Direct coverage for #305's structured-body claim: `show_server_info`'s invalid-config
1311        // and failed-introspection branches both build the reported `ServerInfo` through this
1312        // helper, so asserting its output here confirms the JSON body actually carries
1313        // `"status": "unavailable"` — the ExitCode-only end-to-end tests above cannot observe
1314        // this crate's `println!`-only output (see `with_home_pointed_at` test comments).
1315        let info = unavailable_server_info("http-malformed".to_string(), "curl".to_string());
1316
1317        assert_eq!(info.status, ServerStatus::Unavailable);
1318        assert_eq!(info.id, "http-malformed");
1319        assert_eq!(info.name, "http-malformed");
1320        assert!(info.tools.is_empty());
1321        assert!(info.capabilities.is_empty());
1322
1323        let json = serde_json::to_string(&info).unwrap();
1324        assert!(json.contains("\"status\":\"unavailable\""));
1325    }
1326
1327    // ── validate_command config-load vs. name-lookup error paths (#479) ──
1328    //
1329    // `validate_command`'s message field is only ever printed to stdout (see
1330    // `with_home_pointed_at` doc comment above), so — like the #280/#304 regression tests above
1331    // — these assert on `ExitCode` only. The message-content distinction itself is covered
1332    // directly in `common.rs`'s `test_config_load_and_lookup_errors_are_distinguishable`; what
1333    // these confirm is that all three underlying conditions (missing config file, malformed
1334    // JSON, genuinely absent server name) are reachable through `validate_command` and still
1335    // resolve to a structured `ExitCode::ERROR` result rather than an unhandled `Err`.
1336
1337    #[cfg(unix)]
1338    #[tokio::test]
1339    async fn test_validate_command_missing_config_file_reports_error() {
1340        // HOME points at a fresh temp dir with no `.claude/mcp.json` at all.
1341        let temp = tempfile::TempDir::new().unwrap();
1342
1343        let result = with_home_pointed_at(temp.path(), || {
1344            run(
1345                ServerAction::Validate {
1346                    command: "anything".to_string(),
1347                },
1348                OutputFormat::Json,
1349            )
1350        })
1351        .await;
1352
1353        assert_eq!(result.unwrap(), ExitCode::ERROR);
1354    }
1355
1356    #[cfg(unix)]
1357    #[tokio::test]
1358    async fn test_validate_command_malformed_config_reports_error() {
1359        let temp = write_test_mcp_config("not valid json");
1360
1361        let result = with_home_pointed_at(temp.path(), || {
1362            run(
1363                ServerAction::Validate {
1364                    command: "anything".to_string(),
1365                },
1366                OutputFormat::Json,
1367            )
1368        })
1369        .await;
1370
1371        assert_eq!(result.unwrap(), ExitCode::ERROR);
1372    }
1373
1374    #[cfg(unix)]
1375    #[tokio::test]
1376    async fn test_validate_command_unknown_server_name_reports_error() {
1377        let temp = write_test_mcp_config(r#"{"mcpServers": {"unrelated": {"command": "node"}}}"#);
1378
1379        let result = with_home_pointed_at(temp.path(), || {
1380            run(
1381                ServerAction::Validate {
1382                    command: "nonexistent-server".to_string(),
1383                },
1384                OutputFormat::Json,
1385            )
1386        })
1387        .await;
1388
1389        assert_eq!(result.unwrap(), ExitCode::ERROR);
1390    }
1391
1392    #[test]
1393    fn test_validation_result_serialization() {
1394        let result = ValidationResult {
1395            command: "test".to_string(),
1396            valid: true,
1397            message: "ok".to_string(),
1398        };
1399
1400        let json = serde_json::to_string(&result).unwrap();
1401        assert!(json.contains("command"));
1402        assert!(json.contains("valid"));
1403        assert!(json.contains("message"));
1404    }
1405}