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_status` tool — returns ADR-029 v1 shape plus mcp-specific scope.
//!
//! Mirrors the probe logic from `tsafe-cli/src/cmd_agent.rs::cmd_agent_status_json`:
//! agent reachability via `ping_agent`, biometric via `keyring_store::has_password`.
//! All TTL/expiry fields are intentionally omitted per design §4.3 — they
//! belong to the agent-side schema, not the MCP server view.

use serde_json::{json, Value};
use tsafe_core::{agent, keyring_store};

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

pub fn call(session: &Session, _raw: Value) -> Value {
    let sock = agent::read_agent_sock_env().or_else(agent::read_agent_sock);
    let agent_running = sock
        .as_deref()
        .map(|s| matches!(agent::ping_agent(s), Ok(true)))
        .unwrap_or(false);
    let vault_locked = !agent_running;
    let biometric_enabled = keyring_store::has_password(&session.profile);

    let payload = json!({
        "version": "1",
        "agent_running": agent_running,
        "vault_locked": vault_locked,
        "active_profile": session.profile,
        "biometric_enabled": biometric_enabled,
        "scope": {
            "allowed_globs": session.allowed_globs,
            "denied_globs": session.denied_globs,
            "contract": session.contract.as_ref().map(|c| c.name.clone()),
        },
        "reveal_enabled": session.allow_reveal,
    });

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

    payload
}

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

    fn session(allow_reveal: bool, allowed: &[&str]) -> Session {
        Session {
            profile: "demo".to_string(),
            allowed_globs: allowed.iter().map(|s| s.to_string()).collect(),
            denied_globs: vec!["demo/private".to_string()],
            contract: None,
            allow_reveal,
            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_vars(
            [
                (
                    "TSAFE_VAULT_DIR",
                    Some(vault_dir.to_string_lossy().to_string()),
                ),
                ("TSAFE_AGENT_SOCK", None),
            ],
            f,
        );
    }

    #[test]
    fn status_shape_matches_design_4_3() {
        isolated(|| {
            let resp = call(&session(true, &["demo/*", "shared/*"]), json!({}));
            assert_eq!(resp["version"], "1");
            assert_eq!(resp["active_profile"], "demo");
            assert_eq!(resp["agent_running"], false);
            assert_eq!(resp["vault_locked"], true);
            assert_eq!(resp["reveal_enabled"], true);
            assert_eq!(
                resp["scope"]["allowed_globs"],
                json!(["demo/*", "shared/*"])
            );
            assert_eq!(resp["scope"]["denied_globs"], json!(["demo/private"]));
            assert!(resp["scope"]["contract"].is_null());
            // Must NOT include TTL fields.
            assert!(resp.get("session_expires_at").is_none());
            assert!(resp.get("idle_ttl_remaining_secs").is_none());
        });
    }

    #[test]
    fn reveal_disabled_session_reports_false() {
        isolated(|| {
            let resp = call(&session(false, &["demo/*"]), json!({}));
            assert_eq!(resp["reveal_enabled"], false);
        });
    }
}