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_reveal` tool — gated value return with audit + biometric prompt.
//!
//! Pre-conditions enforced by `tools::dispatch`:
//! - `session.allow_reveal == true` (otherwise dispatch returns -32006 before
//!   reaching this module).
//!
//! Internal ordering per design §4.3:
//! 1. Audit the ATTEMPT first (so a panic or biometric denial still leaves a
//!    record).
//! 2. Biometric check via `keyring_store::has_password` is informational —
//!    when configured we treat keyring access as the implicit consent
//!    (the OS keychain may already gate retrieve_password with biometric;
//!    explicit re-prompt requires a tsafe-core API that does not yet exist
//!    for non-keychain platforms, so v1 documents this as "biometric is an
//!    extra layer when available, not a requirement").
//! 3. Scope check.
//! 4. Vault open + lookup.

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)?;

    // Step 1 — audit the ATTEMPT before any vault touch.
    audit_call(
        session,
        "tsafe_reveal",
        Some(&key),
        Vec::new(),
        None,
        None,
        CallStatus::Success,
        Some("reveal requested"),
    );

    // Step 3 — scope.
    if !session.is_in_scope(&key) {
        let err = McpError::new(
            McpErrorKind::KeyOutOfScope,
            format!("key '{key}' is outside the configured scope for this server"),
        )
        .with_data(json!({"key": key}));
        audit_call(
            session,
            "tsafe_reveal",
            Some(&key),
            Vec::new(),
            None,
            None,
            CallStatus::Failure,
            Some(&err.message),
        );
        return Err(err);
    }

    // Step 4 — vault open + lookup.
    let vault = match backend::open_vault(session) {
        Ok(v) => v,
        Err(e) => {
            audit_call(
                session,
                "tsafe_reveal",
                Some(&key),
                Vec::new(),
                None,
                None,
                CallStatus::Failure,
                Some(&e.message),
            );
            return Err(e);
        }
    };

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

    let plaintext = value.as_str().to_string();
    drop(vault);

    Ok(json!({"value": plaintext}))
}

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() -> Session {
        Session {
            profile: "demo".to_string(),
            allowed_globs: vec!["demo/*".to_string()],
            denied_globs: vec![],
            contract: None,
            allow_reveal: true,
            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 parse_args_rejects_unknown_or_missing() {
        assert!(parse_args(&json!({})).is_err());
        assert!(parse_args(&json!({"key": ""})).is_err());
        assert!(parse_args(&json!({"other": "x"})).is_err());
        assert_eq!(parse_args(&json!({"key": "DEMO_API"})).unwrap(), "DEMO_API");
    }

    #[test]
    fn out_of_scope_key_returns_key_out_of_scope_and_audits() {
        isolated(|| {
            let err = call(&session(), json!({"key": "other/forbidden"})).unwrap_err();
            assert_eq!(err.kind, McpErrorKind::KeyOutOfScope);

            // Audit log should include at least one mcp.reveal entry.
            let log_path = tsafe_core::profile::audit_log_path("demo");
            let log = tsafe_core::audit::AuditLog::new(&log_path);
            let entries = log.read(None).unwrap();
            assert!(entries.iter().any(
                |e| e.operation == "mcp.reveal" && e.key.as_deref() == Some("other/forbidden")

            ));
        });
    }
}