tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! Tool dispatch table and `tools/list` catalog construction.
//!
//! Per ADR-006 + design §4.3, the default surface is six action-shaped tools
//! that NEVER return raw secret values:
//! - `tsafe_run`
//! - `tsafe_list_keys`
//! - `tsafe_search_keys`
//! - `tsafe_has_key`
//! - `tsafe_audit_tail`
//! - `tsafe_status`
//!
//! `tsafe_reveal` is the opt-in 7th tool, registered only when
//! `--allow-reveal` is set on the session.
//!
//! Tool schemas in [`list_tools`] match design §4.3 verbatim — reviewers diff
//! against the design doc.

use serde_json::{json, Value};

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

pub mod audit_tail;
pub mod has_key;
pub mod list_keys;
pub mod reveal;
pub mod run;
pub mod search_keys;
pub mod status;

/// Build the MCP `tools/list` response payload for the bound session.
pub fn list_tools(session: &Session) -> Value {
    let mut tools: Vec<Value> = vec![
        json!({
            "name": "tsafe_run",
            "description": "Execute a command with explicitly-allowed vault keys injected as environment variables. Returns stdout/stderr/exit_code/duration_ms and the names of keys injected — never the secret values.",
            "inputSchema": {
                "type": "object",
                "required": ["command", "allowed_keys"],
                "properties": {
                    "command": {"type": "string"},
                    "args": {"type": "array", "items": {"type": "string"}, "default": []},
                    "allowed_keys": {"type": "array", "items": {"type": "string"}, "minItems": 1},
                    "cwd": {"type": "string"},
                    "timeout_secs": {"type": "integer", "minimum": 1, "maximum": 600, "default": 60}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "tsafe_list_keys",
            "description": "List vault key names visible to this server, filtered by scope. Optionally narrow by namespace prefix. Values are never returned.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "namespace": {"type": "string"}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "tsafe_search_keys",
            "description": "Case-insensitive substring search across scope-filtered vault key names. Returns key names only.",
            "inputSchema": {
                "type": "object",
                "required": ["query"],
                "properties": {
                    "query": {"type": "string", "minLength": 1},
                    "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "tsafe_has_key",
            "description": "Check whether a vault key exists within this server's scope. Out-of-scope keys always return present=false regardless of vault contents.",
            "inputSchema": {
                "type": "object",
                "required": ["key"],
                "properties": {"key": {"type": "string"}},
                "additionalProperties": false
            }
        }),
        json!({
            "name": "tsafe_audit_tail",
            "description": "Return the most recent audit entries for the bound profile. Values are redacted; only id, timestamp, operation, key, status, and source are surfaced.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "tsafe_status",
            "description": "Return agent/vault/profile status plus this server's configured scope. Matches ADR-029 schema version 1.",
            "inputSchema": {
                "type": "object",
                "properties": {},
                "additionalProperties": false
            }
        }),
    ];

    if session.allow_reveal {
        tools.push(json!({
            "name": "tsafe_reveal",
            "description": "Return the plaintext value of a single in-scope vault key. Gated by --allow-reveal; audited; biometric re-prompt when configured. The explicit escape hatch — every call appears in the profile audit log.",
            "inputSchema": {
                "type": "object",
                "required": ["key"],
                "properties": {"key": {"type": "string"}},
                "additionalProperties": false
            }
        }));
    }

    json!({ "tools": tools })
}

/// Dispatch a `tools/call` request to the matching tool module.
pub fn dispatch(session: &Session, name: &str, args: Value) -> Result<Value, McpError> {
    // Reject request-time profile/scope widening attempts per the thin-stance
    // "one profile per server" doctrine row in ADR-006.
    reject_scope_widening(&args)?;

    match name {
        "tsafe_run" => run::call(session, args),
        "tsafe_list_keys" => list_keys::call(session, args),
        "tsafe_search_keys" => search_keys::call(session, args),
        "tsafe_has_key" => has_key::call(session, args),
        "tsafe_audit_tail" => audit_tail::call(session, args),
        "tsafe_status" => Ok(status::call(session, args)),
        "tsafe_reveal" => {
            if !session.allow_reveal {
                return Err(McpError::kind_only(McpErrorKind::RevealDisabled));
            }
            reveal::call(session, args)
        }
        other => Err(McpError::new(
            McpErrorKind::MethodNotFound,
            format!("unknown tool '{other}'"),
        )),
    }
}

fn reject_scope_widening(args: &Value) -> Result<(), McpError> {
    let Some(obj) = args.as_object() else {
        return Ok(());
    };
    // Forbidden top-level argument keys that would attempt to alter scope
    // per design §6.2 + ADR-006.
    for forbidden in ["profile", "contract", "allowed_globs", "denied_globs"] {
        if obj.contains_key(forbidden) {
            return Err(McpError::new(
                McpErrorKind::ScopeWidening,
                format!("request includes forbidden field '{forbidden}'"),
            ));
        }
    }
    Ok(())
}

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

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

    #[test]
    fn list_tools_default_has_six_tools() {
        let payload = list_tools(&session(false));
        let tools = payload["tools"].as_array().unwrap();
        assert_eq!(tools.len(), 6, "expected 6 default tools (no reveal)");
        let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
        for expected in [
            "tsafe_run",
            "tsafe_list_keys",
            "tsafe_search_keys",
            "tsafe_has_key",
            "tsafe_audit_tail",
            "tsafe_status",
        ] {
            assert!(names.contains(&expected), "missing tool {expected}");
        }
        assert!(!names.contains(&"tsafe_reveal"));
    }

    #[test]
    fn list_tools_with_reveal_has_seven_tools() {
        let payload = list_tools(&session(true));
        let tools = payload["tools"].as_array().unwrap();
        assert_eq!(tools.len(), 7, "expected 7 tools when --allow-reveal");
        let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
        assert!(names.contains(&"tsafe_reveal"));
    }

    #[test]
    fn dispatch_unknown_tool_returns_method_not_found() {
        let err = dispatch(&session(false), "tsafe_bogus", json!({})).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::MethodNotFound);
    }

    #[test]
    fn dispatch_reveal_without_allow_reveal_returns_reveal_disabled() {
        let err = dispatch(&session(false), "tsafe_reveal", json!({"key": "x"})).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::RevealDisabled);
    }

    #[test]
    fn scope_widening_args_are_rejected() {
        let err = dispatch(
            &session(false),
            "tsafe_list_keys",
            json!({"profile": "other"}),
        )
        .unwrap_err();
        assert_eq!(err.kind, McpErrorKind::ScopeWidening);

        let err = dispatch(
            &session(false),
            "tsafe_run",
            json!({"contract": "deploy", "command": "x", "allowed_keys": ["a"]}),
        )
        .unwrap_err();
        assert_eq!(err.kind, McpErrorKind::ScopeWidening);
    }

}