tsafe-mcp 0.1.0

First-party MCP server for tsafe โ€” exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Codex install writer โ€” TOML `[mcp_servers.<name>]`.
//!
//! Per design ยง5.3 row 5:
//! - Global: `~/.codex/config.toml`
//! - Project (trusted): `.codex/config.toml`
//!
//! TOML shape:
//! ```toml
//! [mcp_servers.testsrv]
//! command = "tsafe-mcp"
//! args = ["--profile", "demo", ...]
//! env = { TSAFE_AGENT_SOCK = "..." }
//! ```

use std::path::{Path, PathBuf};

use toml::Value;

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

pub fn write(opts: &InstallOpts) -> Result<(), McpError> {
    let path = config_path(&opts.scope)?;
    let name = opts.entry_name();
    debug_assert!(
        name.chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
        "name '{name}' should have been validated by dispatch"
    );

    let mut doc = load_or_create_toml(&path)?;

    // Walk to (or create) the [mcp_servers] table.
    let mcp_servers = ensure_mcp_servers_table(&mut doc)?;

    if opts.uninstall {
        if mcp_servers.remove(&name).is_some() {
            tracing::info!(target: "tsafe_mcp::install", "removed [mcp_servers.{name}] from {}", path.display());
        }
    } else {
        let mut entry = toml::value::Table::new();
        entry.insert(
            "command".to_string(),
            Value::String("tsafe-mcp".to_string()),
        );
        entry.insert(
            "args".to_string(),
            Value::Array(opts.server_args().into_iter().map(Value::String).collect()),
        );
        if let Ok(sock) = std::env::var("TSAFE_AGENT_SOCK") {
            if !sock.is_empty() {
                let mut env = toml::value::Table::new();
                env.insert("TSAFE_AGENT_SOCK".to_string(), Value::String(sock));
                entry.insert("env".to_string(), Value::Table(env));
            }
        }
        mcp_servers.insert(name, Value::Table(entry));
    }

    if opts.dry_run {
        let s = toml::to_string_pretty(&doc).map_err(|e| {
            McpError::new(
                McpErrorKind::InternalError,
                format!("serialize Codex TOML: {e}"),
            )
        })?;
        println!("# dry-run: would write {}", path.display());
        println!("{s}");
        return Ok(());
    }

    persist_atomically(&path, &doc)
}

pub fn config_path(scope: &Scope) -> Result<PathBuf, McpError> {
    match scope {
        Scope::Project { dir } => Ok(dir.join(".codex").join("config.toml")),
        Scope::Global => {
            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(".codex").join("config.toml"))
        }
    }
}

fn load_or_create_toml(path: &Path) -> Result<Value, McpError> {
    if !path.exists() {
        return Ok(Value::Table(toml::value::Table::new()));
    }
    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(Value::Table(toml::value::Table::new()));
    }
    toml::from_str::<Value>(&raw).map_err(|e| {
        McpError::new(
            McpErrorKind::InstallConfigMalformed,
            format!(
                "existing Codex config at {} is not valid TOML: {e}",
                path.display()
            ),
        )
    })
}

fn ensure_mcp_servers_table(doc: &mut Value) -> Result<&mut toml::value::Table, McpError> {
    let root = doc.as_table_mut().ok_or_else(|| {
        McpError::new(
            McpErrorKind::InstallConfigMalformed,
            "Codex config root is not a TOML table",
        )
    })?;

    if !matches!(root.get("mcp_servers"), Some(Value::Table(_))) {
        root.insert(
            "mcp_servers".to_string(),
            Value::Table(toml::value::Table::new()),
        );
    }
    let table = root.get_mut("mcp_servers").expect("just inserted");
    table.as_table_mut().ok_or_else(|| {
        McpError::new(
            McpErrorKind::InstallConfigMalformed,
            "[mcp_servers] exists but is not a TOML table",
        )
    })
}

fn persist_atomically(path: &Path, doc: &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 = toml::to_string_pretty(doc)
        .map_err(|e| McpError::new(McpErrorKind::InternalError, format!("serialize TOML: {e}")))?;
    let tmp = path.with_extension("toml.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::*;
    use crate::install::Scope;

    fn opts_for(tmp_path: &Path, 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::Project {
                dir: tmp_path.to_path_buf(),
            },
            dry_run: false,
            uninstall,
            audit_source: None,
        }
    }

    #[test]
    fn merge_with_existing_other_entry() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(".codex").join("config.toml");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(
            &path,
            r#"[mcp_servers.other]
command = "x"
args = []
"#,
        )
        .unwrap();

        write(&opts_for(tmp.path(), "testsrv", false)).unwrap();
        let raw = std::fs::read_to_string(&path).unwrap();
        let v: Value = toml::from_str(&raw).unwrap();
        let servers = v["mcp_servers"].as_table().unwrap();
        assert!(servers.contains_key("other"), "other must survive");
        assert!(servers.contains_key("testsrv"), "new entry present");
        assert_eq!(servers["testsrv"]["command"].as_str(), Some("tsafe-mcp"));
    }

    #[test]
    fn malformed_toml_is_rejected() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(".codex").join("config.toml");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, "this is not toml ===").unwrap();
        let err = write(&opts_for(tmp.path(), "testsrv", false)).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::InstallConfigMalformed);
    }

    #[test]
    fn uninstall_removes_only_named_entry() {
        let tmp = tempfile::tempdir().unwrap();
        write(&opts_for(tmp.path(), "alpha", false)).unwrap();
        write(&opts_for(tmp.path(), "beta", false)).unwrap();
        let path = tmp.path().join(".codex").join("config.toml");
        let v: Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert!(v["mcp_servers"]["alpha"].is_table());
        assert!(v["mcp_servers"]["beta"].is_table());

        write(&opts_for(tmp.path(), "alpha", true)).unwrap();
        let v: Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert!(v["mcp_servers"].get("alpha").is_none());
        assert!(v["mcp_servers"]["beta"].is_table());
    }
}