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;
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 })
}
pub fn dispatch(session: &Session, name: &str, args: Value) -> Result<Value, McpError> {
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(());
};
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);
}
}