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::{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/// Status of a configured server.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum ServerStatus {
17    /// Server command exists and is executable.
18    Available,
19    /// Server command not found in PATH.
20    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/// Represents a configured server entry for output.
33#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34pub struct ServerEntry {
35    /// Server identifier.
36    pub id: String,
37    /// Command used to start the server.
38    pub command: String,
39    /// Current server status.
40    pub status: String,
41}
42
43/// List of configured servers.
44#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
45pub struct ServerList {
46    /// All configured servers.
47    pub servers: Vec<ServerEntry>,
48}
49
50/// Detailed server information for output.
51#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
52pub struct ServerInfo {
53    /// Server identifier.
54    pub id: String,
55    /// Server name from introspection.
56    pub name: String,
57    /// Server version.
58    pub version: String,
59    /// Command used to start the server.
60    pub command: String,
61    /// Current server status.
62    pub status: String,
63    /// Available tools.
64    pub tools: Vec<ToolSummary>,
65    /// Server capabilities.
66    pub capabilities: Vec<String>,
67}
68
69/// Tool summary for output.
70#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
71pub struct ToolSummary {
72    /// Tool name.
73    pub name: String,
74    /// Tool description.
75    pub description: String,
76}
77
78/// Validation result for a server command.
79#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
80pub struct ValidationResult {
81    /// The validated command.
82    pub command: String,
83    /// Whether the command is valid.
84    pub valid: bool,
85    /// Validation message.
86    pub message: String,
87}
88
89/// Runs the server command.
90///
91/// Manages server listing, detailed info, and validation.
92/// All server definitions are loaded from `~/.claude/mcp.json`.
93///
94/// # Arguments
95///
96/// * `action` - Server management action (List, Info, or Validate)
97/// * `output_format` - Output format (json, text, pretty)
98///
99/// # Errors
100///
101/// Returns an error if:
102/// - The configuration file cannot be read or is malformed
103/// - For the `Info` action, the named server is not found in the configuration
104/// - Output formatting fails (serialization error)
105///
106/// Note: For the `Validate` action, an unknown server name is reported via
107/// `ExitCode::ERROR` rather than returning `Err`. Server introspection failures
108/// (for both `Info` and `Validate`) are also caught internally and reported via
109/// `ExitCode::ERROR`.
110///
111/// # Examples
112///
113/// ```no_run
114/// use mcp_execution_cli::commands::server;
115/// use mcp_execution_core::cli::{ExitCode, OutputFormat};
116///
117/// # #[tokio::main]
118/// # async fn main() {
119/// let result = server::run(
120///     mcp_execution_cli::ServerAction::List,
121///     OutputFormat::Json
122/// ).await;
123/// assert!(result.is_ok());
124/// # }
125/// ```
126pub 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
137/// Lists all servers configured in `~/.claude/mcp.json`.
138///
139/// Returns an empty list (not an error) when the config file does not exist.
140async 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
177/// Shows detailed information about a specific server.
178///
179/// Connects to the server and introspects its capabilities, tools, and status.
180async 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
249/// Validates a server by checking its command and attempting introspection.
250///
251/// The server must be configured in `~/.claude/mcp.json`.
252async 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
317/// Builds a displayable command string from a server entry.
318fn 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
326/// Returns `true` if the given command binary is available in PATH.
327fn 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    /// Serializes tests that mutate the `HOME` env var so they cannot race
441    /// each other when run in the same process (e.g. under plain `cargo
442    /// test`, unlike `cargo nextest`, which isolates each test in its own
443    /// process). An async-aware mutex, since the guard must stay held across
444    /// the `.await` of the code under test.
445    #[cfg(unix)]
446    static HOME_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
447
448    // Unix-only: redirects `dirs::home_dir()` by mutating `HOME`, which
449    // `dirs` only consults on Unix. On Windows `dirs::home_dir()` resolves
450    // via `SHGetKnownFolderPath(FOLDERID_Profile)`, a Win32 API that reads
451    // the real OS user profile and ignores environment variables entirely
452    // — no env var override can redirect it.
453    #[cfg(unix)]
454    #[tokio::test]
455    async fn test_show_server_info_not_found_error_not_duplicated() {
456        // Regression test for #164: the "not found" message from
457        // `get_mcp_server` must propagate unwrapped, not get re-wrapped by
458        // an equivalent, less complete `with_context` in `show_server_info`.
459        //
460        // Hermetic: HOME is pointed at a temp dir with a controlled
461        // mcp.json that defines an unrelated server, so the "not found"
462        // branch inside `get_mcp_server` is deterministically reached
463        // regardless of the executing machine's real HOME. Without this, a
464        // clean CI runner with no `~/.claude/mcp.json` at all would instead
465        // fail earlier at the "read config file" step, and the regression
466        // this test exists to catch would never actually be exercised.
467        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        // SAFETY: guarded by `HOME_ENV_LOCK`; no other test in this process
480        // reads or writes `HOME` while the guard is held.
481        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        // SAFETY: see above.
494        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}