tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Shared `mcpServers`-shaped JSON writer used by Claude Desktop, Cursor, and
//! Windsurf installers.
//!
//! All three hosts use the same JSON structure:
//! ```json
//! {
//!   "mcpServers": {
//!     "<name>": {"command": "...", "args": [...], "env": {...}}
//!   }
//! }
//! ```
//! Merge semantics: existing entries are preserved; only the named entry is
//! added or replaced. Uninstall removes the named entry and leaves the rest
//! untouched. Writes are atomic (temp file + rename).

use std::path::Path;

use serde_json::{json, Value};

use crate::errors::{McpError, McpErrorKind};
use crate::install::InstallOpts;

/// Read-modify-write `mcpServers.<name>` in the target JSON file. The
/// `host_label` is purely for error messages ("invalid Claude Desktop config").
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());
    }
}