1use 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#[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#[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
49pub fn execute(args: &InstallArgs) -> Result<()> {
51 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 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 let config_path = get_config_path(&args.target)?;
70
71 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 let mut config = load_config(&config_path)?;
82
83 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 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 let server_config = McpServerConfig {
111 command: server_path.to_string_lossy().to_string(),
112 args: args.args.clone(),
113 env,
114 };
115
116 config
118 .mcp_servers
119 .insert(server_name.clone(), server_config);
120
121 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
139fn 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
147fn 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
179fn 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
215fn 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 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
234fn 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
242fn target_display_name(target: &InstallTarget) -> &'static str {
244 match target {
245 InstallTarget::ClaudeDesktop => "Claude Desktop",
246 InstallTarget::Cursor => "Cursor",
247 }
248}
249
250fn 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
266pub 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}