Skip to main content

mcp_execution_cli/commands/
setup.rs

1//! Setup command implementation.
2//!
3//! Validates the runtime environment for MCP tool execution:
4//! - Checks Node.js 18+ is installed
5//! - Verifies generated files are executable
6//! - Provides helpful error messages and suggestions
7
8use anyhow::{Context, Result};
9use mcp_execution_core::cli::{ExitCode, OutputFormat};
10use serde::Serialize;
11use std::path::PathBuf;
12use std::process::Stdio;
13use tokio::process::Command;
14
15/// Structured result of the environment setup checks.
16///
17/// Captures every check [`run`] performs so it can be rendered as JSON,
18/// plain text, or the default human-readable pretty summary via
19/// [`crate::formatters::format_output`].
20///
21/// # Examples
22///
23/// ```
24/// use mcp_execution_cli::commands::setup::SetupResult;
25///
26/// let result = SetupResult {
27///     node_version: "20.10.0".to_string(),
28///     mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
29///     mcp_config_found: true,
30///     servers_dir_found: true,
31///     files_made_executable: 3,
32/// };
33///
34/// assert_eq!(result.files_made_executable, 3);
35/// ```
36#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
37pub struct SetupResult {
38    /// Detected Node.js version (e.g. `"20.10.0"`), without the leading `v`.
39    pub node_version: String,
40    /// Path where `~/.claude/mcp.json` is expected.
41    pub mcp_config_path: String,
42    /// Whether `~/.claude/mcp.json` exists.
43    pub mcp_config_found: bool,
44    /// Whether `~/.claude/servers/` exists. Always `false` on non-Unix
45    /// platforms, since file permissions are not checked there.
46    pub servers_dir_found: bool,
47    /// Number of `.ts` files made executable under `~/.claude/servers/`.
48    /// Always `0` on non-Unix platforms.
49    pub files_made_executable: usize,
50}
51
52/// Runs the setup command.
53///
54/// Validates that the runtime environment is ready for MCP tool execution
55/// and renders the results according to `output_format`.
56///
57/// # Checks Performed
58///
59/// 1. **Node.js version**: Ensures Node.js 18.0.0 or higher is installed
60/// 2. **File permissions**: Makes TypeScript files executable (Unix only)
61/// 3. **Configuration**: Checks if ~/.claude/mcp.json exists
62///
63/// # Examples
64///
65/// ```bash
66/// # Run setup validation (default pretty output)
67/// mcp-execution-cli setup
68///
69/// # Output:
70/// # ✓ Node.js v20.10.0 detected
71/// # ✓ Runtime setup complete
72/// # Claude Code can now execute MCP tools via:
73/// #   node ~/.claude/servers/<server>/<tool>.ts '{"param":"value"}'
74///
75/// # Structured output for scripting
76/// mcp-execution-cli --format json setup
77/// ```
78///
79/// # Errors
80///
81/// Returns an error if:
82/// - Node.js is not installed
83/// - Node.js version is less than 18.0.0
84/// - Home directory cannot be determined
85/// - Output formatting fails (serialization error)
86pub async fn run(output_format: OutputFormat) -> Result<ExitCode> {
87    if output_format == OutputFormat::Pretty {
88        println!("Checking runtime environment...\n");
89    }
90
91    let node_version = check_node_version().await?;
92
93    let mcp_config_path = get_mcp_config_path()?;
94    let mcp_config_found = mcp_config_path.exists();
95
96    let (servers_dir_found, files_made_executable) = check_files_executable().await?;
97
98    let result = SetupResult {
99        node_version,
100        mcp_config_path: mcp_config_path.display().to_string(),
101        mcp_config_found,
102        servers_dir_found,
103        files_made_executable,
104    };
105
106    if output_format == OutputFormat::Pretty {
107        print_pretty_summary(&result);
108    } else {
109        let formatted = crate::formatters::format_output(&result, output_format)?;
110        println!("{formatted}");
111    }
112
113    Ok(ExitCode::SUCCESS)
114}
115
116/// Prints the human-readable setup summary (the `Pretty` format rendering).
117fn print_pretty_summary(result: &SetupResult) {
118    println!("✓ Node.js v{} detected", result.node_version);
119
120    if result.mcp_config_found {
121        println!("✓ MCP configuration found: {}", result.mcp_config_path);
122    } else {
123        println!("⚠ MCP configuration not found");
124        println!("  Expected location: {}", result.mcp_config_path);
125        println!("  Create it with your server configurations:");
126        println!();
127        println!("  {{");
128        println!("    \"mcpServers\": {{");
129        println!("      \"github\": {{");
130        println!("        \"command\": \"docker\",");
131        println!("        \"args\": [\"run\", \"-i\", \"--rm\", \"...\"]");
132        println!("      }}");
133        println!("    }}");
134        println!("  }}");
135        println!();
136        println!("  See examples/mcp.json.example for more details.");
137    }
138
139    #[cfg(unix)]
140    {
141        if result.servers_dir_found {
142            if result.files_made_executable > 0 {
143                println!(
144                    "✓ Made {} TypeScript files executable",
145                    result.files_made_executable
146                );
147            }
148        } else {
149            println!("⚠ No servers directory found");
150            println!("  Run 'mcp-execution-cli generate <server>' to create tools");
151        }
152    }
153
154    println!("\n✓ Runtime setup complete");
155    println!("  Claude Code can now execute MCP tools via:");
156    println!("  node ~/.claude/servers/<server>/<tool>.ts '{{\"param\":\"value\"}}'");
157    println!("\nNext steps:");
158    println!("  1. Generate tools: mcp-execution-cli generate <server>");
159    println!("  2. Configure servers in ~/.claude/mcp.json");
160    println!("  3. Execute tools autonomously via Node.js");
161}
162
163/// Checks Node.js version requirement.
164///
165/// Verifies that Node.js 18.0.0 or higher is installed and accessible, and
166/// returns the detected version string (without the leading `v`).
167///
168/// # Errors
169///
170/// Returns error if:
171/// - Node.js command not found in PATH
172/// - Node.js version cannot be determined
173/// - Node.js version is less than 18.0.0
174async fn check_node_version() -> Result<String> {
175    // Check if node command exists
176    let output = Command::new("node")
177        .arg("--version")
178        .stdout(Stdio::piped())
179        .stderr(Stdio::piped())
180        .output()
181        .await
182        .context(
183            "Node.js not found in PATH.\n\
184             \n\
185             Node.js 18+ is required for MCP tool execution.\n\
186             Install from: https://nodejs.org\n\
187             \n\
188             Or use a version manager:\n\
189             - nvm: https://github.com/nvm-sh/nvm\n\
190             - fnm: https://github.com/Schniz/fnm",
191        )?;
192
193    if !output.status.success() {
194        anyhow::bail!("Node.js is installed but not working correctly");
195    }
196
197    // Parse version
198    let version_str = String::from_utf8_lossy(&output.stdout);
199    let version_str = version_str.trim().trim_start_matches('v');
200
201    // Extract major version
202    let major_version = version_str
203        .split('.')
204        .next()
205        .and_then(|s| s.parse::<u32>().ok())
206        .context("Failed to parse Node.js version")?;
207
208    if major_version < 18 {
209        anyhow::bail!(
210            "Node.js version {version_str} is too old.\n\
211             \n\
212             Required: Node.js 18.0.0 or higher\n\
213             Current:  Node.js {version_str}\n\
214             \n\
215             Please upgrade Node.js:\n\
216             - Download: https://nodejs.org\n\
217             - Or use nvm: nvm install 18"
218        );
219    }
220
221    Ok(version_str.to_string())
222}
223
224/// Checks for and makes TypeScript files executable (Unix only).
225///
226/// Sets executable permissions (0755) on all .ts files in ~/.claude/servers/
227/// This allows files to be executed with shebang: `./tool.ts`
228///
229/// # Platform Support
230///
231/// - Unix/Linux/macOS: Sets permissions, returns `(servers_dir_found, files_made_executable)`
232/// - Windows: No-op, always returns `(false, 0)`
233///
234/// # Errors
235///
236/// Returns error if:
237/// - Home directory cannot be determined
238/// - Permission changes fail
239#[cfg(unix)]
240async fn check_files_executable() -> Result<(bool, usize)> {
241    use std::os::unix::fs::PermissionsExt;
242    use tokio::fs;
243
244    let servers_dir = get_servers_dir()?;
245
246    // Check if servers directory exists
247    if !servers_dir.exists() {
248        return Ok((false, 0));
249    }
250
251    // Walk through all .ts files and make them executable
252    let mut count = 0;
253    let mut entries = fs::read_dir(&servers_dir).await?;
254
255    while let Some(entry) = entries.next_entry().await? {
256        let path = entry.path();
257
258        if path.is_dir() {
259            // Recurse into server directories
260            if let Ok(mut server_entries) = fs::read_dir(&path).await {
261                while let Some(server_entry) = server_entries.next_entry().await? {
262                    let file_path = server_entry.path();
263
264                    if file_path.extension().and_then(|s| s.to_str()) == Some("ts") {
265                        let metadata = fs::metadata(&file_path).await?;
266                        let mut perms = metadata.permissions();
267                        perms.set_mode(0o755); // rwxr-xr-x
268                        fs::set_permissions(&file_path, perms).await?;
269                        count += 1;
270                    }
271                }
272            }
273        }
274    }
275
276    Ok((true, count))
277}
278
279/// Checks for and makes TypeScript files executable (Unix only).
280///
281/// No-op on non-Unix platforms, since file permissions are not checked there.
282#[cfg(not(unix))]
283async fn check_files_executable() -> Result<(bool, usize)> {
284    Ok((false, 0))
285}
286
287/// Gets the path to ~/.claude/mcp.json
288fn get_mcp_config_path() -> Result<PathBuf> {
289    let home = dirs::home_dir().context("Failed to get home directory")?;
290    Ok(home.join(".claude").join("mcp.json"))
291}
292
293/// Gets the path to ~/.claude/servers/
294fn get_servers_dir() -> Result<PathBuf> {
295    let home = dirs::home_dir().context("Failed to get home directory")?;
296    Ok(home.join(".claude").join("servers"))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[tokio::test]
304    async fn test_check_node_version() {
305        // This test will pass if Node.js 18+ is installed
306        // Otherwise it will fail, which is the expected behavior
307        let result = check_node_version().await;
308
309        // We can't assert success because Node.js might not be installed
310        // in CI environment, but we can verify error messages are helpful
311        if let Err(e) = result {
312            let error_msg = e.to_string();
313            assert!(
314                error_msg.contains("Node.js") || error_msg.contains("version"),
315                "Error message should be helpful: {error_msg}"
316            );
317        }
318    }
319
320    #[test]
321    fn test_get_mcp_config_path() {
322        let path = get_mcp_config_path();
323        assert!(path.is_ok());
324
325        let path = path.unwrap();
326        assert!(path.to_string_lossy().contains(".claude"));
327        assert!(path.to_string_lossy().contains("mcp.json"));
328    }
329
330    #[test]
331    fn test_get_servers_dir() {
332        let path = get_servers_dir();
333        assert!(path.is_ok());
334
335        let path = path.unwrap();
336        assert!(path.to_string_lossy().contains(".claude"));
337        assert!(path.to_string_lossy().contains("servers"));
338    }
339
340    #[tokio::test]
341    async fn test_check_files_executable_no_panic() {
342        // Should not panic regardless of whether ~/.claude/servers exists.
343        let result = check_files_executable().await;
344        assert!(result.is_ok());
345    }
346
347    #[test]
348    fn test_setup_result_serialization() {
349        let result = SetupResult {
350            node_version: "20.10.0".to_string(),
351            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
352            mcp_config_found: true,
353            servers_dir_found: true,
354            files_made_executable: 3,
355        };
356
357        let json = serde_json::to_string(&result).unwrap();
358        assert!(json.contains("\"node_version\":\"20.10.0\""));
359        assert!(json.contains("\"mcp_config_found\":true"));
360        assert!(json.contains("\"files_made_executable\":3"));
361    }
362
363    #[test]
364    fn test_setup_result_format_output_json() {
365        let result = SetupResult {
366            node_version: "20.10.0".to_string(),
367            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
368            mcp_config_found: false,
369            servers_dir_found: true,
370            files_made_executable: 7,
371        };
372
373        let formatted =
374            crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Json)
375                .unwrap();
376        assert!(formatted.contains("\"node_version\": \"20.10.0\""));
377        assert!(formatted.contains("\"mcp_config_path\": \"/home/user/.claude/mcp.json\""));
378        assert!(formatted.contains("\"mcp_config_found\": false"));
379        assert!(formatted.contains("\"servers_dir_found\": true"));
380        assert!(formatted.contains("\"files_made_executable\": 7"));
381    }
382
383    #[test]
384    fn test_setup_result_format_output_text() {
385        let result = SetupResult {
386            node_version: "20.10.0".to_string(),
387            mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
388            mcp_config_found: true,
389            servers_dir_found: false,
390            files_made_executable: 0,
391        };
392
393        let formatted =
394            crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Text)
395                .unwrap();
396        // Text format is compact JSON (no newlines), unlike the pretty-printed
397        // Json format checked above.
398        assert!(!formatted.contains('\n'));
399        assert!(formatted.contains("\"node_version\":\"20.10.0\""));
400        assert!(formatted.contains("\"mcp_config_found\":true"));
401        assert!(formatted.contains("\"servers_dir_found\":false"));
402        assert!(formatted.contains("\"files_made_executable\":0"));
403    }
404}