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}