use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientType {
Cursor,
Claude,
Both,
}
#[derive(Debug, Clone)]
pub struct ConfigPaths {
pub cursor_path: PathBuf,
pub claude_path: PathBuf,
}
impl ConfigPaths {
pub fn from_home_dir() -> Result<Self, InstallError> {
let home_dir = dirs::home_dir().ok_or(InstallError::HomeDirNotFound)?;
Ok(ConfigPaths {
cursor_path: home_dir.join(".cursor").join("mcp.json"),
claude_path: home_dir.join(".claude.json"),
})
}
}
pub struct MCPInstaller {
client_type: ClientType,
config_paths: ConfigPaths,
}
#[derive(Error, Debug)]
pub enum InstallError {
#[error("Home directory not found")]
HomeDirNotFound,
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse JSON: {0}")]
ParseError(#[from] serde_json::Error),
#[error("Failed to get executable path: {0}")]
ExePathError(String),
#[error("Invalid config: {0}")]
InvalidConfig(String),
}
impl MCPInstaller {
pub fn new(client_type: ClientType, config_paths: ConfigPaths) -> Self {
MCPInstaller {
client_type,
config_paths,
}
}
pub fn install(&self) -> Result<(), InstallError> {
match self.client_type {
ClientType::Cursor => {
self.update_config(&self.config_paths.cursor_path)?;
}
ClientType::Claude => {
self.update_config(&self.config_paths.claude_path)?;
}
ClientType::Both => {
self.update_config(&self.config_paths.cursor_path)?;
self.update_config(&self.config_paths.claude_path)?;
}
}
Ok(())
}
fn update_config(&self, path: &Path) -> Result<(), InstallError> {
let exe_path =
std::env::current_exe().map_err(|e| InstallError::ExePathError(e.to_string()))?;
let mut config: Value = if path.exists() {
let contents = fs::read_to_string(path)?;
if contents.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&contents)?
}
} else {
json!({})
};
if config.get("mcpServers").is_none() {
config["mcpServers"] = json!({});
}
let mcp_servers = config
.get_mut("mcpServers")
.and_then(|v| v.as_object_mut())
.ok_or_else(|| {
InstallError::InvalidConfig("mcpServers is not an object".to_string())
})?;
mcp_servers.insert(
"magick-mcp".to_string(),
json!({
"command": exe_path.to_string_lossy().to_string(),
"args": ["mcp"]
}),
);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let pretty_json = serde_json::to_string_pretty(&config)?;
fs::write(path, pretty_json)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_temp_config(dir: &TempDir, filename: &str, content: &str) -> PathBuf {
let path = dir.path().join(filename);
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_install_cursor_only() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = create_temp_config(
&temp_dir,
"mcp.json",
r#"{"mcpServers": {"existing-server": {"command": "existing"}}}"#,
);
let claude_path = temp_dir.path().join("claude.json");
let config_paths = ConfigPaths {
cursor_path: cursor_path.clone(),
claude_path,
};
let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
installer.install().unwrap();
let contents = fs::read_to_string(&cursor_path).unwrap();
let config: Value = serde_json::from_str(&contents).unwrap();
assert!(config["mcpServers"]["magick-mcp"].is_object());
assert!(config["mcpServers"]["existing-server"].is_object());
assert_eq!(config["mcpServers"]["magick-mcp"]["args"], json!(["mcp"]));
}
#[test]
fn test_install_claude_only() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = temp_dir.path().join("mcp.json");
let claude_path = create_temp_config(
&temp_dir,
"claude.json",
r#"{"mcpServers": {"existing-server": {"command": "existing"}}}"#,
);
let config_paths = ConfigPaths {
cursor_path,
claude_path: claude_path.clone(),
};
let installer = MCPInstaller::new(ClientType::Claude, config_paths);
installer.install().unwrap();
let contents = fs::read_to_string(&claude_path).unwrap();
let config: Value = serde_json::from_str(&contents).unwrap();
assert!(config["mcpServers"]["magick-mcp"].is_object());
assert!(config["mcpServers"]["existing-server"].is_object());
}
#[test]
fn test_install_both() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = create_temp_config(
&temp_dir,
"mcp.json",
r#"{"mcpServers": {"cursor-server": {"command": "cursor"}}}"#,
);
let claude_path = create_temp_config(
&temp_dir,
"claude.json",
r#"{"mcpServers": {"claude-server": {"command": "claude"}}}"#,
);
let config_paths = ConfigPaths {
cursor_path: cursor_path.clone(),
claude_path: claude_path.clone(),
};
let installer = MCPInstaller::new(ClientType::Both, config_paths);
installer.install().unwrap();
let cursor_contents = fs::read_to_string(&cursor_path).unwrap();
let cursor_config: Value = serde_json::from_str(&cursor_contents).unwrap();
assert!(cursor_config["mcpServers"]["magick-mcp"].is_object());
assert!(cursor_config["mcpServers"]["cursor-server"].is_object());
let claude_contents = fs::read_to_string(&claude_path).unwrap();
let claude_config: Value = serde_json::from_str(&claude_contents).unwrap();
assert!(claude_config["mcpServers"]["magick-mcp"].is_object());
assert!(claude_config["mcpServers"]["claude-server"].is_object());
}
#[test]
fn test_preserve_existing_servers() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = create_temp_config(
&temp_dir,
"mcp.json",
r#"{
"mcpServers": {
"server1": {"command": "cmd1", "args": ["arg1"]},
"server2": {"command": "cmd2"}
}
}"#,
);
let claude_path = temp_dir.path().join("claude.json");
let config_paths = ConfigPaths {
cursor_path: cursor_path.clone(),
claude_path,
};
let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
installer.install().unwrap();
let contents = fs::read_to_string(&cursor_path).unwrap();
let config: Value = serde_json::from_str(&contents).unwrap();
assert!(config["mcpServers"]["magick-mcp"].is_object());
assert!(config["mcpServers"]["server1"].is_object());
assert!(config["mcpServers"]["server2"].is_object());
assert_eq!(config["mcpServers"]["server1"]["args"], json!(["arg1"]));
}
#[test]
fn test_create_config_when_not_exists() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = temp_dir.path().join("mcp.json");
let claude_path = temp_dir.path().join("claude.json");
let config_paths = ConfigPaths {
cursor_path: cursor_path.clone(),
claude_path,
};
let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
installer.install().unwrap();
assert!(cursor_path.exists());
let contents = fs::read_to_string(&cursor_path).unwrap();
let config: Value = serde_json::from_str(&contents).unwrap();
assert!(config["mcpServers"]["magick-mcp"].is_object());
}
#[test]
fn test_update_existing_magick_mcp() {
let temp_dir = TempDir::new().unwrap();
let cursor_path = create_temp_config(
&temp_dir,
"mcp.json",
r#"{
"mcpServers": {
"magick-mcp": {"command": "old-path", "args": ["old"]}
}
}"#,
);
let claude_path = temp_dir.path().join("claude.json");
let config_paths = ConfigPaths {
cursor_path: cursor_path.clone(),
claude_path,
};
let installer = MCPInstaller::new(ClientType::Cursor, config_paths);
installer.install().unwrap();
let contents = fs::read_to_string(&cursor_path).unwrap();
let config: Value = serde_json::from_str(&contents).unwrap();
assert_eq!(config["mcpServers"]["magick-mcp"]["args"], json!(["mcp"]));
assert!(
config["mcpServers"]["magick-mcp"]["command"]
.as_str()
.unwrap()
.contains("magick-mcp")
);
}
}