tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! mcp-flavored AuditEntry construction helpers built on `tsafe_core::audit`.
//!
//! Every tool call appends one row through this module. Audit is best-effort
//! per the codebase convention (see `tsafe-agent/src/lib.rs` for the
//! `.ok()`-after-append pattern); failures are logged via `tracing::warn!`
//! but never bubble up the call stack.

use tsafe_core::audit::{AuditContext, AuditEntry, AuditLog, AuditMcpContext, AuditStatus};
use tsafe_core::profile;

use crate::session::Session;

/// Status the caller wants attached to this audit row. Mirrors the
/// `tsafe-core::audit::AuditStatus` enum without re-exposing it directly.
#[derive(Debug, Clone, Copy)]
pub enum CallStatus {
    Success,
    Failure,
}

/// Append a single audit entry for one tool call.
///
/// `operation` is the bare tool name (e.g. `tsafe_run`); this function prefixes
/// it with `mcp.` so the resulting operation is `mcp.run`, `mcp.list_keys`,
/// etc per design §6.1. Failures to write the audit log are logged via
/// `tracing::warn!` and dropped (best-effort).
#[allow(clippy::too_many_arguments)]
pub fn audit_call(
    session: &Session,
    tool: &str,
    key: Option<&str>,
    injected_keys: Vec<String>,
    exit_code: Option<i32>,
    duration_ms: Option<u64>,
    status: CallStatus,
    message: Option<&str>,
) {
    let operation = mcp_operation(tool);

    let mut entry = match status {
        CallStatus::Success => AuditEntry::success(&session.profile, &operation, key),
        CallStatus::Failure => AuditEntry::failure(
            &session.profile,
            &operation,
            key,
            message.unwrap_or("(unspecified failure)"),
        ),
    };

    entry = entry.with_context(AuditContext::from_mcp(AuditMcpContext {
        host: session.audit_source.clone(),
        pid: session.pid,
        tool: tool.to_string(),
        injected_keys,
        exit_code,
        duration_ms,
    }));

    // Sanity: AuditStatus enforcement (we built it via success/failure helpers
    // so this is always consistent; the asserts document the invariant).
    debug_assert!(matches!(
        entry.status,
        AuditStatus::Success | AuditStatus::Failure
    ));

    let log = AuditLog::new(&profile::audit_log_path(&session.profile));
    if let Err(e) = log.append(&entry) {
        tracing::warn!(
            target: "tsafe_mcp::audit",
            error = %e,
            operation = %operation,
            "audit append failed (continuing)"
        );
    }
}

/// Tool name -> `mcp.<noun>` operation string per design §6.1.
fn mcp_operation(tool: &str) -> String {
    // Tools are already named `tsafe_run`, `tsafe_list_keys`, etc. Strip the
    // `tsafe_` prefix when present to produce the `mcp.<noun>` form. Anything
    // exotic falls back to `mcp.<tool>` as-is.
    if let Some(rest) = tool.strip_prefix("tsafe_") {
        format!("mcp.{rest}")
    } else {
        format!("mcp.{tool}")
    }
}

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

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

    #[test]
    fn mcp_operation_strips_tsafe_prefix() {
        assert_eq!(mcp_operation("tsafe_run"), "mcp.run");
        assert_eq!(mcp_operation("tsafe_list_keys"), "mcp.list_keys");
        assert_eq!(mcp_operation("tsafe_reveal"), "mcp.reveal");
        assert_eq!(mcp_operation("other"), "mcp.other");
    }

    #[test]
    fn audit_call_writes_jsonl_row_with_mcp_context() {
        let tmp = tempfile::tempdir().unwrap();
        // tsafe-core honors TSAFE_VAULT_DIR to relocate every profile path.
        // Point at a *child* directory inside the tempdir so the audit log
        // (derived from `parent().join("state")`) also lands inside the
        // tempdir and not at a sibling shared across test runs.
        let vault_dir = tmp.path().join("vaults");
        std::fs::create_dir_all(&vault_dir).unwrap();
        let profile_name = "mcp_audit_singleton_test";
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
            let session = session_with_profile(profile_name);
            let log_path = profile::audit_log_path(profile_name);
            // Pre-emptively clear any stale log left from a previous run
            // (e.g. CI environments where target/ caches across runs).
            let _ = std::fs::remove_file(&log_path);

            audit_call(
                &session,
                "tsafe_run",
                None,
                vec!["demo/foo".to_string()],
                Some(0),
                Some(15),
                CallStatus::Success,
                None,
            );

            let log = AuditLog::new(&log_path);
            let entries = log.read(None).unwrap();
            assert_eq!(entries.len(), 1, "expected exactly one audit entry");
            let e = &entries[0];
            assert_eq!(e.operation, "mcp.run");
            assert!(matches!(e.status, AuditStatus::Success));
            let ctx = e.context.as_ref().unwrap();
            let mcp = ctx.mcp.as_ref().unwrap();
            assert_eq!(mcp.tool, "tsafe_run");
            assert_eq!(mcp.host, "mcp:test:9999");
            assert_eq!(mcp.injected_keys, vec!["demo/foo".to_string()]);
            assert_eq!(mcp.exit_code, Some(0));
            assert_eq!(mcp.duration_ms, Some(15));
        });
    }

    /// Failure variant: when `CallStatus::Failure` is passed, the audit entry
    /// must record status=Failure with the supplied message. This is the path
    /// used by every tool when scope/vault/timeout checks reject the request.
    #[test]
    fn audit_call_records_failure_status_with_message() {
        let tmp = tempfile::tempdir().unwrap();
        let vault_dir = tmp.path().join("vaults");
        std::fs::create_dir_all(&vault_dir).unwrap();
        let profile_name = "mcp_audit_failure_test";
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_dir.as_os_str()), || {
            let session = session_with_profile(profile_name);
            let log_path = profile::audit_log_path(profile_name);
            let _ = std::fs::remove_file(&log_path);

            audit_call(
                &session,
                "tsafe_reveal",
                Some("demo/forbidden"),
                Vec::new(),
                None,
                None,
                CallStatus::Failure,
                Some("key 'demo/forbidden' is outside the configured scope"),
            );

            let log = AuditLog::new(&log_path);
            let entries = log.read(None).unwrap();
            assert_eq!(entries.len(), 1);
            let e = &entries[0];
            assert_eq!(e.operation, "mcp.reveal");
            assert!(
                matches!(e.status, AuditStatus::Failure),
                "expected Failure status, got {:?}",
                e.status
            );
            assert_eq!(e.key.as_deref(), Some("demo/forbidden"));
            let mcp = e.context.as_ref().unwrap().mcp.as_ref().unwrap();
            assert_eq!(mcp.tool, "tsafe_reveal");
        });
    }
}