mcp_execution_cli/commands/
setup.rs1use anyhow::{Context, Result};
9use mcp_execution_core::cli::{ExitCode, OutputFormat};
10#[cfg(unix)]
11use mcp_execution_core::sanitize_path_for_error;
12use serde::Serialize;
13#[cfg(unix)]
14use std::path::Path;
15use std::path::PathBuf;
16use std::process::Stdio;
17use tokio::process::Command;
18
19#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct SetupResult {
43 pub node_version: String,
45 pub mcp_config_path: String,
47 pub mcp_config_found: bool,
49 pub servers_dir_found: bool,
52 pub files_made_executable: usize,
55 pub skipped_entries: usize,
60}
61
62pub async fn run(output_format: OutputFormat) -> Result<ExitCode> {
97 if output_format == OutputFormat::Pretty {
98 println!("Checking runtime environment...\n");
99 }
100
101 let node_version = check_node_version().await?;
102
103 let mcp_config_path = get_mcp_config_path()?;
104 let mcp_config_found = mcp_config_path.exists();
105
106 let (servers_dir_found, files_made_executable, skipped_entries) =
107 check_files_executable().await?;
108
109 let result = SetupResult {
110 node_version,
111 mcp_config_path: mcp_config_path.display().to_string(),
112 mcp_config_found,
113 servers_dir_found,
114 files_made_executable,
115 skipped_entries,
116 };
117
118 if output_format == OutputFormat::Pretty {
119 print_pretty_summary(&result);
120 return Ok(ExitCode::SUCCESS);
121 }
122
123 crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
124}
125
126fn print_pretty_summary(result: &SetupResult) {
128 println!("✓ Node.js v{} detected", result.node_version);
129
130 if result.mcp_config_found {
131 println!("✓ MCP configuration found: {}", result.mcp_config_path);
132 } else {
133 println!("⚠ MCP configuration not found");
134 println!(" Expected location: {}", result.mcp_config_path);
135 println!(" Create it with your server configurations:");
136 println!();
137 println!(" {{");
138 println!(" \"mcpServers\": {{");
139 println!(" \"github\": {{");
140 println!(" \"command\": \"docker\",");
141 println!(" \"args\": [\"run\", \"-i\", \"--rm\", \"...\"]");
142 println!(" }}");
143 println!(" }}");
144 println!(" }}");
145 println!();
146 println!(" See examples/mcp.json.example for more details.");
147 }
148
149 #[cfg(unix)]
150 {
151 if result.servers_dir_found {
152 if result.files_made_executable > 0 {
153 println!(
154 "✓ Made {} TypeScript files executable",
155 result.files_made_executable
156 );
157 }
158 if result.skipped_entries > 0 {
159 println!(
160 "⚠ Skipped {} symlinked entr{} under the servers directory (see warnings above)",
161 result.skipped_entries,
162 if result.skipped_entries == 1 {
163 "y"
164 } else {
165 "ies"
166 }
167 );
168 }
169 } else {
170 println!("⚠ No servers directory found");
171 println!(" Run 'mcp-execution-cli generate <server>' to create tools");
172 }
173 }
174
175 println!("\n✓ Runtime setup complete");
176 println!(" Claude Code can now execute MCP tools via:");
177 println!(" node ~/.claude/servers/<server>/<tool>.ts '{{\"param\":\"value\"}}'");
178 println!("\nNext steps:");
179 println!(" 1. Generate tools: mcp-execution-cli generate <server>");
180 println!(" 2. Configure servers in ~/.claude/mcp.json");
181 println!(" 3. Execute tools autonomously via Node.js");
182}
183
184async fn check_node_version() -> Result<String> {
196 let output = Command::new("node")
198 .arg("--version")
199 .stdout(Stdio::piped())
200 .stderr(Stdio::piped())
201 .output()
202 .await
203 .context(
204 "Node.js not found in PATH.\n\
205 \n\
206 Node.js 18+ is required for MCP tool execution.\n\
207 Install from: https://nodejs.org\n\
208 \n\
209 Or use a version manager:\n\
210 - nvm: https://github.com/nvm-sh/nvm\n\
211 - fnm: https://github.com/Schniz/fnm",
212 )?;
213
214 if !output.status.success() {
215 anyhow::bail!("Node.js is installed but not working correctly");
216 }
217
218 let version_str = String::from_utf8_lossy(&output.stdout);
220 let version_str = version_str.trim().trim_start_matches('v');
221
222 let major_version = version_str
224 .split('.')
225 .next()
226 .and_then(|s| s.parse::<u32>().ok())
227 .context("Failed to parse Node.js version")?;
228
229 if major_version < 18 {
230 anyhow::bail!(
231 "Node.js version {version_str} is too old.\n\
232 \n\
233 Required: Node.js 18.0.0 or higher\n\
234 Current: Node.js {version_str}\n\
235 \n\
236 Please upgrade Node.js:\n\
237 - Download: https://nodejs.org\n\
238 - Or use nvm: nvm install 18"
239 );
240 }
241
242 Ok(version_str.to_string())
243}
244
245#[cfg(unix)]
262async fn check_files_executable() -> Result<(bool, usize, usize)> {
263 let servers_dir = get_servers_dir()?;
264 check_files_executable_in(&servers_dir).await
265}
266
267#[cfg(not(unix))]
271async fn check_files_executable() -> Result<(bool, usize, usize)> {
272 Ok((false, 0, 0))
273}
274
275#[cfg(unix)]
313async fn check_files_executable_in(servers_dir: &Path) -> Result<(bool, usize, usize)> {
314 use tokio::fs;
315
316 if !servers_dir.exists() {
318 return Ok((false, 0, 0));
319 }
320
321 let root = fs::canonicalize(servers_dir).await?;
322 let entries = fs::read_dir(&root).await?;
326
327 let mut count = 0;
328 let mut skipped = 0;
329 walk_entries(&root, entries, &mut count, &mut skipped).await?;
330
331 Ok((true, count, skipped))
332}
333
334#[cfg(unix)]
340async fn walk_and_chmod(dir: &Path, count: &mut usize, skipped: &mut usize) -> Result<()> {
341 use tokio::fs;
342
343 let entries = match fs::read_dir(dir).await {
344 Ok(entries) => entries,
345 Err(error) => {
346 tracing::warn!(
347 path = %sanitize_path_for_error(dir),
348 %error,
349 "skipping unreadable directory under the servers directory"
350 );
351 return Ok(());
352 }
353 };
354
355 walk_entries(dir, entries, count, skipped).await
356}
357
358#[cfg(unix)]
367async fn walk_entries(
368 dir: &Path,
369 mut entries: tokio::fs::ReadDir,
370 count: &mut usize,
371 skipped: &mut usize,
372) -> Result<()> {
373 use std::os::unix::fs::PermissionsExt;
374 use tokio::fs;
375
376 loop {
377 let entry = match entries.next_entry().await {
378 Ok(Some(entry)) => entry,
379 Ok(None) => break,
380 Err(error) => {
381 tracing::warn!(
382 path = %sanitize_path_for_error(dir),
383 %error,
384 "stopping directory read after error; skipping any remaining entries"
385 );
386 break;
387 }
388 };
389
390 let path = entry.path();
391 let file_type = entry.file_type().await?;
392 if file_type.is_symlink() {
393 tracing::warn!(
394 path = %sanitize_path_for_error(&path),
395 "skipping symlinked entry under the servers directory"
396 );
397 *skipped += 1;
398 continue;
399 }
400
401 if file_type.is_dir() {
402 Box::pin(walk_and_chmod(&path, count, skipped)).await?;
403 continue;
404 }
405
406 if !file_type.is_file() || path.extension().and_then(|s| s.to_str()) != Some("ts") {
407 continue;
408 }
409
410 let metadata = fs::metadata(&path).await?;
411 let mut perms = metadata.permissions();
412 perms.set_mode(0o755); fs::set_permissions(&path, perms).await?;
414 *count += 1;
415 }
416
417 Ok(())
418}
419
420fn get_mcp_config_path() -> Result<PathBuf> {
422 let home = dirs::home_dir().context("Failed to get home directory")?;
423 Ok(home.join(".claude").join("mcp.json"))
424}
425
426fn get_servers_dir() -> Result<PathBuf> {
428 let home = dirs::home_dir().context("Failed to get home directory")?;
429 Ok(home.join(".claude").join("servers"))
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[tokio::test]
437 async fn test_check_node_version() {
438 let result = check_node_version().await;
441
442 if let Err(e) = result {
445 let error_msg = e.to_string();
446 assert!(
447 error_msg.contains("Node.js") || error_msg.contains("version"),
448 "Error message should be helpful: {error_msg}"
449 );
450 }
451 }
452
453 #[test]
454 fn test_get_mcp_config_path() {
455 let path = get_mcp_config_path();
456 assert!(path.is_ok());
457
458 let path = path.unwrap();
459 assert!(path.to_string_lossy().contains(".claude"));
460 assert!(path.to_string_lossy().contains("mcp.json"));
461 }
462
463 #[test]
464 fn test_get_servers_dir() {
465 let path = get_servers_dir();
466 assert!(path.is_ok());
467
468 let path = path.unwrap();
469 assert!(path.to_string_lossy().contains(".claude"));
470 assert!(path.to_string_lossy().contains("servers"));
471 }
472
473 #[tokio::test]
474 async fn test_check_files_executable_no_panic() {
475 let result = check_files_executable().await;
477 assert!(result.is_ok());
478 }
479
480 #[cfg(unix)]
481 #[tokio::test]
482 async fn check_files_executable_in_makes_real_ts_files_executable() {
483 use std::os::unix::fs::PermissionsExt;
484
485 let servers_dir = tempfile::TempDir::new().unwrap();
486 let my_server_dir = servers_dir.path().join("my-server");
487 tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
488 let tool_path = my_server_dir.join("tool.ts");
489 tokio::fs::write(&tool_path, "// tool").await.unwrap();
490
491 let (servers_dir_found, files_made_executable, skipped_entries) =
492 check_files_executable_in(servers_dir.path()).await.unwrap();
493
494 assert!(servers_dir_found);
495 assert_eq!(files_made_executable, 1);
496 assert_eq!(skipped_entries, 0);
497 let mode = tokio::fs::metadata(&tool_path)
498 .await
499 .unwrap()
500 .permissions()
501 .mode();
502 assert_eq!(mode & 0o777, 0o755);
503 }
504
505 #[cfg(unix)]
506 #[tokio::test]
507 async fn check_files_executable_in_recurses_into_nested_subdirectories() {
508 use std::os::unix::fs::PermissionsExt;
509
510 let servers_dir = tempfile::TempDir::new().unwrap();
511 let runtime_dir = servers_dir.path().join("my-server").join("_runtime");
512 tokio::fs::create_dir_all(&runtime_dir).await.unwrap();
513 let bridge_path = runtime_dir.join("mcp-bridge.ts");
514 tokio::fs::write(&bridge_path, "// bridge").await.unwrap();
515
516 let (servers_dir_found, files_made_executable, skipped_entries) =
517 check_files_executable_in(servers_dir.path()).await.unwrap();
518
519 assert!(servers_dir_found);
520 assert_eq!(files_made_executable, 1);
521 assert_eq!(skipped_entries, 0);
522 let mode = tokio::fs::metadata(&bridge_path)
523 .await
524 .unwrap()
525 .permissions()
526 .mode();
527 assert_eq!(mode & 0o777, 0o755);
528 }
529
530 #[cfg(unix)]
536 #[tokio::test]
537 async fn check_files_executable_in_skips_unreadable_nested_dir_processes_siblings() {
538 use std::os::unix::fs::PermissionsExt;
539
540 let servers_dir = tempfile::TempDir::new().unwrap();
541
542 let good_server_dir = servers_dir.path().join("good-server");
543 tokio::fs::create_dir_all(&good_server_dir).await.unwrap();
544 let good_tool_path = good_server_dir.join("tool.ts");
545 tokio::fs::write(&good_tool_path, "// tool").await.unwrap();
546
547 let locked_server_dir = servers_dir.path().join("locked-server");
548 tokio::fs::create_dir_all(&locked_server_dir).await.unwrap();
549 tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o000))
550 .await
551 .unwrap();
552
553 if tokio::fs::read_dir(&locked_server_dir).await.is_ok() {
556 tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
557 .await
558 .unwrap();
559 return;
560 }
561
562 let result = check_files_executable_in(servers_dir.path()).await;
563
564 tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
566 .await
567 .unwrap();
568
569 let (servers_dir_found, files_made_executable, skipped_entries) = result.unwrap();
570
571 assert!(servers_dir_found);
572 assert_eq!(
573 files_made_executable, 1,
574 "the healthy sibling directory must still be processed"
575 );
576 assert_eq!(skipped_entries, 0);
577 let mode = tokio::fs::metadata(&good_tool_path)
578 .await
579 .unwrap()
580 .permissions()
581 .mode();
582 assert_eq!(mode & 0o777, 0o755);
583 }
584
585 #[cfg(unix)]
586 #[tokio::test]
587 async fn check_files_executable_in_propagates_root_read_dir_failure() {
588 use std::os::unix::fs::PermissionsExt;
589
590 let servers_dir = tempfile::TempDir::new().unwrap();
591 tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o000))
592 .await
593 .unwrap();
594
595 if tokio::fs::read_dir(servers_dir.path()).await.is_ok() {
598 tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
599 .await
600 .unwrap();
601 return;
602 }
603
604 let result = check_files_executable_in(servers_dir.path()).await;
605
606 tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
608 .await
609 .unwrap();
610
611 assert!(
612 result.is_err(),
613 "an unreadable servers directory root must propagate an error, not report success"
614 );
615 }
616
617 #[cfg(unix)]
618 #[tokio::test]
619 async fn check_files_executable_in_skips_symlinked_nested_subdirectory() {
620 let servers_dir = tempfile::TempDir::new().unwrap();
621 let outside = tempfile::TempDir::new().unwrap();
622 let target_path = outside.path().join("target.ts");
623 tokio::fs::write(&target_path, "// outside").await.unwrap();
624
625 let my_server_dir = servers_dir.path().join("my-server");
626 tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
627 std::os::unix::fs::symlink(outside.path(), my_server_dir.join("_runtime")).unwrap();
628
629 let (servers_dir_found, files_made_executable, skipped_entries) =
630 check_files_executable_in(servers_dir.path()).await.unwrap();
631
632 assert!(servers_dir_found);
633 assert_eq!(files_made_executable, 0);
634 assert_eq!(skipped_entries, 1);
635 let mode = std::os::unix::fs::PermissionsExt::mode(
636 &tokio::fs::metadata(&target_path)
637 .await
638 .unwrap()
639 .permissions(),
640 );
641 assert_eq!(
642 mode & 0o111,
643 0,
644 "symlinked nested directory's target must not be descended into"
645 );
646 }
647
648 #[cfg(unix)]
649 #[tokio::test]
650 async fn check_files_executable_in_skips_symlinked_server_dir() {
651 let servers_dir = tempfile::TempDir::new().unwrap();
652 let outside = tempfile::TempDir::new().unwrap();
653 let target_path = outside.path().join("target.ts");
654 tokio::fs::write(&target_path, "// outside").await.unwrap();
655 std::os::unix::fs::symlink(outside.path(), servers_dir.path().join("evil-server")).unwrap();
656
657 let (servers_dir_found, files_made_executable, skipped_entries) =
658 check_files_executable_in(servers_dir.path()).await.unwrap();
659
660 assert!(servers_dir_found);
661 assert_eq!(files_made_executable, 0);
662 assert_eq!(skipped_entries, 1);
663 let mode = std::os::unix::fs::PermissionsExt::mode(
664 &tokio::fs::metadata(&target_path)
665 .await
666 .unwrap()
667 .permissions(),
668 );
669 assert_eq!(
670 mode & 0o111,
671 0,
672 "symlinked target must not become executable"
673 );
674 }
675
676 #[cfg(unix)]
677 #[tokio::test]
678 async fn check_files_executable_in_skips_symlinked_ts_file() {
679 let servers_dir = tempfile::TempDir::new().unwrap();
680 let outside = tempfile::TempDir::new().unwrap();
681 let target_path = outside.path().join("target.ts");
682 tokio::fs::write(&target_path, "// outside").await.unwrap();
683
684 let legit_server_dir = servers_dir.path().join("legit-server");
685 tokio::fs::create_dir_all(&legit_server_dir).await.unwrap();
686 std::os::unix::fs::symlink(&target_path, legit_server_dir.join("link.ts")).unwrap();
687
688 let (servers_dir_found, files_made_executable, skipped_entries) =
689 check_files_executable_in(servers_dir.path()).await.unwrap();
690
691 assert!(servers_dir_found);
692 assert_eq!(files_made_executable, 0);
693 assert_eq!(skipped_entries, 1);
694 let mode = std::os::unix::fs::PermissionsExt::mode(
695 &tokio::fs::metadata(&target_path)
696 .await
697 .unwrap()
698 .permissions(),
699 );
700 assert_eq!(
701 mode & 0o111,
702 0,
703 "symlinked target must not become executable"
704 );
705 }
706
707 #[test]
708 fn test_setup_result_serialization() {
709 let result = SetupResult {
710 node_version: "20.10.0".to_string(),
711 mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
712 mcp_config_found: true,
713 servers_dir_found: true,
714 files_made_executable: 3,
715 skipped_entries: 0,
716 };
717
718 let json = serde_json::to_string(&result).unwrap();
719 assert!(json.contains("\"node_version\":\"20.10.0\""));
720 assert!(json.contains("\"mcp_config_found\":true"));
721 assert!(json.contains("\"files_made_executable\":3"));
722 }
723
724 #[test]
725 fn test_setup_result_format_output_json() {
726 let result = SetupResult {
727 node_version: "20.10.0".to_string(),
728 mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
729 mcp_config_found: false,
730 servers_dir_found: true,
731 files_made_executable: 7,
732 skipped_entries: 0,
733 };
734
735 let formatted =
736 crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Json)
737 .unwrap();
738 assert!(formatted.contains("\"node_version\": \"20.10.0\""));
739 assert!(formatted.contains("\"mcp_config_path\": \"/home/user/.claude/mcp.json\""));
740 assert!(formatted.contains("\"mcp_config_found\": false"));
741 assert!(formatted.contains("\"servers_dir_found\": true"));
742 assert!(formatted.contains("\"files_made_executable\": 7"));
743 }
744
745 #[test]
746 fn test_setup_result_format_output_text() {
747 let result = SetupResult {
748 node_version: "20.10.0".to_string(),
749 mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
750 mcp_config_found: true,
751 servers_dir_found: false,
752 files_made_executable: 0,
753 skipped_entries: 0,
754 };
755
756 let formatted =
757 crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Text)
758 .unwrap();
759 assert!(!formatted.contains('\n'));
762 assert!(formatted.contains("\"node_version\":\"20.10.0\""));
763 assert!(formatted.contains("\"mcp_config_found\":true"));
764 assert!(formatted.contains("\"servers_dir_found\":false"));
765 assert!(formatted.contains("\"files_made_executable\":0"));
766 }
767}