tsafe-mcp 0.1.0

First-party MCP server for tsafe โ€” exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Claude Desktop install writer โ€” JSON, `mcpServers` map.
//!
//! Config paths per design ยง5.3 row 1:
//! - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
//! - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
//! - Linux: `~/.config/Claude/claude_desktop_config.json`

use std::path::PathBuf;

use serde_json::{json, Value};

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, "Claude Desktop", opts)
}

/// Per-platform Claude Desktop config path.
pub fn config_path() -> Result<PathBuf, McpError> {
    let home = dirs_home()?;
    #[cfg(target_os = "macos")]
    let p = home
        .join("Library")
        .join("Application Support")
        .join("Claude")
        .join("claude_desktop_config.json");
    #[cfg(target_os = "windows")]
    let p = std::env::var_os("APPDATA")
        .map(PathBuf::from)
        .unwrap_or_else(|| home.join("AppData").join("Roaming"))
        .join("Claude")
        .join("claude_desktop_config.json");
    #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
    let p = home
        .join(".config")
        .join("Claude")
        .join("claude_desktop_config.json");
    Ok(p)
}

fn dirs_home() -> Result<PathBuf, McpError> {
    directories::BaseDirs::new()
        .map(|b| b.home_dir().to_path_buf())
        .ok_or_else(|| {
            McpError::new(
                McpErrorKind::InternalError,
                "could not resolve home directory",
            )
        })
}

/// Build the inner `mcpServers` entry object (`{command, args, env?}`).
pub(crate) fn build_entry(opts: &InstallOpts) -> Value {
    let mut entry = json!({
        "command": "tsafe-mcp",
        "args": opts.server_args(),
    });

    // Pass through TSAFE_AGENT_SOCK if it exists in the current environment
    // so the host inherits the same agent the operator just used. Most hosts
    // launch the MCP server in a clean env so this is the only way to keep
    // them on the same agent session.
    if let Ok(sock) = std::env::var("TSAFE_AGENT_SOCK") {
        if !sock.is_empty() {
            entry["env"] = json!({"TSAFE_AGENT_SOCK": sock});
        }
    }
    entry
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_entry_carries_command_and_args() {
        let opts = InstallOpts {
            profile: "demo".to_string(),
            allowed_keys: vec!["demo/*".to_string()],
            denied_keys: vec![],
            contract: None,
            allow_reveal: false,
            name: None,
            scope: crate::install::Scope::Global,
            dry_run: false,
            uninstall: false,
            audit_source: None,
        };
        let e = build_entry(&opts);
        assert_eq!(e["command"], "tsafe-mcp");
        let args = e["args"].as_array().unwrap();
        assert!(args.iter().any(|s| s == "--profile"));
    }
}