tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Windsurf install writer — JSON, `mcpServers` shape.
//!
//! Per design §5.3 row 4:
//! - macOS/Linux: `~/.codeium/windsurf/mcp_config.json`
//! - Windows: `%USERPROFILE%\.codeium\windsurf\mcp_config.json`

use std::path::PathBuf;

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

pub fn write(opts: &InstallOpts) -> Result<(), McpError> {
    let path = config_path()?;
    super::shared_json::write_mcp_servers_entry(&path, "Windsurf", opts)
}

pub fn config_path() -> Result<PathBuf, McpError> {
    let home = directories::BaseDirs::new()
        .map(|b| b.home_dir().to_path_buf())
        .ok_or_else(|| {
            McpError::new(
                McpErrorKind::InternalError,
                "could not resolve home directory",
            )
        })?;
    Ok(home
        .join(".codeium")
        .join("windsurf")
        .join("mcp_config.json"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::install::shared_json::write_mcp_servers_entry;
    use crate::install::{InstallOpts, Scope};
    use serde_json::Value;

    fn opts_minimal(name: &str, uninstall: bool) -> InstallOpts {
        InstallOpts {
            profile: "demo".to_string(),
            allowed_keys: vec!["demo/*".to_string()],
            denied_keys: vec![],
            contract: None,
            allow_reveal: false,
            name: Some(name.to_string()),
            scope: Scope::Global,
            dry_run: false,
            uninstall,
            audit_source: None,
        }
    }

    /// `config_path` resolves to a path that ends with the documented
    /// `.codeium/windsurf/mcp_config.json` suffix on every supported host.
    /// `directories::BaseDirs` reads the real OS profile dir, not env vars,
    /// so we only assert the suffix shape — not the prefix.
    #[test]
    fn config_path_ends_with_codeium_windsurf_mcp_config_json() {
        let path = config_path().expect("home dir resolved");
        let s = path.to_string_lossy().replace('\\', "/");
        assert!(
            s.ends_with(".codeium/windsurf/mcp_config.json"),
            "path should end with .codeium/windsurf/mcp_config.json: {s}"
        );
    }

    /// Happy path: writing into a fresh tempdir via the shared JSON writer
    /// (the same code path `windsurf::write` uses) produces the expected
    /// `mcpServers.<name>` entry. We invoke the shared writer with a path
    /// inside a tempdir to avoid `directories::BaseDirs` resolving the
    /// operator's actual home dir on Windows.
    #[test]
    fn shared_json_writer_creates_windsurf_shaped_mcp_config() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp
            .path()
            .join(".codeium")
            .join("windsurf")
            .join("mcp_config.json");
        write_mcp_servers_entry(&path, "Windsurf", &opts_minimal("testsrv", false)).unwrap();
        assert!(path.exists(), "expected windsurf config at {path:?}");
        let raw = std::fs::read_to_string(&path).unwrap();
        let v: Value = serde_json::from_str(&raw).unwrap();
        assert_eq!(v["mcpServers"]["testsrv"]["command"], "tsafe-mcp");
        let args = v["mcpServers"]["testsrv"]["args"].as_array().unwrap();
        assert!(args.iter().any(|s| s == "--profile"));
    }

    /// Boundary: writing a new entry into an existing file must preserve any
    /// `mcpServers.<other>` entries already present (merge semantics, not
    /// replace).
    #[test]
    fn shared_json_writer_preserves_other_existing_entries() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg_dir = tmp.path().join(".codeium").join("windsurf");
        std::fs::create_dir_all(&cfg_dir).unwrap();
        let cfg_path = cfg_dir.join("mcp_config.json");
        std::fs::write(
            &cfg_path,
            r#"{"mcpServers":{"other":{"command":"x","args":[]}}}"#,
        )
        .unwrap();

        write_mcp_servers_entry(&cfg_path, "Windsurf", &opts_minimal("testsrv", false)).unwrap();
        let raw = std::fs::read_to_string(&cfg_path).unwrap();
        let v: Value = serde_json::from_str(&raw).unwrap();
        let servers = v["mcpServers"].as_object().unwrap();
        assert!(
            servers.contains_key("other"),
            "existing 'other' must survive"
        );
        assert!(servers.contains_key("testsrv"), "new entry must be present");
    }

    /// Error path: a malformed JSON config must surface
    /// `InstallConfigMalformed` (-32010) per design §5.4. This is the
    /// "don't silently clobber the operator's edits" guarantee.
    #[test]
    fn shared_json_writer_rejects_malformed_config_with_install_config_malformed() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg_dir = tmp.path().join(".codeium").join("windsurf");
        std::fs::create_dir_all(&cfg_dir).unwrap();
        let cfg_path = cfg_dir.join("mcp_config.json");
        std::fs::write(&cfg_path, "{not valid json").unwrap();

        let err = write_mcp_servers_entry(&cfg_path, "Windsurf", &opts_minimal("testsrv", false))
            .expect_err("should reject malformed config");
        assert_eq!(err.kind, McpErrorKind::InstallConfigMalformed);
    }

    /// Uninstall: the named entry is removed while other entries remain
    /// untouched. This is the on-disk shape `tsafe-mcp uninstall windsurf`
    /// produces when the host file is shared with other servers.
    #[test]
    fn shared_json_writer_uninstall_removes_only_named_entry() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg_dir = tmp.path().join(".codeium").join("windsurf");
        std::fs::create_dir_all(&cfg_dir).unwrap();
        let cfg_path = cfg_dir.join("mcp_config.json");
        std::fs::write(
            &cfg_path,
            r#"{"mcpServers":{"other":{"command":"x"},"testsrv":{"command":"old"}}}"#,
        )
        .unwrap();

        write_mcp_servers_entry(&cfg_path, "Windsurf", &opts_minimal("testsrv", true)).unwrap();
        let raw = std::fs::read_to_string(&cfg_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 uninstall"
        );
        assert!(!servers.contains_key("testsrv"), "testsrv must be gone");
    }
}