use crate::actions::ServerAction;
use crate::commands::common::{
McpServerEntry, McpTransport, build_core_config, get_mcp_server_entry, list_mcp_servers,
};
use crate::formatters::escape_error_text;
use anyhow::{Context, Result};
use mcp_execution_core::ServerConfig;
use mcp_execution_core::ServerId;
use mcp_execution_core::cli::{ExitCode, OutputFormat};
use mcp_execution_core::{REDACTED_PLACEHOLDER, RedactedUrl, sanitize_path_for_error};
use mcp_execution_introspector::Introspector;
use serde::Serialize;
use std::path::Path;
use std::time::Duration;
use tracing::{info, warn};
use url::Url;
const LIST_AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ServerStatus {
Available,
Unavailable,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServerEntry {
pub id: String,
pub command: String,
pub status: ServerStatus,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServerList {
pub servers: Vec<ServerEntry>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServerInfo {
pub id: String,
pub name: String,
pub version: String,
pub command: String,
pub status: ServerStatus,
pub tools: Vec<ToolSummary>,
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ToolSummary {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ValidationResult {
pub command: String,
pub valid: bool,
pub message: String,
}
pub async fn run(action: ServerAction, output_format: OutputFormat) -> Result<ExitCode> {
info!("Server action: {:?}", action);
info!("Output format: {}", output_format);
match action {
ServerAction::List => list_servers(output_format).await,
ServerAction::Info { server } => show_server_info(server, output_format).await,
ServerAction::Validate { command } => validate_command(command, output_format).await,
}
}
async fn list_servers(output_format: OutputFormat) -> Result<ExitCode> {
let servers = list_mcp_servers()
.context("failed to read server configuration from ~/.claude/mcp.json")?;
if servers.is_empty() {
info!("No MCP servers configured in ~/.claude/mcp.json");
let server_list = ServerList {
servers: Vec::new(),
};
let formatted = crate::formatters::format_output(&server_list, output_format)?;
println!("{formatted}");
return Ok(ExitCode::SUCCESS);
}
let checks = servers.into_iter().map(|(name, entry)| async move {
let command = build_command_string(&entry);
let status = if transport_available(&name, &entry).await {
ServerStatus::Available
} else {
ServerStatus::Unavailable
};
ServerEntry {
id: name,
command,
status,
}
});
let entries = futures_util::future::join_all(checks).await;
let server_list = ServerList { servers: entries };
let formatted = crate::formatters::format_output(&server_list, output_format)?;
println!("{formatted}");
Ok(ExitCode::SUCCESS)
}
async fn show_server_info(server: String, output_format: OutputFormat) -> Result<ExitCode> {
let (server_id, entry) = get_mcp_server_entry(&server)?;
let command = build_command_string(&entry);
let server_config = match build_core_config(&entry) {
Ok(config) => config,
Err(e) => {
warn!(
"Server '{}' has an invalid configuration: {}",
server,
escape_error_text(&e.to_string())
);
let formatted = crate::formatters::format_output(
&unavailable_server_info(server, command),
output_format,
)?;
println!("{formatted}");
return Ok(ExitCode::ERROR);
}
};
info!("Introspecting server '{}'...", server);
let mut introspector = Introspector::new();
match introspector
.discover_server(server_id, &server_config)
.await
{
Ok(introspected) => {
let mut capabilities = Vec::new();
if introspected.capabilities.supports_tools {
capabilities.push("tools".to_string());
}
if introspected.capabilities.supports_resources {
capabilities.push("resources".to_string());
}
if introspected.capabilities.supports_prompts {
capabilities.push("prompts".to_string());
}
let tools = introspected
.tools
.iter()
.map(|t| ToolSummary {
name: t.name.as_str().to_string(),
description: t.description.clone(),
})
.collect();
let server_info = ServerInfo {
id: server,
name: introspected.name,
version: introspected.version,
command,
status: ServerStatus::Available,
tools,
capabilities,
};
let formatted = crate::formatters::format_output(&server_info, output_format)?;
println!("{formatted}");
Ok(ExitCode::SUCCESS)
}
Err(e) => {
warn!(
"Failed to introspect server '{}': {}",
server,
escape_error_text(&e.to_string())
);
let formatted = crate::formatters::format_output(
&unavailable_server_info(server, command),
output_format,
)?;
println!("{formatted}");
Ok(ExitCode::ERROR)
}
}
}
fn unavailable_server_info(server: String, command: String) -> ServerInfo {
ServerInfo {
id: server.clone(),
name: server,
version: "unknown".to_string(),
command,
status: ServerStatus::Unavailable,
tools: Vec::new(),
capabilities: Vec::new(),
}
}
async fn validate_command(server_name: String, output_format: OutputFormat) -> Result<ExitCode> {
let (server_id, entry) = match get_mcp_server_entry(&server_name) {
Ok(result) => result,
Err(e) => {
let result = ValidationResult {
command: server_name,
valid: false,
message: format!("Server not found in configuration: {e}"),
};
let formatted = crate::formatters::format_output(&result, output_format)?;
println!("{formatted}");
return Ok(ExitCode::ERROR);
}
};
let command = build_command_string(&entry);
info!("Validating server '{}'...", server_name);
let precheck_failure = match &entry.transport {
McpTransport::Stdio {
command: bin_command,
..
} => (!check_command_exists(bin_command))
.then(|| format!("Command '{bin_command}' not found in PATH")),
McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
(!url_well_formed(url)).then(|| url_precheck_message(url))
}
};
if let Some(message) = precheck_failure {
let result = ValidationResult {
command: command.clone(),
valid: false,
message,
};
let formatted = crate::formatters::format_output(&result, output_format)?;
println!("{formatted}");
return Ok(ExitCode::ERROR);
}
let server_config = match build_core_config(&entry) {
Ok(config) => config,
Err(e) => {
let result = ValidationResult {
command,
valid: false,
message: format!("Server '{server_name}' has an invalid configuration: {e}"),
};
let formatted = crate::formatters::format_output(&result, output_format)?;
println!("{formatted}");
return Ok(ExitCode::ERROR);
}
};
let mut introspector = Introspector::new();
match introspector
.discover_server(server_id, &server_config)
.await
{
Ok(_) => {
let result = ValidationResult {
command,
valid: true,
message: format!(
"Server '{server_name}' is available and responds to MCP protocol"
),
};
let formatted = crate::formatters::format_output(&result, output_format)?;
println!("{formatted}");
Ok(ExitCode::SUCCESS)
}
Err(e) => {
warn!(
"Failed to introspect server '{}' during validation: {}",
server_name,
escape_error_text(&e.to_string())
);
let message = match &entry.transport {
McpTransport::Stdio { .. } => format!(
"Server '{server_name}' command exists but failed to respond to MCP protocol"
),
McpTransport::Http { .. } | McpTransport::Sse { .. } => {
format!("Server '{server_name}' endpoint failed to respond to MCP protocol")
}
};
let result = ValidationResult {
command,
valid: false,
message,
};
let formatted = crate::formatters::format_output(&result, output_format)?;
println!("{formatted}");
Ok(ExitCode::ERROR)
}
}
}
fn build_command_string(entry: &McpServerEntry) -> String {
match &entry.transport {
McpTransport::Stdio { command, args, .. } => {
let command = sanitize_path_for_error(Path::new(command));
if args.is_empty() {
command
} else {
let redacted_args = vec![REDACTED_PLACEHOLDER; args.len()].join(" ");
format!("{command} {redacted_args}")
}
}
McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
format!("{:?}", RedactedUrl(url))
}
}
}
fn url_precheck_message(url: &str) -> String {
format!(
"URL '{:?}' is not well-formed (expected http:// or https:// with a host)",
RedactedUrl(url)
)
}
fn check_command_exists(command: &str) -> bool {
which::which(command).is_ok()
}
fn url_well_formed(url: &str) -> bool {
mcp_execution_core::validate_url_scheme(url).is_ok()
&& Url::parse(url).is_ok_and(|parsed| parsed.host().is_some())
}
async fn transport_available(name: &str, entry: &McpServerEntry) -> bool {
match &entry.transport {
McpTransport::Stdio { command, .. } => check_command_exists(command),
McpTransport::Http { url, .. } | McpTransport::Sse { url, .. } => {
if !url_well_formed(url) {
return false;
}
let Ok(server_config) = build_core_config(entry) else {
return false;
};
discover_within(name, &server_config, LIST_AVAILABILITY_TIMEOUT).await
}
}
}
async fn discover_within(name: &str, config: &ServerConfig, timeout: Duration) -> bool {
let Ok(server_id) = ServerId::new(name) else {
return false;
};
tokio::time::timeout(
timeout,
Introspector::new().discover_server(server_id, config),
)
.await
.is_ok_and(|result| result.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_server_status_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&ServerStatus::Available).unwrap(),
"\"available\""
);
assert_eq!(
serde_json::to_string(&ServerStatus::Unavailable).unwrap(),
"\"unavailable\""
);
}
#[test]
fn test_build_command_string_no_args() {
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: "node".to_string(),
args: Vec::new(),
env: HashMap::default(),
cwd: None,
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert_eq!(build_command_string(&entry), "node");
}
#[test]
fn test_build_command_string_with_args() {
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: "node".to_string(),
args: vec!["/path/to/server.js".to_string(), "--verbose".to_string()],
env: HashMap::default(),
cwd: None,
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let command = build_command_string(&entry);
assert_eq!(
command,
format!("node {REDACTED_PLACEHOLDER} {REDACTED_PLACEHOLDER}")
);
assert!(!command.contains("/path/to/server.js"));
assert!(!command.contains("--verbose"));
}
#[test]
fn test_build_command_string_redacts_secret_arg() {
let secret = "sk-live-secret-arg-value";
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: "docker".to_string(),
args: vec!["--api-key".to_string(), secret.to_string()],
env: HashMap::default(),
cwd: None,
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let command = build_command_string(&entry);
assert!(command.starts_with("docker "));
assert!(!command.contains(secret));
assert_eq!(command.matches(REDACTED_PLACEHOLDER).count(), 2);
}
#[test]
fn test_build_command_string_sanitizes_command_home_path() {
let home = dirs::home_dir().expect("home dir available in this environment");
let entry = McpServerEntry {
transport: McpTransport::Stdio {
command: home.join("bin/mcp-server").to_string_lossy().into_owned(),
args: Vec::new(),
env: HashMap::default(),
cwd: None,
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let command = build_command_string(&entry);
assert_eq!(
command,
format!(
"~{}bin{}mcp-server",
std::path::MAIN_SEPARATOR,
std::path::MAIN_SEPARATOR
)
);
}
#[test]
fn test_build_command_string_http() {
let entry = McpServerEntry {
transport: McpTransport::Http {
url: "https://api.example.com/mcp".to_string(),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert_eq!(build_command_string(&entry), "https://api.example.com/mcp");
}
#[test]
fn test_build_command_string_redacts_secret_url() {
let secret = "hunter2";
let entry = McpServerEntry {
transport: McpTransport::Http {
url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
let command = build_command_string(&entry);
assert!(!command.contains(secret));
assert!(command.contains("api.example.com/mcp"));
}
#[test]
fn test_build_command_string_unparseable_url_redacts_entirely() {
let entry = McpServerEntry {
transport: McpTransport::Http {
url: "not-a-url".to_string(),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert_eq!(build_command_string(&entry), REDACTED_PLACEHOLDER);
}
#[test]
fn test_url_precheck_message_redacts_credentials() {
let secret = "hunter2";
let message =
url_precheck_message(&format!("https://alice:{secret}@api.example.com:99999/mcp"));
assert!(!message.contains(secret));
assert!(message.contains("api.example.com"));
}
#[tokio::test]
async fn test_transport_available_http_well_formed_but_unreachable_false() {
let entry = McpServerEntry {
transport: McpTransport::Http {
url: "http://127.0.0.1:1/mcp".to_string(),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert!(!transport_available("unreachable", &entry).await);
}
#[tokio::test]
async fn test_discover_within_times_out_on_unresponsive_endpoint() {
let config = ServerConfig::builder()
.http_transport("http://192.0.2.1:9/mcp".to_string())
.build()
.unwrap();
let start = std::time::Instant::now();
let available = discover_within("timeout-test", &config, Duration::from_millis(200)).await;
let elapsed = start.elapsed();
assert!(!available);
assert!(
elapsed < Duration::from_secs(2),
"expected the short timeout to cut this attempt short, took {elapsed:?}"
);
}
#[tokio::test]
async fn test_transport_available_http_malformed_false() {
let entry = McpServerEntry {
transport: McpTransport::Http {
url: "not-a-url".to_string(),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert!(!transport_available("badhttp", &entry).await);
}
#[tokio::test]
async fn test_transport_available_sse_malformed_false() {
let entry = McpServerEntry {
transport: McpTransport::Sse {
url: "ftp://example.com".to_string(),
headers: HashMap::default(),
},
connect_timeout_secs: None,
discover_timeout_secs: None,
};
assert!(!transport_available("badsse", &entry).await);
}
#[test]
fn test_url_well_formed_http_valid() {
assert!(url_well_formed("http://example.com"));
}
#[test]
fn test_url_well_formed_https_valid() {
assert!(url_well_formed("https://example.com/mcp"));
}
#[test]
fn test_url_well_formed_wrong_scheme_ftp() {
assert!(!url_well_formed("ftp://example.com"));
}
#[test]
fn test_url_well_formed_wrong_scheme_file() {
assert!(!url_well_formed("file:///etc/passwd"));
}
#[test]
fn test_url_well_formed_rejects_leading_whitespace() {
assert!(!url_well_formed(" https://example.com/mcp"));
}
#[test]
fn test_url_well_formed_rejects_trailing_control_chars() {
assert!(!url_well_formed("\thttps://example.com/mcp\n"));
}
#[test]
fn test_url_well_formed_no_host() {
assert!(!url_well_formed("http://"));
}
#[test]
fn test_url_well_formed_malformed_no_scheme() {
assert!(!url_well_formed("not-a-url"));
}
#[test]
fn test_url_well_formed_empty_string() {
assert!(!url_well_formed(""));
}
#[test]
fn test_check_command_exists() {
assert!(check_command_exists("ls"));
assert!(!check_command_exists(
"this_command_definitely_does_not_exist_12345"
));
}
#[test]
fn test_server_entry_serialization() {
let entry = ServerEntry {
id: "test".to_string(),
command: "test-cmd".to_string(),
status: ServerStatus::Available,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("test"));
assert!(json.contains("test-cmd"));
assert!(json.contains("available"));
}
#[test]
fn test_server_list_serialization() {
let list = ServerList {
servers: vec![ServerEntry {
id: "test".to_string(),
command: "test-cmd".to_string(),
status: ServerStatus::Available,
}],
};
let json = serde_json::to_string(&list).unwrap();
assert!(json.contains("servers"));
assert!(json.contains("test"));
}
#[test]
fn test_server_info_serialization() {
let info = ServerInfo {
id: "test".to_string(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
command: "test-cmd".to_string(),
status: ServerStatus::Available,
tools: vec![ToolSummary {
name: "test_tool".to_string(),
description: "A test tool".to_string(),
}],
capabilities: vec!["tools".to_string()],
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test"));
assert!(json.contains("Test Server"));
assert!(json.contains("capabilities"));
assert!(json.contains("tools"));
}
#[test]
fn test_tool_summary_serialization() {
let tool = ToolSummary {
name: "send_message".to_string(),
description: "Sends a message".to_string(),
};
let json = serde_json::to_string(&tool).unwrap();
assert!(json.contains("send_message"));
assert!(json.contains("Sends a message"));
}
#[cfg(unix)]
static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(unix)]
#[tokio::test]
async fn test_show_server_info_not_found_error_not_duplicated() {
let _guard = HOME_ENV_LOCK.lock().await;
let temp = tempfile::TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("mcp.json"),
r#"{"mcpServers": {"unrelated": {"command": "node"}}}"#,
)
.unwrap();
let original_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", temp.path());
}
let result = run(
ServerAction::Info {
server: "nonexistent-server".to_string(),
},
OutputFormat::Json,
)
.await;
unsafe {
match &original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
assert!(result.is_err());
let message = format!("{:#}", result.unwrap_err());
assert_eq!(
message.matches("not found in ~/.claude/mcp.json").count(),
1,
"expected exactly one not-found message in the error chain, got: {message}"
);
}
#[cfg(unix)]
async fn with_home_pointed_at<F, Fut, T>(home_dir: &std::path::Path, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let _guard = HOME_ENV_LOCK.lock().await;
let original_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", home_dir);
}
let result = f().await;
unsafe {
match &original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
}
result
}
#[cfg(unix)]
fn write_test_mcp_config(content: &str) -> tempfile::TempDir {
let temp = tempfile::TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(claude_dir.join("mcp.json"), content).unwrap();
temp
}
#[cfg(unix)]
#[tokio::test]
async fn test_validate_command_malformed_http_url_early_exit() {
let temp = write_test_mcp_config(
r#"{"mcpServers": {"badhttp": {"type": "http", "url": "not-a-url"}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Validate {
command: "badhttp".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(result.unwrap(), ExitCode::ERROR);
}
#[cfg(unix)]
#[tokio::test]
async fn test_validate_command_malformed_sse_url_early_exit() {
let temp = write_test_mcp_config(
r#"{"mcpServers": {"badsse": {"type": "sse", "url": "ftp://example.com"}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Validate {
command: "badsse".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(result.unwrap(), ExitCode::ERROR);
}
#[cfg(unix)]
#[tokio::test]
async fn test_validate_command_stdio_missing_command_early_exit() {
let temp = write_test_mcp_config(
r#"{"mcpServers": {"badstdio": {"command": "this_command_definitely_does_not_exist_12345"}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Validate {
command: "badstdio".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(result.unwrap(), ExitCode::ERROR);
}
#[cfg(unix)]
#[tokio::test]
async fn test_validate_command_well_formed_but_unreachable_http_url_fails_post_introspection() {
let temp = write_test_mcp_config(
r#"{"mcpServers": {"unreachable": {"type": "http", "url": "http://127.0.0.1:1/mcp"}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Validate {
command: "unreachable".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(result.unwrap(), ExitCode::ERROR);
}
#[cfg(unix)]
#[tokio::test]
async fn test_validate_command_scheme_failure_does_not_reach_precheck_bypass() {
let temp = write_test_mcp_config(
r#"{"mcpServers": {"badtimeout": {"type": "http", "url": "https://example.com/mcp", "connectTimeoutSecs": 0}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Validate {
command: "badtimeout".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(result.unwrap(), ExitCode::ERROR);
}
#[cfg(unix)]
#[tokio::test]
async fn test_show_server_info_invalid_url_scheme_reports_structured_unavailable_not_raw_error()
{
let temp = write_test_mcp_config(
r#"{"mcpServers": {"http-malformed": {"type": "http", "url": "not-a-url"}}}"#,
);
let result = with_home_pointed_at(temp.path(), || {
run(
ServerAction::Info {
server: "http-malformed".to_string(),
},
OutputFormat::Json,
)
})
.await;
assert_eq!(
result.expect("must return Ok(ExitCode::ERROR), not propagate a raw Err"),
ExitCode::ERROR
);
}
#[test]
fn test_unavailable_server_info_reports_unavailable_status() {
let info = unavailable_server_info("http-malformed".to_string(), "curl".to_string());
assert_eq!(info.status, ServerStatus::Unavailable);
assert_eq!(info.id, "http-malformed");
assert_eq!(info.name, "http-malformed");
assert!(info.tools.is_empty());
assert!(info.capabilities.is_empty());
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"status\":\"unavailable\""));
}
#[test]
fn test_validation_result_serialization() {
let result = ValidationResult {
command: "test".to_string(),
valid: true,
message: "ok".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("command"));
assert!(json.contains("valid"));
assert!(json.contains("message"));
}
}