use anyhow::{Context, Result}; use serde_json::{Map, Value}; use std::{ fs, path::{Path, PathBuf}, }; pub trait ClientAdapter { fn client_name(&self) -> &'static str; fn config_path(&self) -> PathBuf; } pub struct CursorAdapter; impl ClientAdapter for CursorAdapter { fn client_name(&self) -> &'static str { "cursor" } fn config_path(&self) -> PathBuf { default_home().join(".cursor").join("mcp.json") } } pub struct ClaudeCodeAdapter; impl ClientAdapter for ClaudeCodeAdapter { fn client_name(&self) -> &'static str { "claude-code" } fn config_path(&self) -> PathBuf { default_home().join(".claude").join("mcp.json") } } fn default_home() -> PathBuf { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result { let path = adapter.config_path(); let root = read_config_or_default(&path)?; Ok(root .get("mcpServers") .and_then(Value::as_object) .is_some_and(|servers| servers.contains_key(server_name))) } pub fn upsert_managed_server( adapter: &dyn ClientAdapter, server_name: &str, server_config: Value, ) -> Result<()> { let path = adapter.config_path(); let mut root = read_config_or_default(&path)?; let root_object = ensure_object(&mut root); let mcp_servers = root_object .entry("mcpServers".to_string()) .or_insert_with(|| Value::Object(Map::new())); let servers_object = ensure_object(mcp_servers); servers_object.insert(server_name.to_string(), server_config); write_config_atomically(&path, &root) } fn read_config_or_default(path: &Path) -> Result { if !path.exists() { return Ok(Value::Object(Map::new())); } let raw = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) } fn write_config_atomically(path: &Path, value: &Value) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let tmp_path = path.with_extension("json.tmp"); let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?; fs::write(&tmp_path, body) .with_context(|| format!("failed to write {}", tmp_path.display()))?; fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?; Ok(()) } fn ensure_object(value: &mut Value) -> &mut Map { if !value.is_object() { *value = Value::Object(Map::new()); } value.as_object_mut().expect("object just ensured") } #[cfg(test)] mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; struct TestAdapter { path: PathBuf, } impl ClientAdapter for TestAdapter { fn client_name(&self) -> &'static str { "test" } fn config_path(&self) -> PathBuf { self.path.clone() } } #[test] fn upsert_preserves_other_servers() { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") .as_nanos(); let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}")); let adapter = TestAdapter { path: base.join("mcp.json"), }; fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir"); fs::write( &adapter.path, r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#, ) .expect("seed config"); upsert_managed_server( &adapter, "secrets", serde_json::json!({ "url": "http://127.0.0.1:9515/mcp" }), ) .expect("upsert config"); let root: Value = serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back")) .expect("parse back"); let servers = root .get("mcpServers") .and_then(Value::as_object) .expect("mcpServers object"); assert!(servers.contains_key("postgres")); assert_eq!( servers .get("secrets") .and_then(Value::as_object) .and_then(|value| value.get("url")) .and_then(Value::as_str), Some("http://127.0.0.1:9515/mcp") ); let _ = fs::remove_dir_all(base); } }