Skip to main content

magick_mcp/feature/
install.rs

1use serde_json::{Value, json};
2use std::fs;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6/// Type of client to install for
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ClientType {
9    Cursor,
10    Claude,
11    Both,
12}
13
14/// Paths to configuration files
15#[derive(Debug, Clone)]
16pub struct ConfigPaths {
17    pub cursor_path: PathBuf,
18    pub claude_path: PathBuf,
19}
20
21impl ConfigPaths {
22    /// Get default configuration paths based on home directory
23    pub fn from_home_dir() -> Result<Self, InstallError> {
24        let home_dir = dirs::home_dir().ok_or(InstallError::HomeDirNotFound)?;
25
26        Ok(ConfigPaths {
27            cursor_path: home_dir.join(".cursor").join("mcp.json"),
28            claude_path: home_dir.join(".claude.json"),
29        })
30    }
31}
32
33/// Installer for MCP configuration
34pub struct MCPInstaller {
35    client_type: ClientType,
36    config_paths: ConfigPaths,
37}
38
39#[derive(Error, Debug)]
40pub enum InstallError {
41    #[error("Home directory not found")]
42    HomeDirNotFound,
43    #[error("IO error: {0}")]
44    IoError(#[from] std::io::Error),
45    #[error("Failed to parse JSON: {0}")]
46    ParseError(#[from] serde_json::Error),
47    #[error("Failed to get executable path: {0}")]
48    ExePathError(String),
49    #[error("Invalid config: {0}")]
50    InvalidConfig(String),
51}
52
53impl MCPInstaller {
54    /// Create a new installer with the specified client type and config paths
55    pub fn new(client_type: ClientType, config_paths: ConfigPaths) -> Self {
56        MCPInstaller {
57            client_type,
58            config_paths,
59        }
60    }
61
62    /// Install magick-mcp to the specified client(s)
63    pub fn install(&self) -> Result<(), InstallError> {
64        match self.client_type {
65            ClientType::Cursor => {
66                self.update_config(&self.config_paths.cursor_path)?;
67            }
68            ClientType::Claude => {
69                self.update_config(&self.config_paths.claude_path)?;
70            }
71            ClientType::Both => {
72                self.update_config(&self.config_paths.cursor_path)?;
73                self.update_config(&self.config_paths.claude_path)?;
74            }
75        }
76        Ok(())
77    }
78
79    /// Update a single configuration file
80    fn update_config(&self, path: &Path) -> Result<(), InstallError> {
81        // Get the path to the magick-mcp executable
82        let exe_path =
83            std::env::current_exe().map_err(|e| InstallError::ExePathError(e.to_string()))?;
84
85        // Read existing config or create new one
86        let mut config: Value = if path.exists() {
87            let contents = fs::read_to_string(path)?;
88            if contents.trim().is_empty() {
89                json!({})
90            } else {
91                serde_json::from_str(&contents)?
92            }
93        } else {
94            json!({})
95        };
96
97        // Ensure mcpServers object exists
98        if config.get("mcpServers").is_none() {
99            config["mcpServers"] = json!({});
100        }
101
102        // Get or create mcpServers object
103        let mcp_servers = config
104            .get_mut("mcpServers")
105            .and_then(|v| v.as_object_mut())
106            .ok_or_else(|| {
107                InstallError::InvalidConfig("mcpServers is not an object".to_string())
108            })?;
109
110        // Add or update magick-mcp server entry
111        mcp_servers.insert(
112            "magick-mcp".to_string(),
113            json!({
114                "command": exe_path.to_string_lossy().to_string(),
115                "args": ["mcp"]
116            }),
117        );
118
119        // Create parent directory if it doesn't exist
120        if let Some(parent) = path.parent() {
121            fs::create_dir_all(parent)?;
122        }
123
124        // Write updated config back to file
125        let pretty_json = serde_json::to_string_pretty(&config)?;
126        fs::write(path, pretty_json)?;
127
128        Ok(())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::fs;
136    use tempfile::TempDir;
137
138    fn create_temp_config(dir: &TempDir, filename: &str, content: &str) -> PathBuf {
139        let path = dir.path().join(filename);
140        fs::write(&path, content).unwrap();
141        path
142    }
143
144    #[test]
145    fn test_install_cursor_only() {
146        let temp_dir = TempDir::new().unwrap();
147        let cursor_path = create_temp_config(
148            &temp_dir,
149            "mcp.json",
150            r#"{"mcpServers": {"existing-server": {"command": "existing"}}}"#,
151        );
152        let claude_path = temp_dir.path().join("claude.json");
153
154        let config_paths = ConfigPaths {
155            cursor_path: cursor_path.clone(),
156            claude_path,
157        };
158
159        let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
160        installer.install().unwrap();
161
162        let contents = fs::read_to_string(&cursor_path).unwrap();
163        let config: Value = serde_json::from_str(&contents).unwrap();
164
165        assert!(config["mcpServers"]["magick-mcp"].is_object());
166        assert!(config["mcpServers"]["existing-server"].is_object());
167        assert_eq!(config["mcpServers"]["magick-mcp"]["args"], json!(["mcp"]));
168    }
169
170    #[test]
171    fn test_install_claude_only() {
172        let temp_dir = TempDir::new().unwrap();
173        let cursor_path = temp_dir.path().join("mcp.json");
174        let claude_path = create_temp_config(
175            &temp_dir,
176            "claude.json",
177            r#"{"mcpServers": {"existing-server": {"command": "existing"}}}"#,
178        );
179
180        let config_paths = ConfigPaths {
181            cursor_path,
182            claude_path: claude_path.clone(),
183        };
184
185        let installer = MCPInstaller::new(ClientType::Claude, config_paths);
186        installer.install().unwrap();
187
188        let contents = fs::read_to_string(&claude_path).unwrap();
189        let config: Value = serde_json::from_str(&contents).unwrap();
190
191        assert!(config["mcpServers"]["magick-mcp"].is_object());
192        assert!(config["mcpServers"]["existing-server"].is_object());
193    }
194
195    #[test]
196    fn test_install_both() {
197        let temp_dir = TempDir::new().unwrap();
198        let cursor_path = create_temp_config(
199            &temp_dir,
200            "mcp.json",
201            r#"{"mcpServers": {"cursor-server": {"command": "cursor"}}}"#,
202        );
203        let claude_path = create_temp_config(
204            &temp_dir,
205            "claude.json",
206            r#"{"mcpServers": {"claude-server": {"command": "claude"}}}"#,
207        );
208
209        let config_paths = ConfigPaths {
210            cursor_path: cursor_path.clone(),
211            claude_path: claude_path.clone(),
212        };
213
214        let installer = MCPInstaller::new(ClientType::Both, config_paths);
215        installer.install().unwrap();
216
217        // Check cursor config
218        let cursor_contents = fs::read_to_string(&cursor_path).unwrap();
219        let cursor_config: Value = serde_json::from_str(&cursor_contents).unwrap();
220        assert!(cursor_config["mcpServers"]["magick-mcp"].is_object());
221        assert!(cursor_config["mcpServers"]["cursor-server"].is_object());
222
223        // Check claude config
224        let claude_contents = fs::read_to_string(&claude_path).unwrap();
225        let claude_config: Value = serde_json::from_str(&claude_contents).unwrap();
226        assert!(claude_config["mcpServers"]["magick-mcp"].is_object());
227        assert!(claude_config["mcpServers"]["claude-server"].is_object());
228    }
229
230    #[test]
231    fn test_preserve_existing_servers() {
232        let temp_dir = TempDir::new().unwrap();
233        let cursor_path = create_temp_config(
234            &temp_dir,
235            "mcp.json",
236            r#"{
237                "mcpServers": {
238                    "server1": {"command": "cmd1", "args": ["arg1"]},
239                    "server2": {"command": "cmd2"}
240                }
241            }"#,
242        );
243        let claude_path = temp_dir.path().join("claude.json");
244
245        let config_paths = ConfigPaths {
246            cursor_path: cursor_path.clone(),
247            claude_path,
248        };
249
250        let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
251        installer.install().unwrap();
252
253        let contents = fs::read_to_string(&cursor_path).unwrap();
254        let config: Value = serde_json::from_str(&contents).unwrap();
255
256        assert!(config["mcpServers"]["magick-mcp"].is_object());
257        assert!(config["mcpServers"]["server1"].is_object());
258        assert!(config["mcpServers"]["server2"].is_object());
259        assert_eq!(config["mcpServers"]["server1"]["args"], json!(["arg1"]));
260    }
261
262    #[test]
263    fn test_create_config_when_not_exists() {
264        let temp_dir = TempDir::new().unwrap();
265        let cursor_path = temp_dir.path().join("mcp.json");
266        let claude_path = temp_dir.path().join("claude.json");
267
268        let config_paths = ConfigPaths {
269            cursor_path: cursor_path.clone(),
270            claude_path,
271        };
272
273        let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
274        installer.install().unwrap();
275
276        assert!(cursor_path.exists());
277        let contents = fs::read_to_string(&cursor_path).unwrap();
278        let config: Value = serde_json::from_str(&contents).unwrap();
279
280        assert!(config["mcpServers"]["magick-mcp"].is_object());
281    }
282
283    #[test]
284    fn test_update_existing_magick_mcp() {
285        let temp_dir = TempDir::new().unwrap();
286        let cursor_path = create_temp_config(
287            &temp_dir,
288            "mcp.json",
289            r#"{
290                "mcpServers": {
291                    "magick-mcp": {"command": "old-path", "args": ["old"]}
292                }
293            }"#,
294        );
295        let claude_path = temp_dir.path().join("claude.json");
296
297        let config_paths = ConfigPaths {
298            cursor_path: cursor_path.clone(),
299            claude_path,
300        };
301
302        let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
303        installer.install().unwrap();
304
305        let contents = fs::read_to_string(&cursor_path).unwrap();
306        let config: Value = serde_json::from_str(&contents).unwrap();
307
308        assert_eq!(config["mcpServers"]["magick-mcp"]["args"], json!(["mcp"]));
309        // Command should be updated to current exe path
310        assert!(
311            config["mcpServers"]["magick-mcp"]["command"]
312                .as_str()
313                .unwrap()
314                .contains("magick-mcp")
315        );
316    }
317}