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}