use anyhow::{Context, Result};
use mcp_execution_core::cli::{ExitCode, OutputFormat};
#[cfg(unix)]
use mcp_execution_core::sanitize_path_for_error;
use serde::Serialize;
#[cfg(unix)]
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SetupResult {
pub node_version: String,
pub mcp_config_path: String,
pub mcp_config_found: bool,
pub servers_dir_found: bool,
pub files_made_executable: usize,
pub skipped_entries: usize,
}
pub async fn run(output_format: OutputFormat) -> Result<ExitCode> {
if output_format == OutputFormat::Pretty {
println!("Checking runtime environment...\n");
}
let node_version = check_node_version().await?;
let mcp_config_path = get_mcp_config_path()?;
let mcp_config_found = mcp_config_path.exists();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable().await?;
let result = SetupResult {
node_version,
mcp_config_path: mcp_config_path.display().to_string(),
mcp_config_found,
servers_dir_found,
files_made_executable,
skipped_entries,
};
if output_format == OutputFormat::Pretty {
print_pretty_summary(&result);
return Ok(ExitCode::SUCCESS);
}
crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
}
fn print_pretty_summary(result: &SetupResult) {
println!("✓ Node.js v{} detected", result.node_version);
if result.mcp_config_found {
println!("✓ MCP configuration found: {}", result.mcp_config_path);
} else {
println!("⚠ MCP configuration not found");
println!(" Expected location: {}", result.mcp_config_path);
println!(" Create it with your server configurations:");
println!();
println!(" {{");
println!(" \"mcpServers\": {{");
println!(" \"github\": {{");
println!(" \"command\": \"docker\",");
println!(" \"args\": [\"run\", \"-i\", \"--rm\", \"...\"]");
println!(" }}");
println!(" }}");
println!(" }}");
println!();
println!(" See examples/mcp.json.example for more details.");
}
#[cfg(unix)]
{
if result.servers_dir_found {
if result.files_made_executable > 0 {
println!(
"✓ Made {} TypeScript files executable",
result.files_made_executable
);
}
if result.skipped_entries > 0 {
println!(
"⚠ Skipped {} symlinked entr{} under the servers directory (see warnings above)",
result.skipped_entries,
if result.skipped_entries == 1 {
"y"
} else {
"ies"
}
);
}
} else {
println!("⚠ No servers directory found");
println!(" Run 'mcp-execution-cli generate <server>' to create tools");
}
}
println!("\n✓ Runtime setup complete");
println!(" Claude Code can now execute MCP tools via:");
println!(" node ~/.claude/servers/<server>/<tool>.ts '{{\"param\":\"value\"}}'");
println!("\nNext steps:");
println!(" 1. Generate tools: mcp-execution-cli generate <server>");
println!(" 2. Configure servers in ~/.claude/mcp.json");
println!(" 3. Execute tools autonomously via Node.js");
}
async fn check_node_version() -> Result<String> {
let output = Command::new("node")
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context(
"Node.js not found in PATH.\n\
\n\
Node.js 18+ is required for MCP tool execution.\n\
Install from: https://nodejs.org\n\
\n\
Or use a version manager:\n\
- nvm: https://github.com/nvm-sh/nvm\n\
- fnm: https://github.com/Schniz/fnm",
)?;
if !output.status.success() {
anyhow::bail!("Node.js is installed but not working correctly");
}
let version_str = String::from_utf8_lossy(&output.stdout);
let version_str = version_str.trim().trim_start_matches('v');
let major_version = version_str
.split('.')
.next()
.and_then(|s| s.parse::<u32>().ok())
.context("Failed to parse Node.js version")?;
if major_version < 18 {
anyhow::bail!(
"Node.js version {version_str} is too old.\n\
\n\
Required: Node.js 18.0.0 or higher\n\
Current: Node.js {version_str}\n\
\n\
Please upgrade Node.js:\n\
- Download: https://nodejs.org\n\
- Or use nvm: nvm install 18"
);
}
Ok(version_str.to_string())
}
#[cfg(unix)]
async fn check_files_executable() -> Result<(bool, usize, usize)> {
let servers_dir = get_servers_dir()?;
check_files_executable_in(&servers_dir).await
}
#[cfg(not(unix))]
async fn check_files_executable() -> Result<(bool, usize, usize)> {
Ok((false, 0, 0))
}
#[cfg(unix)]
async fn check_files_executable_in(servers_dir: &Path) -> Result<(bool, usize, usize)> {
use tokio::fs;
if !servers_dir.exists() {
return Ok((false, 0, 0));
}
let root = fs::canonicalize(servers_dir).await?;
let entries = fs::read_dir(&root).await?;
let mut count = 0;
let mut skipped = 0;
walk_entries(&root, entries, &mut count, &mut skipped).await?;
Ok((true, count, skipped))
}
#[cfg(unix)]
async fn walk_and_chmod(dir: &Path, count: &mut usize, skipped: &mut usize) -> Result<()> {
use tokio::fs;
let entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(
path = %sanitize_path_for_error(dir),
%error,
"skipping unreadable directory under the servers directory"
);
return Ok(());
}
};
walk_entries(dir, entries, count, skipped).await
}
#[cfg(unix)]
async fn walk_entries(
dir: &Path,
mut entries: tokio::fs::ReadDir,
count: &mut usize,
skipped: &mut usize,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
use tokio::fs;
loop {
let entry = match entries.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(error) => {
tracing::warn!(
path = %sanitize_path_for_error(dir),
%error,
"stopping directory read after error; skipping any remaining entries"
);
break;
}
};
let path = entry.path();
let file_type = entry.file_type().await?;
if file_type.is_symlink() {
tracing::warn!(
path = %sanitize_path_for_error(&path),
"skipping symlinked entry under the servers directory"
);
*skipped += 1;
continue;
}
if file_type.is_dir() {
Box::pin(walk_and_chmod(&path, count, skipped)).await?;
continue;
}
if !file_type.is_file() || path.extension().and_then(|s| s.to_str()) != Some("ts") {
continue;
}
let metadata = fs::metadata(&path).await?;
let mut perms = metadata.permissions();
perms.set_mode(0o755); fs::set_permissions(&path, perms).await?;
*count += 1;
}
Ok(())
}
fn get_mcp_config_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Failed to get home directory")?;
Ok(home.join(".claude").join("mcp.json"))
}
fn get_servers_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Failed to get home directory")?;
Ok(home.join(".claude").join("servers"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_check_node_version() {
let result = check_node_version().await;
if let Err(e) = result {
let error_msg = e.to_string();
assert!(
error_msg.contains("Node.js") || error_msg.contains("version"),
"Error message should be helpful: {error_msg}"
);
}
}
#[test]
fn test_get_mcp_config_path() {
let path = get_mcp_config_path();
assert!(path.is_ok());
let path = path.unwrap();
assert!(path.to_string_lossy().contains(".claude"));
assert!(path.to_string_lossy().contains("mcp.json"));
}
#[test]
fn test_get_servers_dir() {
let path = get_servers_dir();
assert!(path.is_ok());
let path = path.unwrap();
assert!(path.to_string_lossy().contains(".claude"));
assert!(path.to_string_lossy().contains("servers"));
}
#[tokio::test]
async fn test_check_files_executable_no_panic() {
let result = check_files_executable().await;
assert!(result.is_ok());
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_makes_real_ts_files_executable() {
use std::os::unix::fs::PermissionsExt;
let servers_dir = tempfile::TempDir::new().unwrap();
let my_server_dir = servers_dir.path().join("my-server");
tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
let tool_path = my_server_dir.join("tool.ts");
tokio::fs::write(&tool_path, "// tool").await.unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable_in(servers_dir.path()).await.unwrap();
assert!(servers_dir_found);
assert_eq!(files_made_executable, 1);
assert_eq!(skipped_entries, 0);
let mode = tokio::fs::metadata(&tool_path)
.await
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_recurses_into_nested_subdirectories() {
use std::os::unix::fs::PermissionsExt;
let servers_dir = tempfile::TempDir::new().unwrap();
let runtime_dir = servers_dir.path().join("my-server").join("_runtime");
tokio::fs::create_dir_all(&runtime_dir).await.unwrap();
let bridge_path = runtime_dir.join("mcp-bridge.ts");
tokio::fs::write(&bridge_path, "// bridge").await.unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable_in(servers_dir.path()).await.unwrap();
assert!(servers_dir_found);
assert_eq!(files_made_executable, 1);
assert_eq!(skipped_entries, 0);
let mode = tokio::fs::metadata(&bridge_path)
.await
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_skips_unreadable_nested_dir_processes_siblings() {
use std::os::unix::fs::PermissionsExt;
let servers_dir = tempfile::TempDir::new().unwrap();
let good_server_dir = servers_dir.path().join("good-server");
tokio::fs::create_dir_all(&good_server_dir).await.unwrap();
let good_tool_path = good_server_dir.join("tool.ts");
tokio::fs::write(&good_tool_path, "// tool").await.unwrap();
let locked_server_dir = servers_dir.path().join("locked-server");
tokio::fs::create_dir_all(&locked_server_dir).await.unwrap();
tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o000))
.await
.unwrap();
if tokio::fs::read_dir(&locked_server_dir).await.is_ok() {
tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
return;
}
let result = check_files_executable_in(servers_dir.path()).await;
tokio::fs::set_permissions(&locked_server_dir, std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) = result.unwrap();
assert!(servers_dir_found);
assert_eq!(
files_made_executable, 1,
"the healthy sibling directory must still be processed"
);
assert_eq!(skipped_entries, 0);
let mode = tokio::fs::metadata(&good_tool_path)
.await
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_propagates_root_read_dir_failure() {
use std::os::unix::fs::PermissionsExt;
let servers_dir = tempfile::TempDir::new().unwrap();
tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o000))
.await
.unwrap();
if tokio::fs::read_dir(servers_dir.path()).await.is_ok() {
tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
return;
}
let result = check_files_executable_in(servers_dir.path()).await;
tokio::fs::set_permissions(servers_dir.path(), std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
assert!(
result.is_err(),
"an unreadable servers directory root must propagate an error, not report success"
);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_skips_symlinked_nested_subdirectory() {
let servers_dir = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let target_path = outside.path().join("target.ts");
tokio::fs::write(&target_path, "// outside").await.unwrap();
let my_server_dir = servers_dir.path().join("my-server");
tokio::fs::create_dir_all(&my_server_dir).await.unwrap();
std::os::unix::fs::symlink(outside.path(), my_server_dir.join("_runtime")).unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable_in(servers_dir.path()).await.unwrap();
assert!(servers_dir_found);
assert_eq!(files_made_executable, 0);
assert_eq!(skipped_entries, 1);
let mode = std::os::unix::fs::PermissionsExt::mode(
&tokio::fs::metadata(&target_path)
.await
.unwrap()
.permissions(),
);
assert_eq!(
mode & 0o111,
0,
"symlinked nested directory's target must not be descended into"
);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_skips_symlinked_server_dir() {
let servers_dir = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let target_path = outside.path().join("target.ts");
tokio::fs::write(&target_path, "// outside").await.unwrap();
std::os::unix::fs::symlink(outside.path(), servers_dir.path().join("evil-server")).unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable_in(servers_dir.path()).await.unwrap();
assert!(servers_dir_found);
assert_eq!(files_made_executable, 0);
assert_eq!(skipped_entries, 1);
let mode = std::os::unix::fs::PermissionsExt::mode(
&tokio::fs::metadata(&target_path)
.await
.unwrap()
.permissions(),
);
assert_eq!(
mode & 0o111,
0,
"symlinked target must not become executable"
);
}
#[cfg(unix)]
#[tokio::test]
async fn check_files_executable_in_skips_symlinked_ts_file() {
let servers_dir = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let target_path = outside.path().join("target.ts");
tokio::fs::write(&target_path, "// outside").await.unwrap();
let legit_server_dir = servers_dir.path().join("legit-server");
tokio::fs::create_dir_all(&legit_server_dir).await.unwrap();
std::os::unix::fs::symlink(&target_path, legit_server_dir.join("link.ts")).unwrap();
let (servers_dir_found, files_made_executable, skipped_entries) =
check_files_executable_in(servers_dir.path()).await.unwrap();
assert!(servers_dir_found);
assert_eq!(files_made_executable, 0);
assert_eq!(skipped_entries, 1);
let mode = std::os::unix::fs::PermissionsExt::mode(
&tokio::fs::metadata(&target_path)
.await
.unwrap()
.permissions(),
);
assert_eq!(
mode & 0o111,
0,
"symlinked target must not become executable"
);
}
#[test]
fn test_setup_result_serialization() {
let result = SetupResult {
node_version: "20.10.0".to_string(),
mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
mcp_config_found: true,
servers_dir_found: true,
files_made_executable: 3,
skipped_entries: 0,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"node_version\":\"20.10.0\""));
assert!(json.contains("\"mcp_config_found\":true"));
assert!(json.contains("\"files_made_executable\":3"));
}
#[test]
fn test_setup_result_format_output_json() {
let result = SetupResult {
node_version: "20.10.0".to_string(),
mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
mcp_config_found: false,
servers_dir_found: true,
files_made_executable: 7,
skipped_entries: 0,
};
let formatted =
crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Json)
.unwrap();
assert!(formatted.contains("\"node_version\": \"20.10.0\""));
assert!(formatted.contains("\"mcp_config_path\": \"/home/user/.claude/mcp.json\""));
assert!(formatted.contains("\"mcp_config_found\": false"));
assert!(formatted.contains("\"servers_dir_found\": true"));
assert!(formatted.contains("\"files_made_executable\": 7"));
}
#[test]
fn test_setup_result_format_output_text() {
let result = SetupResult {
node_version: "20.10.0".to_string(),
mcp_config_path: "/home/user/.claude/mcp.json".to_string(),
mcp_config_found: true,
servers_dir_found: false,
files_made_executable: 0,
skipped_entries: 0,
};
let formatted =
crate::formatters::format_output(&result, mcp_execution_core::cli::OutputFormat::Text)
.unwrap();
assert!(!formatted.contains('\n'));
assert!(formatted.contains("\"node_version\":\"20.10.0\""));
assert!(formatted.contains("\"mcp_config_found\":true"));
assert!(formatted.contains("\"servers_dir_found\":false"));
assert!(formatted.contains("\"files_made_executable\":0"));
}
}