use std::path::Path;
use serde_json::{json, Value};
use crate::errors::{McpError, McpErrorKind};
use crate::install::InstallOpts;
pub fn write_mcp_servers_entry(
path: &Path,
host_label: &str,
opts: &InstallOpts,
) -> Result<(), McpError> {
let mut root = load_or_create(path, host_label)?;
let servers = ensure_servers_object(&mut root)?;
let name = opts.entry_name();
if opts.uninstall {
if servers.remove(&name).is_some() {
tracing::info!(target: "tsafe_mcp::install", "removed mcpServers entry '{name}' from {}", path.display());
}
} else {
let entry = crate::install::claude::build_entry(opts);
servers.insert(name.clone(), entry);
}
if opts.dry_run {
let preview = serde_json::to_string_pretty(&root).unwrap_or_default();
println!("# dry-run: would write {}", path.display());
println!("{preview}");
return Ok(());
}
persist_atomically(path, &root)
}
fn load_or_create(path: &Path, host_label: &str) -> Result<Value, McpError> {
if !path.exists() {
return Ok(json!({"mcpServers": {}}));
}
let raw = std::fs::read_to_string(path).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("read {}: {e}", path.display()),
)
})?;
if raw.trim().is_empty() {
return Ok(json!({"mcpServers": {}}));
}
serde_json::from_str::<Value>(&raw).map_err(|e| {
McpError::new(
McpErrorKind::InstallConfigMalformed,
format!(
"existing {host_label} config at {} is not valid JSON: {e}",
path.display()
),
)
})
}
fn ensure_servers_object(
root: &mut Value,
) -> Result<&mut serde_json::Map<String, Value>, McpError> {
if !root.is_object() {
return Err(McpError::new(
McpErrorKind::InstallConfigMalformed,
"root of host config is not a JSON object",
));
}
let obj = root.as_object_mut().expect("checked above");
if !obj.contains_key("mcpServers") {
obj.insert("mcpServers".to_string(), json!({}));
}
let entry = obj.get_mut("mcpServers").expect("just inserted");
if !entry.is_object() {
return Err(McpError::new(
McpErrorKind::InstallConfigMalformed,
"'mcpServers' is present but not a JSON object",
));
}
Ok(entry.as_object_mut().expect("checked above"))
}
fn persist_atomically(path: &Path, root: &Value) -> Result<(), McpError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("create_dir_all {}: {e}", parent.display()),
)
})?;
}
let pretty = serde_json::to_string_pretty(root)
.map_err(|e| McpError::new(McpErrorKind::InternalError, format!("serialize JSON: {e}")))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &pretty).map_err(|e| {
McpError::new(
McpErrorKind::InternalError,
format!("write {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
McpError::new(
McpErrorKind::InternalError,
format!("rename to {}: {e}", path.display()),
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(name: Option<&str>, uninstall: bool) -> InstallOpts {
InstallOpts {
profile: "demo".to_string(),
allowed_keys: vec!["demo/*".to_string()],
denied_keys: vec![],
contract: None,
allow_reveal: false,
name: name.map(str::to_string),
scope: crate::install::Scope::Global,
dry_run: false,
uninstall,
audit_source: None,
}
}
#[test]
fn merge_preserves_other_entries() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("claude.json");
std::fs::write(
&path,
r#"{"mcpServers":{"other":{"command":"x","args":[]}}}"#,
)
.unwrap();
write_mcp_servers_entry(&path, "Claude Desktop", &opts(Some("testsrv"), false)).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let v: Value = serde_json::from_str(&raw).unwrap();
let servers = v["mcpServers"].as_object().unwrap();
assert!(servers.contains_key("other"), "other entry must survive");
assert!(servers.contains_key("testsrv"), "new entry must be present");
assert_eq!(servers["testsrv"]["command"], "tsafe-mcp");
}
#[test]
fn uninstall_removes_only_named_entry() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("claude.json");
std::fs::write(
&path,
r#"{"mcpServers":{"other":{"command":"x","args":[]},"testsrv":{"command":"old"}}}"#,
)
.unwrap();
write_mcp_servers_entry(&path, "Claude Desktop", &opts(Some("testsrv"), true)).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let v: Value = serde_json::from_str(&raw).unwrap();
let servers = v["mcpServers"].as_object().unwrap();
assert!(servers.contains_key("other"));
assert!(!servers.contains_key("testsrv"));
}
#[test]
fn malformed_config_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("bad.json");
std::fs::write(&path, "{not json}").unwrap();
let err =
write_mcp_servers_entry(&path, "Claude Desktop", &opts(Some("x"), false)).unwrap_err();
assert_eq!(err.kind, McpErrorKind::InstallConfigMalformed);
}
#[test]
fn creates_file_with_default_skeleton() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("claude.json");
write_mcp_servers_entry(&path, "Claude Desktop", &opts(Some("testsrv"), false)).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let v: Value = serde_json::from_str(&raw).unwrap();
assert!(v["mcpServers"]["testsrv"].is_object());
}
}