tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Vault unlock helper — calls `tsafe_core::agent::request_password_from_agent`
//! and opens the vault read-only for the bound profile.
//!
//! Per ADR-006's alignment row for ADR-003, this crate reuses the existing
//! agent IPC path (the same `OpenVault` request `tsafe exec` already uses).
//! It does NOT prompt for a password and does NOT add a new IPC variant.

use zeroize::Zeroizing;

use tsafe_core::agent::request_password_from_agent;
use tsafe_core::vault::Vault;

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

/// Resolve the vault password from a running `tsafe-agent` and open the vault
/// read-only. Fails closed if the agent is unreachable and `require_agent`
/// is set on the session (the default).
pub fn open_vault(session: &Session) -> Result<Vault, McpError> {
    let password = match request_password_from_agent(&session.profile) {
        Ok(Some(pw)) => pw,
        Ok(None) => {
            if session.require_agent {
                return Err(agent_not_running_error(&session.profile));
            }
            return Err(McpError::new(
                McpErrorKind::AgentNotRunning,
                "no agent available (TSAFE_MCP_REQUIRE_AGENT=0 set; pass --no-require-agent to confirm)",
            ));
        }
        Err(e) => {
            return Err(McpError::new(
                McpErrorKind::AgentNotRunning,
                format!("agent rpc failed: {e}"),
            ));
        }
    };

    // We hold the password only long enough to open the vault. The vault's
    // own zeroization handles the in-memory secret bytes after open.
    let password = Zeroizing::new(password);
    Vault::open_read_only(&session.vault_path, password.as_bytes()).map_err(|e| {
        McpError::new(
            McpErrorKind::InternalError,
            format!("vault open failed: {e}"),
        )
    })
}

/// Look up `key` in the open vault. Maps the tsafe-core not-found path to
/// the `-32004 KeyMissing` JSON-RPC error.
pub fn lookup_key(vault: &Vault, key: &str) -> Result<Zeroizing<String>, McpError> {
    vault.get(key).map_err(|e| {
        // Heuristic: tsafe-core surfaces missing keys via SafeError::KeyNotFound
        // (display string includes "not found"). Anything else is internal.
        let msg = format!("{e}");
        if msg.to_lowercase().contains("not found") {
            McpError::new(McpErrorKind::KeyMissing, format!("key '{key}'"))
        } else {
            McpError::new(
                McpErrorKind::InternalError,
                format!("lookup '{key}': {msg}"),
            )
        }
    })
}

/// `true` if the vault contains `key` (without surfacing the value or
/// distinguishing decryption errors from absence).
pub fn vault_has_key(vault: &Vault, key: &str) -> bool {
    vault.get(key).is_ok()
}

fn agent_not_running_error(profile: &str) -> McpError {
    McpError::new(
        McpErrorKind::AgentNotRunning,
        format!(
            "tsafe-agent not running. Run `tsafe agent unlock --profile {profile}` and reload the host."
        ),
    )
}

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

    fn session_without_agent() -> Session {
        Session {
            profile: "demo".to_string(),
            allowed_globs: vec!["demo/*".to_string()],
            denied_globs: vec![],
            contract: None,
            allow_reveal: false,
            audit_source: "mcp:test:1".to_string(),
            pid: 1,
            require_agent: true,
            vault_path: PathBuf::from("nonexistent.tsafe"),
        }
    }

    #[test]
    fn open_vault_without_agent_returns_agent_not_running() {
        // Redirect TSAFE_VAULT_DIR so read_agent_sock() looks in a fresh temp
        // directory with no agent.sock state file (the real data dir may have
        // a stale state file from a previous proof-harness run). Also clear
        // TSAFE_AGENT_SOCK and XDG_RUNTIME_DIR for belt-and-suspenders.
        let tmp = tempfile::tempdir().unwrap();
        let tmp_str = tmp.path().to_str().unwrap().to_string();
        temp_env::with_vars(
            [
                ("TSAFE_AGENT_SOCK", None::<&str>),
                ("XDG_RUNTIME_DIR", None::<&str>),
                ("TSAFE_VAULT_DIR", Some(tmp_str.as_str())),
            ],
            || {
                let s = session_without_agent();
                match open_vault(&s) {
                    Ok(_) => panic!("expected AgentNotRunning, got Ok"),
                    Err(e) => {
                        assert_eq!(e.kind, McpErrorKind::AgentNotRunning);
                        assert!(e.message.contains("tsafe-agent not running"));
                    }
                }
            },
        );
    }

    /// Happy path: seeded key resolves and the value matches.
    #[test]
    fn lookup_key_returns_value_for_present_key() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("demo.vault");
        let mut v = Vault::create(&path, b"pw").unwrap();
        v.set("demo/foo", "value-foo", Default::default()).unwrap();
        drop(v);

        let vault = Vault::open_read_only(&path, b"pw").unwrap();
        let value = lookup_key(&vault, "demo/foo").expect("present key resolves");
        assert_eq!(value.as_str(), "value-foo");
    }

    /// Boundary / error path: a missing key must surface as `-32004 KeyMissing`,
    /// not the internal-error fallthrough. This is the heuristic in
    /// `lookup_key` that splits on "not found" in the underlying error.
    #[test]
    fn lookup_key_missing_returns_key_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("demo.vault");
        let v = Vault::create(&path, b"pw").unwrap();
        drop(v);

        let vault = Vault::open_read_only(&path, b"pw").unwrap();
        let err = lookup_key(&vault, "does/not/exist").expect_err("missing must error");
        assert_eq!(err.kind, McpErrorKind::KeyMissing);
        assert!(err.message.to_lowercase().contains("does/not/exist"));
    }

    /// `vault_has_key` returns true for a present key and false for a missing
    /// one (callable without surfacing the value).
    #[test]
    fn vault_has_key_distinguishes_present_and_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("demo.vault");
        let mut v = Vault::create(&path, b"pw").unwrap();
        v.set("demo/here", "x", Default::default()).unwrap();
        drop(v);

        let vault = Vault::open_read_only(&path, b"pw").unwrap();
        assert!(vault_has_key(&vault, "demo/here"));
        assert!(!vault_has_key(&vault, "demo/missing"));
    }
}