1use crate::actions::ServerAction;
7use crate::commands::common::{McpServerEntry, get_mcp_server, list_mcp_servers};
8use anyhow::{Context, Result};
9use mcp_execution_core::cli::{ExitCode, OutputFormat};
10use mcp_execution_introspector::Introspector;
11use serde::Serialize;
12use tracing::{info, warn};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum ServerStatus {
17 Available,
19 Unavailable,
21}
22
23impl ServerStatus {
24 const fn as_str(self) -> &'static str {
25 match self {
26 Self::Available => "available",
27 Self::Unavailable => "unavailable",
28 }
29 }
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34pub struct ServerEntry {
35 pub id: String,
37 pub command: String,
39 pub status: String,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
45pub struct ServerList {
46 pub servers: Vec<ServerEntry>,
48}
49
50#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
52pub struct ServerInfo {
53 pub id: String,
55 pub name: String,
57 pub version: String,
59 pub command: String,
61 pub status: String,
63 pub tools: Vec<ToolSummary>,
65 pub capabilities: Vec<String>,
67}
68
69#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
71pub struct ToolSummary {
72 pub name: String,
74 pub description: String,
76}
77
78#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
80pub struct ValidationResult {
81 pub command: String,
83 pub valid: bool,
85 pub message: String,
87}
88
89pub async fn run(action: ServerAction, output_format: OutputFormat) -> Result<ExitCode> {
127 info!("Server action: {:?}", action);
128 info!("Output format: {}", output_format);
129
130 match action {
131 ServerAction::List => list_servers(output_format).await,
132 ServerAction::Info { server } => show_server_info(server, output_format).await,
133 ServerAction::Validate { command } => validate_command(command, output_format).await,
134 }
135}
136
137async fn list_servers(output_format: OutputFormat) -> Result<ExitCode> {
141 let servers = list_mcp_servers()
142 .context("failed to read server configuration from ~/.claude/mcp.json")?;
143
144 if servers.is_empty() {
145 info!("No MCP servers configured in ~/.claude/mcp.json");
146 let server_list = ServerList {
147 servers: Vec::new(),
148 };
149 let formatted = crate::formatters::format_output(&server_list, output_format)?;
150 println!("{formatted}");
151 return Ok(ExitCode::SUCCESS);
152 }
153
154 let mut entries = Vec::new();
155 for (name, entry) in servers {
156 let command = build_command_string(&entry);
157 let status = if check_command_exists(&entry.command) {
158 ServerStatus::Available
159 } else {
160 ServerStatus::Unavailable
161 };
162
163 entries.push(ServerEntry {
164 id: name,
165 command,
166 status: status.as_str().to_string(),
167 });
168 }
169
170 let server_list = ServerList { servers: entries };
171 let formatted = crate::formatters::format_output(&server_list, output_format)?;
172 println!("{formatted}");
173
174 Ok(ExitCode::SUCCESS)
175}
176
177async fn show_server_info(server: String, output_format: OutputFormat) -> Result<ExitCode> {
181 let (server_id, server_config, entry) = get_mcp_server(&server)?;
182
183 let command = build_command_string(&entry);
184
185 info!("Introspecting server '{}'...", server);
186
187 let mut introspector = Introspector::new();
188 match introspector
189 .discover_server(server_id, &server_config)
190 .await
191 {
192 Ok(introspected) => {
193 let mut capabilities = Vec::new();
194 if introspected.capabilities.supports_tools {
195 capabilities.push("tools".to_string());
196 }
197 if introspected.capabilities.supports_resources {
198 capabilities.push("resources".to_string());
199 }
200 if introspected.capabilities.supports_prompts {
201 capabilities.push("prompts".to_string());
202 }
203
204 let tools = introspected
205 .tools
206 .iter()
207 .map(|t| ToolSummary {
208 name: t.name.as_str().to_string(),
209 description: t.description.clone(),
210 })
211 .collect();
212
213 let server_info = ServerInfo {
214 id: server,
215 name: introspected.name,
216 version: introspected.version,
217 command,
218 status: ServerStatus::Available.as_str().to_string(),
219 tools,
220 capabilities,
221 };
222
223 let formatted = crate::formatters::format_output(&server_info, output_format)?;
224 println!("{formatted}");
225
226 Ok(ExitCode::SUCCESS)
227 }
228 Err(e) => {
229 warn!("Failed to introspect server '{}': {}", server, e);
230
231 let server_info = ServerInfo {
232 id: server.clone(),
233 name: server,
234 version: "unknown".to_string(),
235 command,
236 status: ServerStatus::Unavailable.as_str().to_string(),
237 tools: Vec::new(),
238 capabilities: Vec::new(),
239 };
240
241 let formatted = crate::formatters::format_output(&server_info, output_format)?;
242 println!("{formatted}");
243
244 Ok(ExitCode::ERROR)
245 }
246 }
247}
248
249async fn validate_command(server_name: String, output_format: OutputFormat) -> Result<ExitCode> {
253 let (server_id, server_config, entry) = match get_mcp_server(&server_name) {
254 Ok(result) => result,
255 Err(e) => {
256 let result = ValidationResult {
257 command: server_name,
258 valid: false,
259 message: format!("Server not found in configuration: {e}"),
260 };
261 let formatted = crate::formatters::format_output(&result, output_format)?;
262 println!("{formatted}");
263 return Ok(ExitCode::ERROR);
264 }
265 };
266
267 let command = build_command_string(&entry);
268 info!("Validating server '{}'...", server_name);
269
270 if !check_command_exists(&entry.command) {
271 let result = ValidationResult {
272 command: command.clone(),
273 valid: false,
274 message: format!("Command '{}' not found in PATH", entry.command),
275 };
276 let formatted = crate::formatters::format_output(&result, output_format)?;
277 println!("{formatted}");
278 return Ok(ExitCode::ERROR);
279 }
280
281 let mut introspector = Introspector::new();
282 match introspector
283 .discover_server(server_id, &server_config)
284 .await
285 {
286 Ok(_) => {
287 let result = ValidationResult {
288 command,
289 valid: true,
290 message: format!(
291 "Server '{server_name}' is available and responds to MCP protocol"
292 ),
293 };
294 let formatted = crate::formatters::format_output(&result, output_format)?;
295 println!("{formatted}");
296 Ok(ExitCode::SUCCESS)
297 }
298 Err(e) => {
299 warn!(
300 "Failed to introspect server '{}' during validation: {}",
301 server_name, e
302 );
303 let result = ValidationResult {
304 command,
305 valid: false,
306 message: format!(
307 "Server '{server_name}' command exists but failed to respond to MCP protocol"
308 ),
309 };
310 let formatted = crate::formatters::format_output(&result, output_format)?;
311 println!("{formatted}");
312 Ok(ExitCode::ERROR)
313 }
314 }
315}
316
317fn build_command_string(entry: &McpServerEntry) -> String {
319 if entry.args.is_empty() {
320 entry.command.clone()
321 } else {
322 format!("{} {}", entry.command, entry.args.join(" "))
323 }
324}
325
326fn check_command_exists(command: &str) -> bool {
328 which::which(command).is_ok()
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use std::collections::HashMap;
335
336 #[test]
337 fn test_server_status_as_str() {
338 assert_eq!(ServerStatus::Available.as_str(), "available");
339 assert_eq!(ServerStatus::Unavailable.as_str(), "unavailable");
340 }
341
342 #[test]
343 fn test_build_command_string_no_args() {
344 let entry = McpServerEntry {
345 command: "node".to_string(),
346 args: Vec::new(),
347 env: HashMap::default(),
348 connect_timeout_secs: None,
349 discover_timeout_secs: None,
350 };
351 assert_eq!(build_command_string(&entry), "node");
352 }
353
354 #[test]
355 fn test_build_command_string_with_args() {
356 let entry = McpServerEntry {
357 command: "node".to_string(),
358 args: vec!["/path/to/server.js".to_string(), "--verbose".to_string()],
359 env: HashMap::default(),
360 connect_timeout_secs: None,
361 discover_timeout_secs: None,
362 };
363 assert_eq!(
364 build_command_string(&entry),
365 "node /path/to/server.js --verbose"
366 );
367 }
368
369 #[test]
370 fn test_check_command_exists() {
371 assert!(check_command_exists("ls"));
372 assert!(!check_command_exists(
373 "this_command_definitely_does_not_exist_12345"
374 ));
375 }
376
377 #[test]
378 fn test_server_entry_serialization() {
379 let entry = ServerEntry {
380 id: "test".to_string(),
381 command: "test-cmd".to_string(),
382 status: "available".to_string(),
383 };
384
385 let json = serde_json::to_string(&entry).unwrap();
386 assert!(json.contains("test"));
387 assert!(json.contains("test-cmd"));
388 assert!(json.contains("available"));
389 }
390
391 #[test]
392 fn test_server_list_serialization() {
393 let list = ServerList {
394 servers: vec![ServerEntry {
395 id: "test".to_string(),
396 command: "test-cmd".to_string(),
397 status: "available".to_string(),
398 }],
399 };
400
401 let json = serde_json::to_string(&list).unwrap();
402 assert!(json.contains("servers"));
403 assert!(json.contains("test"));
404 }
405
406 #[test]
407 fn test_server_info_serialization() {
408 let info = ServerInfo {
409 id: "test".to_string(),
410 name: "Test Server".to_string(),
411 version: "1.0.0".to_string(),
412 command: "test-cmd".to_string(),
413 status: "available".to_string(),
414 tools: vec![ToolSummary {
415 name: "test_tool".to_string(),
416 description: "A test tool".to_string(),
417 }],
418 capabilities: vec!["tools".to_string()],
419 };
420
421 let json = serde_json::to_string(&info).unwrap();
422 assert!(json.contains("test"));
423 assert!(json.contains("Test Server"));
424 assert!(json.contains("capabilities"));
425 assert!(json.contains("tools"));
426 }
427
428 #[test]
429 fn test_tool_summary_serialization() {
430 let tool = ToolSummary {
431 name: "send_message".to_string(),
432 description: "Sends a message".to_string(),
433 };
434
435 let json = serde_json::to_string(&tool).unwrap();
436 assert!(json.contains("send_message"));
437 assert!(json.contains("Sends a message"));
438 }
439
440 #[cfg(unix)]
446 static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
447
448 #[cfg(unix)]
454 #[tokio::test]
455 async fn test_show_server_info_not_found_error_not_duplicated() {
456 let _guard = HOME_ENV_LOCK.lock().await;
468
469 let temp = tempfile::TempDir::new().unwrap();
470 let claude_dir = temp.path().join(".claude");
471 std::fs::create_dir_all(&claude_dir).unwrap();
472 std::fs::write(
473 claude_dir.join("mcp.json"),
474 r#"{"mcpServers": {"unrelated": {"command": "node"}}}"#,
475 )
476 .unwrap();
477
478 let original_home = std::env::var_os("HOME");
479 unsafe {
482 std::env::set_var("HOME", temp.path());
483 }
484
485 let result = run(
486 ServerAction::Info {
487 server: "nonexistent-server".to_string(),
488 },
489 OutputFormat::Json,
490 )
491 .await;
492
493 unsafe {
495 match &original_home {
496 Some(home) => std::env::set_var("HOME", home),
497 None => std::env::remove_var("HOME"),
498 }
499 }
500
501 assert!(result.is_err());
502 let message = format!("{:#}", result.unwrap_err());
503 assert_eq!(
504 message.matches("not found in ~/.claude/mcp.json").count(),
505 1,
506 "expected exactly one not-found message in the error chain, got: {message}"
507 );
508 }
509
510 #[test]
511 fn test_validation_result_serialization() {
512 let result = ValidationResult {
513 command: "test".to_string(),
514 valid: true,
515 message: "ok".to_string(),
516 };
517
518 let json = serde_json::to_string(&result).unwrap();
519 assert!(json.contains("command"));
520 assert!(json.contains("valid"));
521 assert!(json.contains("message"));
522 }
523}