use std::path::PathBuf;
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigShape {
Claude,
VsCode,
Zed,
}
impl ConfigShape {
fn top_key(self) -> &'static str {
match self {
Self::Claude => "mcpServers",
Self::VsCode => "servers",
Self::Zed => "context_servers",
}
}
fn entry(self, command: &str, args: &[String]) -> Value {
let args_json = Value::Array(args.iter().map(|a| Value::String(a.clone())).collect());
match self {
Self::Claude => json!({ "command": command, "args": args_json }),
Self::VsCode => json!({ "type": "stdio", "command": command, "args": args_json }),
Self::Zed => json!({ "source": "custom", "command": command, "args": args_json }),
}
}
}
pub struct ClientTarget {
pub key: &'static str,
pub display_name: &'static str,
pub shape: ConfigShape,
pub config_path: fn() -> Option<PathBuf>,
}
pub fn mergeable_targets() -> Vec<ClientTarget> {
vec![
ClientTarget {
key: "claude-desktop",
display_name: "Claude Desktop",
shape: ConfigShape::Claude,
config_path: claude_desktop_config_path,
},
ClientTarget {
key: "claude-code",
display_name: "Claude Code",
shape: ConfigShape::Claude,
config_path: claude_code_config_path,
},
ClientTarget {
key: "cursor",
display_name: "Cursor",
shape: ConfigShape::Claude,
config_path: cursor_config_path,
},
ClientTarget {
key: "vscode",
display_name: "VS Code",
shape: ConfigShape::VsCode,
config_path: vscode_config_path,
},
ClientTarget {
key: "vscode-copilot",
display_name: "VS Code Copilot Chat",
shape: ConfigShape::VsCode,
config_path: vscode_config_path,
},
ClientTarget {
key: "zed",
display_name: "Zed",
shape: ConfigShape::Zed,
config_path: zed_config_path,
},
ClientTarget {
key: "windsurf",
display_name: "Windsurf",
shape: ConfigShape::Claude,
config_path: windsurf_config_path,
},
]
}
pub fn merge_entry(
existing: &str,
shape: ConfigShape,
server_key: &str,
command: &str,
args: &[String],
) -> Result<String, String> {
let mut root: Value = if existing.trim().is_empty() {
Value::Object(Map::new())
} else {
serde_json::from_str(existing)
.map_err(|e| format!("existing config is not valid JSON: {e}"))?
};
if !root.is_object() {
return Err("existing config's top level is not a JSON object".into());
}
let root_obj = root.as_object_mut().expect("checked above");
let top_key = shape.top_key();
let servers = root_obj
.entry(top_key)
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
return Err(format!("existing `{top_key}` is not a JSON object"));
}
servers
.as_object_mut()
.expect("checked above")
.insert(server_key.to_string(), shape.entry(command, args));
serde_json::to_string_pretty(&root).map_err(|e| e.to_string())
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn claude_desktop_config_path() -> Option<PathBuf> {
match std::env::consts::OS {
"macos" => home_dir()
.map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json")),
"windows" => std::env::var_os("APPDATA").map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
}),
_ => home_dir().map(|h| h.join(".config/Claude/claude_desktop_config.json")),
}
}
fn claude_code_config_path() -> Option<PathBuf> {
std::env::current_dir().ok().map(|d| d.join(".mcp.json"))
}
fn cursor_config_path() -> Option<PathBuf> {
std::env::current_dir()
.ok()
.map(|d| d.join(".cursor").join("mcp.json"))
}
fn vscode_config_path() -> Option<PathBuf> {
std::env::current_dir()
.ok()
.map(|d| d.join(".vscode").join("mcp.json"))
}
fn zed_config_path() -> Option<PathBuf> {
match std::env::consts::OS {
"macos" => home_dir().map(|h| h.join("Library/Application Support/Zed/settings.json")),
"windows" => {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Zed").join("settings.json"))
}
_ => home_dir().map(|h| h.join(".config/zed/settings.json")),
}
}
fn windsurf_config_path() -> Option<PathBuf> {
home_dir().map(|h| h.join(".codeium").join("windsurf").join("mcp_config.json"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_into_empty_creates_top_key() {
let out = merge_entry(
"",
ConfigShape::Claude,
"nexql-mcp",
"nexql-mcp",
&["--profile".into(), "local".into()],
)
.unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["mcpServers"]["nexql-mcp"]["command"], "nexql-mcp");
assert_eq!(v["mcpServers"]["nexql-mcp"]["args"][1], "local");
}
#[test]
fn merge_preserves_unrelated_sibling_servers() {
let existing = r#"{"mcpServers": {"other-server": {"command": "other", "args": []}}}"#;
let out =
merge_entry(existing, ConfigShape::Claude, "nexql-mcp", "nexql-mcp", &[]).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["mcpServers"]["other-server"]["command"], "other");
assert_eq!(v["mcpServers"]["nexql-mcp"]["command"], "nexql-mcp");
}
#[test]
fn merge_preserves_unrelated_top_level_keys() {
let existing = r#"{"someOtherSetting": true, "servers": {}}"#;
let out =
merge_entry(existing, ConfigShape::VsCode, "nexql-mcp", "nexql-mcp", &[]).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["someOtherSetting"], true);
assert_eq!(v["servers"]["nexql-mcp"]["type"], "stdio");
}
#[test]
fn vscode_shape_sets_stdio_type() {
let out = merge_entry("", ConfigShape::VsCode, "nexql-mcp", "nexql-mcp", &[]).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["servers"]["nexql-mcp"]["type"], "stdio");
}
#[test]
fn zed_shape_sets_source_custom() {
let out = merge_entry("", ConfigShape::Zed, "nexql-mcp", "nexql-mcp", &[]).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["context_servers"]["nexql-mcp"]["source"], "custom");
}
#[test]
fn rejects_non_object_top_level() {
let err =
merge_entry("[]", ConfigShape::Claude, "nexql-mcp", "nexql-mcp", &[]).unwrap_err();
assert!(err.contains("not a JSON object"));
}
#[test]
fn rejects_invalid_json() {
let err = merge_entry(
"{not json",
ConfigShape::Claude,
"nexql-mcp",
"nexql-mcp",
&[],
)
.unwrap_err();
assert!(err.contains("not valid JSON"));
}
#[test]
fn mergeable_targets_cover_seven_clients() {
let targets = mergeable_targets();
assert_eq!(targets.len(), 7);
let keys: Vec<_> = targets.iter().map(|t| t.key).collect();
for expected in [
"claude-desktop",
"claude-code",
"cursor",
"vscode",
"vscode-copilot",
"zed",
"windsurf",
] {
assert!(keys.contains(&expected), "missing {expected}");
}
}
}