tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! `tsafe-mcp install <host>` dispatch — routes to per-host writer modules.
//!
//! Per design §5.3 / §5.4, the install path:
//! - Refuses when scope is blank (no `--allowed-keys` and no `--contract`).
//! - Refuses unknown hosts with `-32009 InstallTargetUnknown` whose message
//!   names the supported hosts verbatim.
//! - Validates the entry `name` against `^[a-zA-Z0-9_-]+$` BEFORE the host
//!   writer runs (TOML-safety constraint from design §7).
//! - Honors `--dry-run` by routing to each writer's preview path.

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

pub mod claude;
pub mod codex;
#[path = "continue_.rs"]
pub mod continue_;
pub mod cursor;
pub mod shared_json;
pub mod windsurf;

/// Where the host config should land.
#[derive(Debug, Clone)]
pub enum Scope {
    /// Per-user config in the platform home location.
    Global,
    /// Project-local config under a given directory (`.cursor/` etc).
    Project { dir: std::path::PathBuf },
}

/// Bundle of install/uninstall flags resolved from argv.
#[derive(Debug, Clone)]
pub struct InstallOpts {
    pub profile: String,
    pub allowed_keys: Vec<String>,
    pub denied_keys: Vec<String>,
    pub contract: Option<String>,
    pub allow_reveal: bool,
    /// When `None`, defaults to `tsafe-<profile>` per design §5.3.
    pub name: Option<String>,
    pub scope: Scope,
    pub dry_run: bool,
    pub uninstall: bool,
    pub audit_source: Option<String>,
}

impl InstallOpts {
    /// Resolved server entry name — `name.unwrap_or(format!("tsafe-{profile}"))`.
    pub fn entry_name(&self) -> String {
        self.name
            .clone()
            .unwrap_or_else(|| format!("tsafe-{}", self.profile))
    }

    /// `args` array for the host config — the literal command-line flags
    /// `tsafe-mcp` will receive at startup.
    pub fn server_args(&self) -> Vec<String> {
        let mut out = vec!["--profile".to_string(), self.profile.clone()];
        if !self.allowed_keys.is_empty() {
            out.push("--allowed-keys".to_string());
            out.push(self.allowed_keys.join(","));
        }
        if !self.denied_keys.is_empty() {
            out.push("--denied-keys".to_string());
            out.push(self.denied_keys.join(","));
        }
        if let Some(c) = &self.contract {
            out.push("--contract".to_string());
            out.push(c.clone());
        }
        if self.allow_reveal {
            out.push("--allow-reveal".to_string());
        }
        if let Some(src) = &self.audit_source {
            out.push("--audit-source".to_string());
            out.push(src.clone());
        }
        out
    }
}

/// Top-level install dispatch. Validates scope + name then forwards to the
/// matching host module.
pub fn dispatch(host: &str, opts: &InstallOpts) -> Result<(), McpError> {
    // Validate scope unless this is an uninstall (uninstall can run with no
    // scope flags).
    if !opts.uninstall && opts.allowed_keys.is_empty() && opts.contract.is_none() {
        return Err(McpError::new(
            McpErrorKind::InvalidRequest,
            "no scope: --allowed-keys or --contract required (thin-stance: blank scope refused)",
        ));
    }

    // Validate entry name against the TOML-safe regex (design §7).
    let name = opts.entry_name();
    if !name_is_toml_safe(&name) {
        return Err(McpError::new(
            McpErrorKind::InvalidRequest,
            format!("entry name '{name}' must match [a-zA-Z0-9_-]+ for TOML compatibility"),
        ));
    }

    match host.to_ascii_lowercase().as_str() {
        "claude" | "claude-desktop" => claude::write(opts),
        "cursor" => cursor::write(opts),
        "continue" => continue_::write(opts),
        "windsurf" => windsurf::write(opts),
        "codex" => codex::write(opts),
        _ => Err(McpError::new(
            McpErrorKind::InstallTargetUnknown,
            format!("unknown host '{host}'. Supported: claude, cursor, continue, windsurf, codex"),
        )),
    }
}

fn name_is_toml_safe(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}

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

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

    #[test]
    fn unknown_host_returns_install_target_unknown() {
        let err = dispatch("notreal", &opts_minimal()).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::InstallTargetUnknown);
        assert!(err
            .message
            .contains("claude, cursor, continue, windsurf, codex"));
    }

    #[test]
    fn blank_scope_is_refused() {
        let mut opts = opts_minimal();
        opts.allowed_keys.clear();
        let err = dispatch("claude", &opts).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
        assert!(err.message.contains("scope"));
    }

    #[test]
    fn bad_name_rejected_before_dispatch() {
        let mut opts = opts_minimal();
        opts.name = Some("not.toml.safe".to_string());
        let err = dispatch("claude", &opts).unwrap_err();
        assert!(err.message.contains("must match"));
    }

    #[test]
    fn default_entry_name_uses_tsafe_profile() {
        let opts = opts_minimal();
        assert_eq!(opts.entry_name(), "tsafe-demo");
    }

    #[test]
    fn server_args_serializes_flags_in_order() {
        let mut opts = opts_minimal();
        opts.denied_keys = vec!["demo/secret".to_string()];
        opts.contract = Some("deploy".to_string());
        opts.allow_reveal = true;
        opts.audit_source = Some("mcp:claude:proof".to_string());
        let args = opts.server_args();
        assert_eq!(args[0], "--profile");
        assert_eq!(args[1], "demo");
        assert!(args.iter().any(|s| s == "--allowed-keys"));
        assert!(args.iter().any(|s| s == "--denied-keys"));
        assert!(args.iter().any(|s| s == "--contract"));
        assert!(args.iter().any(|s| s == "--allow-reveal"));
        assert!(args.iter().any(|s| s == "--audit-source"));
    }
}