Skip to main content

turbomcp_cli/
install.rs

1//! Install MCP server to Claude Desktop or Cursor.
2//!
3//! This module implements the `turbomcp install` command which:
4//! - Locates the application's MCP configuration file
5//! - Adds or updates the server entry
6//! - Validates the server binary exists and is executable
7//!
8//! # Usage
9//!
10//! ```bash
11//! # Install to Claude Desktop
12//! turbomcp install claude-desktop ./target/release/my-server
13//!
14//! # Install to Cursor
15//! turbomcp install cursor ./my-server --name "My MCP Server"
16//!
17//! # With environment variables
18//! turbomcp install claude-desktop ./my-server -e API_KEY=xxx -e DEBUG=true
19//! ```
20
21use std::collections::HashMap;
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result, bail};
26use serde::{Deserialize, Serialize};
27
28use crate::cli::{InstallArgs, InstallTarget};
29
30/// MCP server configuration in claude_desktop_config.json format.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32struct McpServerConfig {
33    command: String,
34    #[serde(skip_serializing_if = "Vec::is_empty", default)]
35    args: Vec<String>,
36    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
37    env: HashMap<String, String>,
38}
39
40/// Root configuration structure for Claude Desktop.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42struct ClaudeDesktopConfig {
43    #[serde(rename = "mcpServers", default)]
44    mcp_servers: HashMap<String, McpServerConfig>,
45    #[serde(flatten)]
46    other: HashMap<String, serde_json::Value>,
47}
48
49/// Execute the install command.
50pub fn execute(args: &InstallArgs) -> Result<()> {
51    // Validate server path exists
52    let server_path = args.server_path.canonicalize().with_context(|| {
53        format!(
54            "Server binary not found at '{}'",
55            args.server_path.display()
56        )
57    })?;
58
59    // Check if it's executable
60    if !is_executable(&server_path) {
61        bail!(
62            "Server binary '{}' is not executable. \
63             Make sure it's compiled and has execute permissions.",
64            server_path.display()
65        );
66    }
67
68    // Get config file path for target
69    let config_path = get_config_path(&args.target)?;
70
71    // Derive server name
72    let server_name = args.name.clone().unwrap_or_else(|| {
73        server_path
74            .file_stem()
75            .and_then(|s| s.to_str())
76            .unwrap_or("mcp-server")
77            .to_string()
78    });
79
80    // Load or create config
81    let mut config = load_config(&config_path)?;
82
83    // Check if server already exists
84    if config.mcp_servers.contains_key(&server_name) && !args.force {
85        bail!(
86            "Server '{}' already exists in config. Use --force to overwrite.",
87            server_name
88        );
89    }
90
91    // Parse environment variables
92    let env: HashMap<String, String> = args
93        .env
94        .iter()
95        .filter_map(|e| {
96            let parts: Vec<&str> = e.splitn(2, '=').collect();
97            if parts.len() == 2 {
98                Some((parts[0].to_string(), parts[1].to_string()))
99            } else {
100                eprintln!(
101                    "Warning: Ignoring invalid env var '{}' (expected KEY=VALUE)",
102                    e
103                );
104                None
105            }
106        })
107        .collect();
108
109    // Create server config
110    let server_config = McpServerConfig {
111        command: server_path.to_string_lossy().to_string(),
112        args: args.args.clone(),
113        env,
114    };
115
116    // Add to config
117    config
118        .mcp_servers
119        .insert(server_name.clone(), server_config);
120
121    // Save config
122    save_config(&config_path, &config)?;
123
124    println!(
125        "Successfully installed MCP server '{}' to {:?}",
126        server_name, args.target
127    );
128    println!();
129    println!("Configuration file: {}", config_path.display());
130    println!();
131    println!(
132        "Restart {} to load the new server.",
133        target_display_name(&args.target)
134    );
135
136    Ok(())
137}
138
139/// Get the configuration file path for a target application.
140fn get_config_path(target: &InstallTarget) -> Result<PathBuf> {
141    match target {
142        InstallTarget::ClaudeDesktop => get_claude_desktop_config_path(),
143        InstallTarget::Cursor => get_cursor_config_path(),
144    }
145}
146
147/// Get Claude Desktop config path.
148fn get_claude_desktop_config_path() -> Result<PathBuf> {
149    #[cfg(target_os = "macos")]
150    {
151        let path = dirs::home_dir()
152            .context("Could not find home directory")?
153            .join("Library/Application Support/Claude/claude_desktop_config.json");
154        Ok(path)
155    }
156
157    #[cfg(target_os = "windows")]
158    {
159        let path = dirs::data_dir()
160            .context("Could not find AppData directory")?
161            .join("Claude/claude_desktop_config.json");
162        Ok(path)
163    }
164
165    #[cfg(target_os = "linux")]
166    {
167        let path = dirs::config_dir()
168            .context("Could not find config directory")?
169            .join("Claude/claude_desktop_config.json");
170        Ok(path)
171    }
172
173    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
174    {
175        bail!("Claude Desktop config path not known for this platform")
176    }
177}
178
179/// Get Cursor config path.
180fn get_cursor_config_path() -> Result<PathBuf> {
181    #[cfg(target_os = "macos")]
182    {
183        let path = dirs::home_dir()
184            .context("Could not find home directory")?
185            .join("Library/Application Support/Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json");
186        Ok(path)
187    }
188
189    #[cfg(target_os = "windows")]
190    {
191        let path = dirs::data_dir()
192            .context("Could not find AppData directory")?
193            .join(
194                "Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
195            );
196        Ok(path)
197    }
198
199    #[cfg(target_os = "linux")]
200    {
201        let path = dirs::config_dir()
202            .context("Could not find config directory")?
203            .join(
204                "Cursor/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
205            );
206        Ok(path)
207    }
208
209    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
210    {
211        bail!("Cursor config path not known for this platform")
212    }
213}
214
215/// Load configuration from file, or return default if file doesn't exist.
216fn load_config(path: &Path) -> Result<ClaudeDesktopConfig> {
217    if path.exists() {
218        let contents = fs::read_to_string(path)
219            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
220
221        serde_json::from_str(&contents)
222            .with_context(|| format!("Failed to parse config file: {}", path.display()))
223    } else {
224        // Create parent directories if needed
225        if let Some(parent) = path.parent() {
226            fs::create_dir_all(parent).with_context(|| {
227                format!("Failed to create config directory: {}", parent.display())
228            })?;
229        }
230        Ok(ClaudeDesktopConfig::default())
231    }
232}
233
234/// Save configuration to file.
235fn save_config(path: &Path, config: &ClaudeDesktopConfig) -> Result<()> {
236    let contents = serde_json::to_string_pretty(config).context("Failed to serialize config")?;
237
238    fs::write(path, contents)
239        .with_context(|| format!("Failed to write config file: {}", path.display()))
240}
241
242/// Get display name for a target.
243fn target_display_name(target: &InstallTarget) -> &'static str {
244    match target {
245        InstallTarget::ClaudeDesktop => "Claude Desktop",
246        InstallTarget::Cursor => "Cursor",
247    }
248}
249
250/// Check if a path is an executable file.
251fn is_executable(path: &Path) -> bool {
252    #[cfg(unix)]
253    {
254        use std::os::unix::fs::PermissionsExt;
255        path.metadata()
256            .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
257            .unwrap_or(false)
258    }
259
260    #[cfg(not(unix))]
261    {
262        path.is_file()
263    }
264}
265
266/// List installed MCP servers (for info display).
267pub fn list_installed(target: &InstallTarget) -> Result<Vec<String>> {
268    let config_path = get_config_path(target)?;
269    let config = load_config(&config_path)?;
270    Ok(config.mcp_servers.keys().cloned().collect())
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use tempfile::tempdir;
277
278    #[test]
279    fn test_load_missing_config() {
280        let dir = tempdir().unwrap();
281        let path = dir.path().join("nonexistent.json");
282
283        let config = load_config(&path).unwrap();
284        assert!(config.mcp_servers.is_empty());
285    }
286
287    #[test]
288    fn test_load_save_config() {
289        let dir = tempdir().unwrap();
290        let path = dir.path().join("config.json");
291
292        let mut config = ClaudeDesktopConfig::default();
293        config.mcp_servers.insert(
294            "test-server".to_string(),
295            McpServerConfig {
296                command: "/usr/bin/test".to_string(),
297                args: vec!["--flag".to_string()],
298                env: HashMap::new(),
299            },
300        );
301
302        save_config(&path, &config).unwrap();
303
304        let loaded = load_config(&path).unwrap();
305        assert!(loaded.mcp_servers.contains_key("test-server"));
306        assert_eq!(loaded.mcp_servers["test-server"].command, "/usr/bin/test");
307    }
308
309    #[test]
310    fn test_parse_env_vars() {
311        let env_strings = [
312            "KEY1=value1".to_string(),
313            "KEY2=value with spaces".to_string(),
314            "KEY3=value=with=equals".to_string(),
315        ];
316
317        let env: HashMap<String, String> = env_strings
318            .iter()
319            .filter_map(|e| {
320                let parts: Vec<&str> = e.splitn(2, '=').collect();
321                if parts.len() == 2 {
322                    Some((parts[0].to_string(), parts[1].to_string()))
323                } else {
324                    None
325                }
326            })
327            .collect();
328
329        assert_eq!(env.get("KEY1"), Some(&"value1".to_string()));
330        assert_eq!(env.get("KEY2"), Some(&"value with spaces".to_string()));
331        assert_eq!(env.get("KEY3"), Some(&"value=with=equals".to_string()));
332    }
333}