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_has_key` tool โ€” boolean presence check that honors scope.
//!
//! Per design ยง4.3: out-of-scope keys ALWAYS return `{present: false}`, even
//! when they exist in the vault. The model is told nothing about keys
//! outside the bound scope.

use serde_json::{json, Value};

use crate::audit::{audit_call, CallStatus};
use crate::backend;
use crate::errors::{McpError, McpErrorKind};
use crate::session::Session;

pub fn call(session: &Session, raw: Value) -> Result<Value, McpError> {
    let key = parse_args(&raw)?;

    // Out-of-scope short-circuit: do not even open the vault.
    if !session.is_in_scope(&key) {
        audit_call(
            session,
            "tsafe_has_key",
            Some(&key),
            Vec::new(),
            None,
            None,
            CallStatus::Success,
            None,
        );
        return Ok(json!({"present": false}));
    }

    let vault = match backend::open_vault(session) {
        Ok(v) => v,
        Err(e) => {
            audit_call(
                session,
                "tsafe_has_key",
                Some(&key),
                Vec::new(),
                None,
                None,
                CallStatus::Failure,
                Some(&e.message),
            );
            return Err(e);
        }
    };

    let present = backend::vault_has_key(&vault, &key);
    drop(vault);

    audit_call(
        session,
        "tsafe_has_key",
        Some(&key),
        Vec::new(),
        None,
        None,
        CallStatus::Success,
        None,
    );

    Ok(json!({"present": present}))
}

fn parse_args(raw: &Value) -> Result<String, McpError> {
    let obj = raw
        .as_object()
        .ok_or_else(|| McpError::new(McpErrorKind::InvalidParams, "expected an object"))?;
    for k in obj.keys() {
        if k != "key" {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                format!("unknown field '{k}'"),
            ));
        }
    }
    let key = obj
        .get("key")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .ok_or_else(|| McpError::new(McpErrorKind::InvalidParams, "missing 'key'"))?;
    if key.is_empty() {
        return Err(McpError::new(
            McpErrorKind::InvalidParams,
            "'key' must be non-empty",
        ));
    }
    Ok(key)
}

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

    fn session(allowed: &[&str]) -> Session {
        Session {
            profile: "demo".to_string(),
            allowed_globs: allowed.iter().map(|s| s.to_string()).collect(),
            denied_globs: vec![],
            contract: None,
            allow_reveal: false,
            audit_source: "mcp:test:1".to_string(),
            pid: 1,
            require_agent: false,
            vault_path: PathBuf::from("nonexistent"),
        }
    }

    fn isolated<F: FnOnce()>(f: F) {
        let tmp = tempfile::tempdir().unwrap();
        let vault_dir = tmp.path().join("vaults");
        std::fs::create_dir_all(&vault_dir).unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), f);
    }

    #[test]
    fn out_of_scope_key_returns_false_without_opening_vault() {
        isolated(|| {
            let s = session(&["in_scope_*"]);
            let resp = call(&s, json!({"key": "out_of_scope"})).unwrap();
            assert_eq!(resp["present"], false);
        });
    }

    #[test]
    fn parse_args_rejects_missing_key() {
        assert!(parse_args(&json!({})).is_err());
        assert!(parse_args(&json!({"key": ""})).is_err());
        assert!(parse_args(&json!({"other": "x"})).is_err());
    }
}