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