tsafe-mcp 0.1.0

First-party MCP server for tsafe โ€” exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Continue install writer โ€” YAML per-server file under `.continue/mcpServers/`.
//!
//! Per design ยง5.3 row 3, Continue's convention is one YAML file per server
//! at `<repo>/.continue/mcpServers/<name>.yaml` with body:
//! ```yaml
//! name: <name>
//! command: tsafe-mcp
//! args: [...]
//! env:
//!   KEY: VALUE
//! ```

use std::path::PathBuf;

use serde::Serialize;

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

#[derive(Debug, Serialize)]
struct ContinueServer {
    name: String,
    command: String,
    args: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    env: Option<std::collections::BTreeMap<String, String>>,
}

pub fn write(opts: &InstallOpts) -> Result<(), McpError> {
    let path = entry_path(opts)?;
    let name = opts.entry_name();

    if opts.uninstall {
        if path.exists() {
            std::fs::remove_file(&path).map_err(|e| {
                McpError::new(
                    McpErrorKind::InternalError,
                    format!("remove {}: {e}", path.display()),
                )
            })?;
        }
        return Ok(());
    }

    let server = ContinueServer {
        name: name.clone(),
        command: "tsafe-mcp".to_string(),
        args: opts.server_args(),
        env: env_map(),
    };

    let yaml = serde_yaml::to_string(&server).map_err(|e| {
        McpError::new(
            McpErrorKind::InternalError,
            format!("serialize Continue YAML: {e}"),
        )
    })?;

    if opts.dry_run {
        println!("# dry-run: would write {}", path.display());
        println!("{yaml}");
        return Ok(());
    }

    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 tmp = path.with_extension("yaml.tmp");
    std::fs::write(&tmp, &yaml).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(())
}

fn env_map() -> Option<std::collections::BTreeMap<String, String>> {
    let sock = std::env::var("TSAFE_AGENT_SOCK")
        .ok()
        .filter(|v| !v.is_empty())?;
    let mut m = std::collections::BTreeMap::new();
    m.insert("TSAFE_AGENT_SOCK".to_string(), sock);
    Some(m)
}

pub fn entry_path(opts: &InstallOpts) -> Result<PathBuf, McpError> {
    let base = match &opts.scope {
        Scope::Project { dir } => dir.clone(),
        Scope::Global => directories::BaseDirs::new()
            .map(|b| b.home_dir().to_path_buf())
            .ok_or_else(|| {
                McpError::new(
                    McpErrorKind::InternalError,
                    "could not resolve home directory",
                )
            })?,
    };
    Ok(base
        .join(".continue")
        .join("mcpServers")
        .join(format!("{}.yaml", opts.entry_name())))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::install::Scope;
    use serde::Deserialize;

    #[derive(Debug, Deserialize)]
    #[allow(dead_code)]
    struct ContinueFile {
        name: String,
        command: String,
        args: Vec<String>,
    }

    #[test]
    fn project_scope_writes_one_yaml_per_server() {
        let tmp = tempfile::tempdir().unwrap();
        let opts = InstallOpts {
            profile: "demo".to_string(),
            allowed_keys: vec!["demo/*".to_string()],
            denied_keys: vec![],
            contract: None,
            allow_reveal: false,
            name: Some("testsrv".to_string()),
            scope: Scope::Project {
                dir: tmp.path().to_path_buf(),
            },
            dry_run: false,
            uninstall: false,
            audit_source: None,
        };
        write(&opts).unwrap();
        let path = tmp
            .path()
            .join(".continue")
            .join("mcpServers")
            .join("testsrv.yaml");
        assert!(path.exists());
        let yaml = std::fs::read_to_string(&path).unwrap();
        let parsed: ContinueFile = serde_yaml::from_str(&yaml).unwrap();
        assert_eq!(parsed.name, "testsrv");
        assert_eq!(parsed.command, "tsafe-mcp");
        assert!(parsed.args.iter().any(|s| s == "--profile"));
    }

    #[test]
    fn uninstall_removes_existing_file() {
        let tmp = tempfile::tempdir().unwrap();
        let opts = InstallOpts {
            profile: "demo".to_string(),
            allowed_keys: vec!["demo/*".to_string()],
            denied_keys: vec![],
            contract: None,
            allow_reveal: false,
            name: Some("byebye".to_string()),
            scope: Scope::Project {
                dir: tmp.path().to_path_buf(),
            },
            dry_run: false,
            uninstall: false,
            audit_source: None,
        };
        write(&opts).unwrap();
        let path = entry_path(&opts).unwrap();
        assert!(path.exists());

        let uopts = InstallOpts {
            uninstall: true,
            ..opts
        };
        write(&uopts).unwrap();
        assert!(!path.exists());
    }
}