mcp_execution_cli/commands/
server.rs

1//! Server command implementation.
2//!
3//! Manages MCP server connections and configurations.
4
5use crate::actions::ServerAction;
6use anyhow::{Context, Result};
7use mcp_execution_core::cli::{ExitCode, OutputFormat};
8use mcp_execution_core::{ServerConfig as CoreServerConfig, ServerId};
9use mcp_execution_introspector::Introspector;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13use tracing::{debug, info, warn};
14
15/// Claude Desktop configuration file structure.
16///
17/// Represents the JSON structure of `claude_desktop_config.json`.
18#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
19struct ClaudeDesktopConfig {
20    #[serde(rename = "mcpServers")]
21    mcp_execution_servers: HashMap<String, ServerConfig>,
22}
23
24/// MCP server configuration from Claude Desktop config.
25///
26/// Represents a single server entry with command, args, and environment variables.
27#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
28struct ServerConfig {
29    /// Command to execute (e.g., "node", "python", "npx")
30    command: String,
31    /// Command arguments
32    #[serde(default)]
33    args: Vec<String>,
34    /// Environment variables
35    #[serde(default)]
36    env: HashMap<String, String>,
37}
38
39/// Status of a configured server.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum ServerStatus {
42    /// Server is available and responds
43    Available,
44    /// Server command not found or not executable
45    Unavailable,
46}
47
48impl ServerStatus {
49    const fn as_str(self) -> &'static str {
50        match self {
51            Self::Available => "available",
52            Self::Unavailable => "unavailable",
53        }
54    }
55}
56
57/// Represents a configured server entry for output.
58#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
59pub struct ServerEntry {
60    /// Server identifier
61    pub id: String,
62    /// Command used to start the server
63    pub command: String,
64    /// Current server status
65    pub status: String,
66}
67
68/// List of configured servers.
69#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
70pub struct ServerList {
71    /// All configured servers
72    pub servers: Vec<ServerEntry>,
73}
74
75/// Detailed server information for output.
76#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
77pub struct ServerInfo {
78    /// Server identifier
79    pub id: String,
80    /// Server name from introspection
81    pub name: String,
82    /// Server version
83    pub version: String,
84    /// Command used to start the server
85    pub command: String,
86    /// Current server status
87    pub status: String,
88    /// Available tools
89    pub tools: Vec<ToolSummary>,
90    /// Server capabilities
91    pub capabilities: Vec<String>,
92}
93
94/// Tool summary for output.
95#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
96pub struct ToolSummary {
97    /// Tool name
98    pub name: String,
99    /// Tool description
100    pub description: String,
101}
102
103/// Validation result for a server command.
104#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
105pub struct ValidationResult {
106    /// The validated command
107    pub command: String,
108    /// Whether the command is valid
109    pub valid: bool,
110    /// Validation message
111    pub message: String,
112}
113
114/// Manages MCP server discovery and validation.
115///
116/// Reads Claude Desktop configuration and provides server management operations.
117#[derive(Debug)]
118struct ServerManager {
119    config_path: PathBuf,
120}
121
122impl ServerManager {
123    /// Creates a new server manager.
124    ///
125    /// Discovers the Claude Desktop config file location automatically.
126    fn new() -> Result<Self> {
127        let config_path = Self::find_config_path()?;
128        Ok(Self { config_path })
129    }
130
131    /// Finds the Claude Desktop configuration file.
132    ///
133    /// Searches in platform-specific locations:
134    /// - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
135    /// - Linux: `~/.config/Claude/claude_desktop_config.json`
136    /// - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
137    fn find_config_path() -> Result<PathBuf> {
138        let home = dirs::home_dir().context("Failed to determine home directory")?;
139
140        let paths = if cfg!(target_os = "macos") {
141            vec![
142                home.join("Library")
143                    .join("Application Support")
144                    .join("Claude")
145                    .join("claude_desktop_config.json"),
146            ]
147        } else if cfg!(target_os = "windows") {
148            let appdata = std::env::var("APPDATA")
149                .map_or_else(|_| home.join("AppData").join("Roaming"), PathBuf::from);
150            vec![appdata.join("Claude").join("claude_desktop_config.json")]
151        } else {
152            // Linux and other Unix-like systems
153            vec![
154                home.join(".config")
155                    .join("Claude")
156                    .join("claude_desktop_config.json"),
157            ]
158        };
159
160        // Check environment variable override
161        if let Ok(custom_path) = std::env::var("CLAUDE_CONFIG_PATH") {
162            let custom = PathBuf::from(custom_path);
163            if custom.exists() {
164                debug!("Using config from CLAUDE_CONFIG_PATH: {}", custom.display());
165                return Ok(custom);
166            }
167        }
168
169        // Find first existing path
170        for path in paths {
171            if path.exists() {
172                debug!("Found Claude Desktop config at: {}", path.display());
173                return Ok(path);
174            }
175        }
176
177        anyhow::bail!(
178            "Claude Desktop configuration not found. \
179             Please ensure Claude Desktop is installed or set CLAUDE_CONFIG_PATH environment variable."
180        )
181    }
182
183    /// Reads and parses the Claude Desktop configuration file.
184    fn read_config(&self) -> Result<ClaudeDesktopConfig> {
185        let contents = std::fs::read_to_string(&self.config_path).context(format!(
186            "Failed to read config file: {}",
187            self.config_path.display()
188        ))?;
189
190        let config: ClaudeDesktopConfig = serde_json::from_str(&contents).context(format!(
191            "Failed to parse config file: {}",
192            self.config_path.display()
193        ))?;
194
195        Ok(config)
196    }
197
198    /// Lists all configured servers.
199    fn list_servers(&self) -> Result<Vec<(String, ServerConfig)>> {
200        let config = self.read_config()?;
201        Ok(config.mcp_execution_servers.into_iter().collect())
202    }
203
204    /// Gets configuration for a specific server.
205    fn get_server_config(&self, server_name: &str) -> Result<ServerConfig> {
206        let config = self.read_config()?;
207        config
208            .mcp_execution_servers
209            .get(server_name)
210            .cloned()
211            .context(format!("Server '{server_name}' not found in configuration"))
212    }
213
214    /// Builds the full command string for a server.
215    fn build_command_string(config: &ServerConfig) -> String {
216        if config.args.is_empty() {
217            config.command.clone()
218        } else {
219            format!("{} {}", config.command, config.args.join(" "))
220        }
221    }
222
223    /// Checks if a command is executable.
224    fn check_command_exists(command: &str) -> bool {
225        which::which(command).is_ok()
226    }
227
228    /// Validates a server by attempting to connect and introspect it.
229    async fn validate_server(&self, server_name: &str) -> Result<ServerStatus> {
230        let config = self.get_server_config(server_name)?;
231
232        // First check if command exists
233        if !Self::check_command_exists(&config.command) {
234            warn!(
235                "Command '{}' not found in PATH for server '{}'",
236                config.command, server_name
237            );
238            return Ok(ServerStatus::Unavailable);
239        }
240
241        // Try to connect and introspect
242        match self.introspect_server(server_name).await {
243            Ok(_) => Ok(ServerStatus::Available),
244            Err(e) => {
245                warn!("Failed to introspect server '{}': {}", server_name, e);
246                Ok(ServerStatus::Unavailable)
247            }
248        }
249    }
250
251    /// Introspects a server using mcp-introspector.
252    async fn introspect_server(
253        &self,
254        server_name: &str,
255    ) -> Result<mcp_execution_introspector::ServerInfo> {
256        let config = self.get_server_config(server_name)?;
257
258        let mut introspector = Introspector::new();
259        let server_id = ServerId::new(server_name);
260
261        // Build ServerConfig with proper args and env
262        let mut builder = CoreServerConfig::builder().command(config.command.clone());
263
264        if !config.args.is_empty() {
265            builder = builder.args(config.args.clone());
266        }
267
268        for (key, value) in &config.env {
269            builder = builder.env(key.clone(), value.clone());
270        }
271
272        let server_config = builder.build();
273
274        introspector
275            .discover_server(server_id, &server_config)
276            .await
277            .context(format!("Failed to introspect server '{server_name}'"))
278    }
279}
280
281/// Runs the server command.
282///
283/// Manages server connections, listing, and validation.
284///
285/// # Arguments
286///
287/// * `action` - Server management action
288/// * `output_format` - Output format (json, text, pretty)
289///
290/// # Errors
291///
292/// Returns an error if server operation fails.
293///
294/// # Examples
295///
296/// ```no_run
297/// use mcp_execution_cli::commands::server;
298/// use mcp_execution_core::cli::{ExitCode, OutputFormat};
299///
300/// # #[tokio::main]
301/// # async fn main() {
302/// let result = server::run(
303///     mcp_execution_cli::ServerAction::List,
304///     OutputFormat::Json
305/// ).await;
306/// assert!(result.is_ok());
307/// # }
308/// ```
309pub async fn run(action: ServerAction, output_format: OutputFormat) -> Result<ExitCode> {
310    info!("Server action: {:?}", action);
311    info!("Output format: {}", output_format);
312
313    match action {
314        ServerAction::List => list_servers(output_format).await,
315        ServerAction::Info { server } => show_server_info(server, output_format).await,
316        ServerAction::Validate { command } => validate_command(command, output_format).await,
317    }
318}
319
320/// Lists all configured servers.
321///
322/// Reads Claude Desktop configuration and returns list of all servers with their status.
323async fn list_servers(output_format: OutputFormat) -> Result<ExitCode> {
324    let manager = ServerManager::new().context("Failed to initialize server manager")?;
325
326    let servers = manager
327        .list_servers()
328        .context("Failed to read server configuration")?;
329
330    if servers.is_empty() {
331        info!("No MCP servers configured in Claude Desktop");
332        let server_list = ServerList {
333            servers: Vec::new(),
334        };
335        let formatted = crate::formatters::format_output(&server_list, output_format)?;
336        println!("{formatted}");
337        return Ok(ExitCode::SUCCESS);
338    }
339
340    // Build server entries
341    let mut entries = Vec::new();
342    for (name, config) in servers {
343        let command = ServerManager::build_command_string(&config);
344        let status = if ServerManager::check_command_exists(&config.command) {
345            ServerStatus::Available
346        } else {
347            ServerStatus::Unavailable
348        };
349
350        entries.push(ServerEntry {
351            id: name,
352            command,
353            status: status.as_str().to_string(),
354        });
355    }
356
357    let server_list = ServerList { servers: entries };
358
359    let formatted = crate::formatters::format_output(&server_list, output_format)?;
360    println!("{formatted}");
361
362    Ok(ExitCode::SUCCESS)
363}
364
365/// Shows detailed information about a specific server.
366///
367/// Connects to the server and introspects its capabilities, tools, and status.
368async fn show_server_info(server: String, output_format: OutputFormat) -> Result<ExitCode> {
369    let manager = ServerManager::new().context("Failed to initialize server manager")?;
370
371    let config = manager
372        .get_server_config(&server)
373        .context(format!("Server '{server}' not found in configuration"))?;
374
375    let command = ServerManager::build_command_string(&config);
376
377    // Try to introspect the server
378    info!("Introspecting server '{}'...", server);
379    match manager.introspect_server(&server).await {
380        Ok(introspected) => {
381            // Successfully introspected
382            let mut capabilities = Vec::new();
383            if introspected.capabilities.supports_tools {
384                capabilities.push("tools".to_string());
385            }
386            if introspected.capabilities.supports_resources {
387                capabilities.push("resources".to_string());
388            }
389            if introspected.capabilities.supports_prompts {
390                capabilities.push("prompts".to_string());
391            }
392
393            let tools = introspected
394                .tools
395                .iter()
396                .map(|t| ToolSummary {
397                    name: t.name.as_str().to_string(),
398                    description: t.description.clone(),
399                })
400                .collect();
401
402            let server_info = ServerInfo {
403                id: server,
404                name: introspected.name,
405                version: introspected.version,
406                command,
407                status: ServerStatus::Available.as_str().to_string(),
408                tools,
409                capabilities,
410            };
411
412            let formatted = crate::formatters::format_output(&server_info, output_format)?;
413            println!("{formatted}");
414
415            Ok(ExitCode::SUCCESS)
416        }
417        Err(e) => {
418            // Failed to introspect - return basic info
419            warn!("Failed to introspect server '{}': {}", server, e);
420
421            let server_info = ServerInfo {
422                id: server.clone(),
423                name: server,
424                version: "unknown".to_string(),
425                command,
426                status: ServerStatus::Unavailable.as_str().to_string(),
427                tools: Vec::new(),
428                capabilities: Vec::new(),
429            };
430
431            let formatted = crate::formatters::format_output(&server_info, output_format)?;
432            println!("{formatted}");
433
434            // Return error code since introspection failed
435            Ok(ExitCode::ERROR)
436        }
437    }
438}
439
440/// Validates a server by checking if it can be reached and introspected.
441///
442/// This validates a server name (not a raw command). The server must be configured
443/// in Claude Desktop configuration.
444async fn validate_command(server_name: String, output_format: OutputFormat) -> Result<ExitCode> {
445    let manager = ServerManager::new().context("Failed to initialize server manager")?;
446
447    // Get server configuration
448    let config = match manager.get_server_config(&server_name) {
449        Ok(cfg) => cfg,
450        Err(e) => {
451            let result = ValidationResult {
452                command: server_name,
453                valid: false,
454                message: format!("Server not found in configuration: {e}"),
455            };
456            let formatted = crate::formatters::format_output(&result, output_format)?;
457            println!("{formatted}");
458            return Ok(ExitCode::ERROR);
459        }
460    };
461
462    let command = ServerManager::build_command_string(&config);
463    info!("Validating server '{}'...", server_name);
464
465    // Check if command exists
466    if !ServerManager::check_command_exists(&config.command) {
467        let result = ValidationResult {
468            command: command.clone(),
469            valid: false,
470            message: format!("Command '{}' not found in PATH", config.command),
471        };
472        let formatted = crate::formatters::format_output(&result, output_format)?;
473        println!("{formatted}");
474        return Ok(ExitCode::ERROR);
475    }
476
477    // Try to connect and introspect
478    match manager.validate_server(&server_name).await? {
479        ServerStatus::Available => {
480            let result = ValidationResult {
481                command,
482                valid: true,
483                message: format!(
484                    "Server '{server_name}' is available and responds to MCP protocol"
485                ),
486            };
487            let formatted = crate::formatters::format_output(&result, output_format)?;
488            println!("{formatted}");
489            Ok(ExitCode::SUCCESS)
490        }
491        ServerStatus::Unavailable => {
492            let result = ValidationResult {
493                command,
494                valid: false,
495                message: format!(
496                    "Server '{server_name}' command exists but failed to respond to MCP protocol"
497                ),
498            };
499            let formatted = crate::formatters::format_output(&result, output_format)?;
500            println!("{formatted}");
501            Ok(ExitCode::ERROR)
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::io::Write;
510
511    /// Creates a temporary config file for testing.
512    fn create_test_config(content: &str) -> tempfile::NamedTempFile {
513        let mut file = tempfile::NamedTempFile::new().unwrap();
514        file.write_all(content.as_bytes()).unwrap();
515        file.flush().unwrap();
516        file
517    }
518
519    #[test]
520    fn test_server_status_as_str() {
521        assert_eq!(ServerStatus::Available.as_str(), "available");
522        assert_eq!(ServerStatus::Unavailable.as_str(), "unavailable");
523    }
524
525    #[test]
526    fn test_server_config_deserialization() {
527        let json = r#"{
528            "command": "node",
529            "args": ["/path/to/server.js"],
530            "env": {"KEY": "value"}
531        }"#;
532
533        let config: ServerConfig = serde_json::from_str(json).unwrap();
534        assert_eq!(config.command, "node");
535        assert_eq!(config.args, vec!["/path/to/server.js"]);
536        assert_eq!(config.env.get("KEY"), Some(&"value".to_string()));
537    }
538
539    #[test]
540    fn test_server_config_deserialization_minimal() {
541        let json = r#"{
542            "command": "python"
543        }"#;
544
545        let config: ServerConfig = serde_json::from_str(json).unwrap();
546        assert_eq!(config.command, "python");
547        assert!(config.args.is_empty());
548        assert!(config.env.is_empty());
549    }
550
551    #[test]
552    fn test_claude_desktop_config_deserialization() {
553        let json = r#"{
554            "mcpServers": {
555                "test-server": {
556                    "command": "node",
557                    "args": ["server.js"]
558                }
559            }
560        }"#;
561
562        let config: ClaudeDesktopConfig = serde_json::from_str(json).unwrap();
563        assert!(config.mcp_execution_servers.contains_key("test-server"));
564    }
565
566    #[test]
567    fn test_build_command_string_no_args() {
568        let config = ServerConfig {
569            command: "node".to_string(),
570            args: Vec::new(),
571            env: HashMap::new(),
572        };
573
574        assert_eq!(ServerManager::build_command_string(&config), "node");
575    }
576
577    #[test]
578    fn test_build_command_string_with_args() {
579        let config = ServerConfig {
580            command: "node".to_string(),
581            args: vec!["/path/to/server.js".to_string(), "--verbose".to_string()],
582            env: HashMap::new(),
583        };
584
585        assert_eq!(
586            ServerManager::build_command_string(&config),
587            "node /path/to/server.js --verbose"
588        );
589    }
590
591    #[test]
592    fn test_check_command_exists() {
593        // Should exist on all platforms
594        assert!(ServerManager::check_command_exists("ls"));
595
596        // Should not exist
597        assert!(!ServerManager::check_command_exists(
598            "this_command_definitely_does_not_exist_12345"
599        ));
600    }
601
602    #[test]
603    fn test_server_manager_read_config() {
604        let config_content = r#"{
605            "mcpServers": {
606                "test-server": {
607                    "command": "node",
608                    "args": ["server.js"]
609                }
610            }
611        }"#;
612
613        let temp_file = create_test_config(config_content);
614
615        let manager = ServerManager {
616            config_path: temp_file.path().to_path_buf(),
617        };
618
619        let config = manager.read_config().unwrap();
620        assert_eq!(config.mcp_execution_servers.len(), 1);
621        assert!(config.mcp_execution_servers.contains_key("test-server"));
622    }
623
624    #[test]
625    fn test_server_manager_list_servers() {
626        let config_content = r#"{
627            "mcpServers": {
628                "server1": {
629                    "command": "node",
630                    "args": ["s1.js"]
631                },
632                "server2": {
633                    "command": "python",
634                    "args": ["s2.py"]
635                }
636            }
637        }"#;
638
639        let temp_file = create_test_config(config_content);
640
641        let manager = ServerManager {
642            config_path: temp_file.path().to_path_buf(),
643        };
644
645        let servers = manager.list_servers().unwrap();
646        assert_eq!(servers.len(), 2);
647
648        let names: Vec<String> = servers.iter().map(|(name, _)| name.clone()).collect();
649        assert!(names.contains(&"server1".to_string()));
650        assert!(names.contains(&"server2".to_string()));
651    }
652
653    #[test]
654    fn test_server_manager_get_server_config() {
655        let config_content = r#"{
656            "mcpServers": {
657                "test-server": {
658                    "command": "node",
659                    "args": ["server.js"]
660                }
661            }
662        }"#;
663
664        let temp_file = create_test_config(config_content);
665
666        let manager = ServerManager {
667            config_path: temp_file.path().to_path_buf(),
668        };
669
670        let config = manager.get_server_config("test-server").unwrap();
671        assert_eq!(config.command, "node");
672        assert_eq!(config.args, vec!["server.js"]);
673    }
674
675    #[test]
676    fn test_server_manager_get_server_config_not_found() {
677        let config_content = r#"{
678            "mcpServers": {}
679        }"#;
680
681        let temp_file = create_test_config(config_content);
682
683        let manager = ServerManager {
684            config_path: temp_file.path().to_path_buf(),
685        };
686
687        let result = manager.get_server_config("nonexistent");
688        assert!(result.is_err());
689        assert!(
690            result
691                .unwrap_err()
692                .to_string()
693                .contains("not found in configuration")
694        );
695    }
696
697    #[test]
698    fn test_server_entry_serialization() {
699        let entry = ServerEntry {
700            id: "test".to_string(),
701            command: "test-cmd".to_string(),
702            status: "available".to_string(),
703        };
704
705        let json = serde_json::to_string(&entry).unwrap();
706        assert!(json.contains("test"));
707        assert!(json.contains("test-cmd"));
708        assert!(json.contains("available"));
709    }
710
711    #[test]
712    fn test_server_list_serialization() {
713        let list = ServerList {
714            servers: vec![ServerEntry {
715                id: "test".to_string(),
716                command: "test-cmd".to_string(),
717                status: "available".to_string(),
718            }],
719        };
720
721        let json = serde_json::to_string(&list).unwrap();
722        assert!(json.contains("servers"));
723        assert!(json.contains("test"));
724    }
725
726    #[test]
727    fn test_server_info_serialization() {
728        let info = ServerInfo {
729            id: "test".to_string(),
730            name: "Test Server".to_string(),
731            version: "1.0.0".to_string(),
732            command: "test-cmd".to_string(),
733            status: "available".to_string(),
734            tools: vec![ToolSummary {
735                name: "test_tool".to_string(),
736                description: "A test tool".to_string(),
737            }],
738            capabilities: vec!["tools".to_string()],
739        };
740
741        let json = serde_json::to_string(&info).unwrap();
742        assert!(json.contains("test"));
743        assert!(json.contains("Test Server"));
744        assert!(json.contains("capabilities"));
745        assert!(json.contains("tools"));
746    }
747
748    #[test]
749    fn test_tool_summary_serialization() {
750        let tool = ToolSummary {
751            name: "send_message".to_string(),
752            description: "Sends a message".to_string(),
753        };
754
755        let json = serde_json::to_string(&tool).unwrap();
756        assert!(json.contains("send_message"));
757        assert!(json.contains("Sends a message"));
758    }
759
760    #[test]
761    fn test_validation_result_serialization() {
762        let result = ValidationResult {
763            command: "test".to_string(),
764            valid: true,
765            message: "ok".to_string(),
766        };
767
768        let json = serde_json::to_string(&result).unwrap();
769        assert!(json.contains("command"));
770        assert!(json.contains("valid"));
771        assert!(json.contains("message"));
772    }
773
774    // Integration tests (require CLAUDE_CONFIG_PATH or Claude Desktop installed)
775    #[tokio::test]
776    #[ignore = "requires CLAUDE_CONFIG_PATH environment variable"]
777    async fn test_list_servers_integration() {
778        // This test requires CLAUDE_CONFIG_PATH to be set
779        if std::env::var("CLAUDE_CONFIG_PATH").is_err() {
780            return;
781        }
782
783        let result = run(ServerAction::List, OutputFormat::Json).await;
784        assert!(result.is_ok());
785    }
786
787    #[tokio::test]
788    #[ignore = "requires CLAUDE_CONFIG_PATH and configured server"]
789    async fn test_server_info_integration() {
790        // This test requires CLAUDE_CONFIG_PATH and a configured server
791        if std::env::var("CLAUDE_CONFIG_PATH").is_err() {
792            return;
793        }
794
795        // Note: Replace with actual server name from your config
796        let result = run(
797            ServerAction::Info {
798                server: "test-server".to_string(),
799            },
800            OutputFormat::Json,
801        )
802        .await;
803
804        // May fail if server doesn't exist or can't be reached
805        assert!(result.is_ok());
806    }
807}