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_list_keys` tool — return scope-filtered key names.
//!
//! Optionally narrows by `namespace` using the dotted-namespace convention
//! consistent with `tsafe-cli/src/cmd_vault.rs cmd_list` — keys that begin
//! with `<namespace>.` (or `<namespace>/`) are kept; trailing separator-free
//! prefix matches are also accepted so callers can pass `demo` and match
//! `demo/foo`.

use serde_json::{json, Value};

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

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

    let vault = match backend::open_vault(session) {
        Ok(v) => v,
        Err(e) => {
            audit_call(
                session,
                "tsafe_list_keys",
                None,
                Vec::new(),
                None,
                None,
                CallStatus::Failure,
                Some(&e.message),
            );
            return Err(e);
        }
    };

    let mut keys: Vec<String> = vault
        .list()
        .into_iter()
        .filter(|k| session.is_in_scope(k))
        .filter(|k| namespace_matches(namespace.as_deref(), k))
        .map(|k| k.to_string())
        .collect();
    keys.sort();

    drop(vault);

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

    Ok(json!(keys))
}

fn parse_namespace(raw: &Value) -> Result<Option<String>, McpError> {
    let obj = match raw.as_object() {
        Some(o) => o,
        None if raw.is_null() => return Ok(None),
        None => {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                "expected an object",
            ))
        }
    };
    for k in obj.keys() {
        if k != "namespace" {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                format!("unknown field '{k}'"),
            ));
        }
    }
    obj.get("namespace")
        .map(|v| {
            v.as_str().map(str::to_string).ok_or_else(|| {
                McpError::new(McpErrorKind::InvalidParams, "'namespace' must be a string")
            })
        })
        .transpose()
}

fn namespace_matches(namespace: Option<&str>, key: &str) -> bool {
    match namespace {
        None => true,
        Some("") => true,
        Some(ns) => {
            // Accept either dotted or slashed separator. Also accept the key
            // exactly equal to the namespace (rare but harmless).
            key.starts_with(&format!("{ns}/")) || key.starts_with(&format!("{ns}.")) || key == ns
        }
    }
}

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

    #[test]
    fn parse_namespace_accepts_string_and_none() {
        assert!(parse_namespace(&json!({})).unwrap().is_none());
        assert_eq!(
            parse_namespace(&json!({"namespace": "demo"}))
                .unwrap()
                .as_deref(),
            Some("demo")
        );
    }

    #[test]
    fn parse_namespace_rejects_unknown_field() {
        let err = parse_namespace(&json!({"foo": "bar"})).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::InvalidParams);
    }

    #[test]
    fn namespace_matches_dotted_or_slashed() {
        assert!(namespace_matches(None, "anything"));
        assert!(namespace_matches(Some("demo"), "demo/foo"));
        assert!(namespace_matches(Some("demo"), "demo.bar"));
        assert!(!namespace_matches(Some("demo"), "other/foo"));
        assert!(namespace_matches(Some("demo"), "demo")); // exact match
    }
}