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_audit_tail` tool โ€” return redacted tail of the profile's audit log.
//!
//! Surfaced fields per design ยง4.3: `{id, timestamp, operation, key, status,
//! source}`. The `source` field is derived from `entry.context.mcp.host`
//! when present; absent contexts yield `source: null`.

use serde_json::{json, Value};
use tsafe_core::audit::{AuditLog, AuditStatus};
use tsafe_core::profile;

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

const DEFAULT_LIMIT: usize = 50;
const MIN_LIMIT: usize = 1;
const MAX_LIMIT: usize = 500;

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

    let log_path = profile::audit_log_path(&session.profile);
    if !log_path.exists() {
        audit_call(
            session,
            "tsafe_audit_tail",
            None,
            Vec::new(),
            None,
            None,
            CallStatus::Success,
            None,
        );
        return Ok(json!([]));
    }

    let log = AuditLog::new(&log_path);
    let entries = log.read(Some(limit)).map_err(|e| {
        McpError::new(
            McpErrorKind::InternalError,
            format!("audit log read failed: {e}"),
        )
    })?;

    let redacted: Vec<Value> = entries
        .iter()
        .map(|e| {
            let source = e
                .context
                .as_ref()
                .and_then(|c| c.mcp.as_ref().map(|m| m.host.clone()));
            let status = match e.status {
                AuditStatus::Success => "success",
                AuditStatus::Failure => "failure",
            };
            json!({
                "id": e.id,
                "timestamp": e.timestamp,
                "operation": e.operation,
                "key": e.key,
                "status": status,
                "source": source,
            })
        })
        .collect();

    audit_call(
        session,
        "tsafe_audit_tail",
        None,
        Vec::new(),
        None,
        None,
        CallStatus::Success,
        None,
    );

    Ok(json!(redacted))
}

fn parse_limit(raw: &Value) -> Result<usize, McpError> {
    let obj = match raw.as_object() {
        Some(o) => o,
        None if raw.is_null() => return Ok(DEFAULT_LIMIT),
        None => {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                "expected an object",
            ))
        }
    };
    for k in obj.keys() {
        if k != "limit" {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                format!("unknown field '{k}'"),
            ));
        }
    }
    let limit = match obj.get("limit") {
        Some(v) => {
            let n = v.as_u64().ok_or_else(|| {
                McpError::new(
                    McpErrorKind::InvalidParams,
                    "'limit' must be a positive integer",
                )
            })? as usize;
            if !(MIN_LIMIT..=MAX_LIMIT).contains(&n) {
                return Err(McpError::new(
                    McpErrorKind::InvalidParams,
                    format!("'limit' must be in {MIN_LIMIT}..={MAX_LIMIT}"),
                ));
            }
            n
        }
        None => DEFAULT_LIMIT,
    };
    Ok(limit)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::path::PathBuf;
    use tsafe_core::audit::{AuditContext, AuditEntry, AuditMcpContext};

    fn session(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:1".to_string(),
            pid: 1,
            require_agent: false,
            vault_path: PathBuf::from("nonexistent"),
        }
    }

    fn isolated<F: FnOnce()>(f: F) {
        let tmp = tempfile::tempdir().unwrap();
        // Point TSAFE_VAULT_DIR at a *child* directory inside the tempdir so
        // `parent().join("state")` (audit/state root) lands inside the tempdir
        // and not at sibling-of-tempdir. Otherwise repeated runs accumulate
        // audit rows in <SystemTemp>/state/audit/<profile>.audit.jsonl.
        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_limit_validates_range() {
        assert!(parse_limit(&json!({"limit": 0})).is_err());
        assert!(parse_limit(&json!({"limit": 9999})).is_err());
        assert_eq!(parse_limit(&json!({"limit": 25})).unwrap(), 25);
        assert_eq!(parse_limit(&json!({})).unwrap(), DEFAULT_LIMIT);
    }

    #[test]
    fn missing_log_returns_empty_array() {
        isolated(|| {
            let resp = call(&session("missing_profile"), json!({})).unwrap();
            assert!(resp.is_array());
            assert!(resp.as_array().unwrap().is_empty());
        });
    }

    #[test]
    fn redaction_surfaces_only_six_fields() {
        isolated(|| {
            // Seed three entries for "audit_tail_test".
            let profile = "audit_tail_test";
            let log_path = profile::audit_log_path(profile);
            let log = AuditLog::new(&log_path);
            for i in 0..3 {
                let entry = AuditEntry::success(profile, "mcp.list_keys", None).with_context(
                    AuditContext::from_mcp(AuditMcpContext {
                        host: format!("mcp:test:{i}"),
                        pid: i,
                        tool: "tsafe_list_keys".to_string(),
                        injected_keys: vec![],
                        exit_code: None,
                        duration_ms: None,
                    }),
                );
                log.append(&entry).unwrap();
            }

            let resp = call(&session(profile), json!({"limit": 2})).unwrap();
            let arr = resp.as_array().unwrap();
            assert_eq!(arr.len(), 2);
            for r in arr {
                let obj = r.as_object().unwrap();
                let keys: Vec<&String> = obj.keys().collect();
                assert_eq!(keys.len(), 6, "expected exactly 6 fields, got {keys:?}");
                assert!(obj.contains_key("id"));
                assert!(obj.contains_key("timestamp"));
                assert!(obj.contains_key("operation"));
                assert!(obj.contains_key("key"));
                assert!(obj.contains_key("status"));
                assert!(obj.contains_key("source"));
                assert!(obj["source"].as_str().unwrap().starts_with("mcp:test:"));
            }
        });
    }
}