use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::{Map, Value};
const CLAUDE_JSON: &str = ".claude.json";
pub const BUILTIN_MANAGED_MCP_SERVERS: &[&str] =
&["trusty-memory", "trusty-review", "trusty-search"];
pub fn builtin_server_entry(name: &str) -> Option<Value> {
let (command, args): (&str, &[&str]) = match name {
"trusty-memory" => ("trusty-memory", &["serve", "--stdio"]),
"trusty-review" => ("trusty-review", &["serve", "--stdio"]),
"trusty-search" => ("trusty-search", &["serve"]),
_ => return None,
};
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
Some(build_stdio_entry(command, &args, &Map::new()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTransport {
Stdio,
Http,
Sse,
}
impl McpTransport {
pub fn as_str(self) -> &'static str {
match self {
McpTransport::Stdio => "stdio",
McpTransport::Http => "http",
McpTransport::Sse => "sse",
}
}
}
pub fn strip_arg_separator(args: &[String]) -> &[String] {
match args.first() {
Some(first) if first == "--" => &args[1..],
_ => args,
}
}
pub fn build_stdio_entry(command: &str, args: &[String], env: &Map<String, Value>) -> Value {
let mut obj = Map::new();
obj.insert("type".into(), Value::String("stdio".into()));
obj.insert("command".into(), Value::String(command.to_string()));
obj.insert(
"args".into(),
Value::Array(args.iter().map(|a| Value::String(a.clone())).collect()),
);
if !env.is_empty() {
obj.insert("env".into(), Value::Object(env.clone()));
}
Value::Object(obj)
}
pub fn build_remote_entry(
transport: McpTransport,
url: &str,
headers: &Map<String, Value>,
) -> Value {
let mut obj = Map::new();
obj.insert("type".into(), Value::String(transport.as_str().to_string()));
obj.insert("url".into(), Value::String(url.to_string()));
if !headers.is_empty() {
obj.insert("headers".into(), Value::Object(headers.clone()));
}
Value::Object(obj)
}
pub fn add_server(config_dir: &Path, name: &str, entry: Value) -> Result<bool> {
let (path, mut config) = read_config(config_dir)?;
let servers = mcp_servers_mut(&mut config);
if servers.get(name) == Some(&entry) {
return Ok(false);
}
servers.insert(name.to_string(), entry);
write(&path, &config)?;
Ok(true)
}
pub fn remove_server(config_dir: &Path, name: &str) -> Result<bool> {
let (path, mut config) = read_config(config_dir)?;
let servers = mcp_servers_mut(&mut config);
if servers.remove(name).is_none() {
return Ok(false);
}
write(&path, &config)?;
Ok(true)
}
pub fn get_server(config_dir: &Path, name: &str) -> Result<Option<Value>> {
let (_, config) = read_config(config_dir)?;
Ok(config
.get("mcpServers")
.and_then(Value::as_object)
.and_then(|m| m.get(name))
.cloned())
}
pub fn list_servers(config_dir: &Path) -> Result<Map<String, Value>> {
let (_, config) = read_config(config_dir)?;
Ok(config
.get("mcpServers")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default())
}
pub fn managed_mcp_server_names(config: &Value) -> Vec<String> {
let mut names: Vec<String> = BUILTIN_MANAGED_MCP_SERVERS
.iter()
.map(|s| s.to_string())
.collect();
if let Some(servers) = config.get("mcpServers").and_then(Value::as_object) {
names.extend(servers.keys().cloned());
}
names.sort();
names.dedup();
names
}
fn read_config(config_dir: &Path) -> Result<(PathBuf, Value)> {
let path = config_dir.join(CLAUDE_JSON);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok((path, Value::Object(Map::new())));
}
Err(e) => {
return Err(e).with_context(|| format!("failed to read {}", path.display()));
}
};
match serde_json::from_str::<Value>(&text) {
Ok(v) if v.is_object() => Ok((path, v)),
other => {
let corrupt = path.with_extension("json.corrupt");
let reason = match &other {
Ok(_) => "valid JSON but not an object".to_string(),
Err(err) => format!("not valid JSON ({err})"),
};
match std::fs::rename(&path, &corrupt) {
Ok(()) => tracing::warn!(
"{} is {reason}; quarantined to {} — proceeding with fresh config",
path.display(),
corrupt.display()
),
Err(rename_err) => tracing::warn!(
"{} is {reason}; quarantine rename failed ({rename_err}) — \
proceeding with fresh config",
path.display()
),
}
Ok((path, Value::Object(Map::new())))
}
}
}
fn mcp_servers_mut(config: &mut Value) -> &mut Map<String, Value> {
let root = config
.as_object_mut()
.expect("read_config always returns an object");
let servers = root
.entry("mcpServers")
.or_insert_with(|| Value::Object(Map::new()));
if !servers.is_object() {
*servers = Value::Object(Map::new());
}
servers
.as_object_mut()
.expect("mcpServers coerced to object above")
}
fn write(path: &Path, config: &Value) -> Result<()> {
trusty_common::claude_config::write_json_atomic(path, config)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn stdio(command: &str, args: &[&str]) -> Value {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
build_stdio_entry(command, &args, &Map::new())
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn strip_arg_separator_removes_only_leading() {
let args = strings(&["--", "-y", "@modelcontextprotocol/server-github"]);
assert_eq!(
strip_arg_separator(&args),
&["-y", "@modelcontextprotocol/server-github"]
);
let doubled = strings(&["--", "--", "-y"]);
assert_eq!(strip_arg_separator(&doubled), &["--", "-y"]);
}
#[test]
fn strip_arg_separator_preserves_deeper_and_absent() {
let deeper = strings(&["run", "--", "tool"]);
assert_eq!(strip_arg_separator(&deeper), &["run", "--", "tool"]);
let clean = strings(&["-y", "pkg"]);
assert_eq!(strip_arg_separator(&clean), &["-y", "pkg"]);
let empty: Vec<String> = Vec::new();
assert!(strip_arg_separator(&empty).is_empty());
}
#[test]
fn build_stdio_entry_has_expected_shape() {
let e = stdio("echo", &["hi"]);
assert_eq!(e["type"], "stdio");
assert_eq!(e["command"], "echo");
assert_eq!(e["args"], serde_json::json!(["hi"]));
assert!(e.get("env").is_none(), "env omitted when empty");
}
#[test]
fn build_stdio_entry_with_env() {
let mut env = Map::new();
env.insert("API_KEY".into(), Value::String("xxx".into()));
let e = build_stdio_entry("srv", &[], &env);
assert_eq!(e["env"]["API_KEY"], "xxx");
assert_eq!(e["args"], serde_json::json!([]), "args always present");
}
#[test]
fn build_remote_entry_http() {
let e = build_remote_entry(McpTransport::Http, "https://x/mcp", &Map::new());
assert_eq!(e["type"], "http");
assert_eq!(e["url"], "https://x/mcp");
assert!(e.get("headers").is_none());
let s = build_remote_entry(McpTransport::Sse, "https://x/sse", &Map::new());
assert_eq!(s["type"], "sse");
}
#[test]
fn build_remote_entry_with_headers() {
let mut h = Map::new();
h.insert("Authorization".into(), Value::String("Bearer t".into()));
let e = build_remote_entry(McpTransport::Http, "https://x", &h);
assert_eq!(e["headers"]["Authorization"], "Bearer t");
}
#[test]
fn add_then_get_roundtrips() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
assert!(add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap());
let got = get_server(&cfg, "echo").unwrap().expect("server present");
assert_eq!(got["command"], "echo");
let text = std::fs::read_to_string(cfg.join(CLAUDE_JSON)).unwrap();
let v: Value = serde_json::from_str(&text).unwrap();
assert!(v["mcpServers"]["echo"].is_object());
}
#[test]
fn add_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
assert!(add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap());
assert!(!add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap());
}
#[test]
fn add_preserves_other_keys() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(
cfg.join(CLAUDE_JSON),
r#"{"oauthAccount":{"keep":"me"},"mcpServers":{"pre":{"type":"stdio","command":"pre","args":[]}}}"#,
)
.unwrap();
add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap();
let v: Value =
serde_json::from_str(&std::fs::read_to_string(cfg.join(CLAUDE_JSON)).unwrap()).unwrap();
assert_eq!(v["oauthAccount"]["keep"], "me", "unrelated key preserved");
assert!(v["mcpServers"]["pre"].is_object(), "existing server kept");
assert!(v["mcpServers"]["echo"].is_object(), "new server added");
}
#[test]
fn remove_existing_returns_true() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap();
assert!(remove_server(&cfg, "echo").unwrap());
assert!(get_server(&cfg, "echo").unwrap().is_none());
}
#[test]
fn remove_absent_returns_false() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
assert!(!remove_server(&cfg, "nope").unwrap());
}
#[test]
fn get_absent_returns_none() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
assert!(get_server(&cfg, "nope").unwrap().is_none());
}
#[test]
fn list_returns_all_added() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
add_server(&cfg, "a", stdio("a", &[])).unwrap();
add_server(
&cfg,
"b",
build_remote_entry(McpTransport::Http, "https://b", &Map::new()),
)
.unwrap();
let all = list_servers(&cfg).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all["a"]["type"], "stdio");
assert_eq!(all["b"]["type"], "http");
}
#[test]
fn add_quarantines_malformed_json() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("cc");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(cfg.join(CLAUDE_JSON), b"{ not valid json !!!").unwrap();
add_server(&cfg, "echo", stdio("echo", &["hi"])).unwrap();
assert!(
cfg.join(".claude.json.corrupt").exists(),
"corrupt file quarantined, not deleted"
);
let v: Value =
serde_json::from_str(&std::fs::read_to_string(cfg.join(CLAUDE_JSON)).unwrap()).unwrap();
assert!(v["mcpServers"]["echo"].is_object());
}
#[test]
fn builtin_server_entry_matches_known_servers() {
for name in BUILTIN_MANAGED_MCP_SERVERS {
let e = builtin_server_entry(name).expect("builtin resolves");
assert_eq!(e["type"], "stdio");
assert_eq!(e["command"], *name, "command matches the binary name");
assert!(e["args"].is_array(), "args always present");
}
assert_eq!(
builtin_server_entry("trusty-memory").unwrap()["args"],
serde_json::json!(["serve", "--stdio"])
);
assert_eq!(
builtin_server_entry("trusty-search").unwrap()["args"],
serde_json::json!(["serve"])
);
assert!(builtin_server_entry("not-a-builtin").is_none());
}
#[test]
fn managed_mcp_server_names_defaults_to_builtin() {
let names = managed_mcp_server_names(&serde_json::json!({}));
assert_eq!(
names,
vec!["trusty-memory", "trusty-review", "trusty-search"]
);
}
#[test]
fn managed_mcp_server_names_unions_builtin_with_configured() {
let config = serde_json::json!({
"mcpServers": {
"aaa-custom": { "type": "stdio", "command": "x", "args": [] },
"trusty-memory": { "type": "stdio", "command": "trusty-memory", "args": [] }
}
});
let names = managed_mcp_server_names(&config);
assert_eq!(
names,
vec![
"aaa-custom",
"trusty-memory",
"trusty-review",
"trusty-search"
]
);
}
}