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_search_keys` tool — case-insensitive substring search over the
//! scope-filtered key list.

use serde_json::{json, Value};

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

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

#[derive(Debug)]
struct SearchArgs {
    query: String,
    limit: usize,
}

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

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

    let needle = args.query.to_lowercase();
    let mut matches: Vec<String> = vault
        .list()
        .into_iter()
        .filter(|k| session.is_in_scope(k))
        .filter(|k| k.to_lowercase().contains(&needle))
        .map(|k| k.to_string())
        .collect();
    matches.sort();
    matches.truncate(args.limit);

    drop(vault);

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

    Ok(json!(matches))
}

fn parse_args(raw: &Value) -> Result<SearchArgs, McpError> {
    let obj = raw
        .as_object()
        .ok_or_else(|| McpError::new(McpErrorKind::InvalidParams, "expected an object"))?;
    for k in obj.keys() {
        if !matches!(k.as_str(), "query" | "limit") {
            return Err(McpError::new(
                McpErrorKind::InvalidParams,
                format!("unknown field '{k}'"),
            ));
        }
    }
    let query = obj
        .get("query")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .ok_or_else(|| McpError::new(McpErrorKind::InvalidParams, "missing 'query'"))?;
    if query.is_empty() {
        return Err(McpError::new(
            McpErrorKind::InvalidParams,
            "'query' must have minLength 1",
        ));
    }
    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(SearchArgs { query, limit })
}

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

    #[test]
    fn parse_args_requires_query() {
        let err = parse_args(&json!({})).unwrap_err();
        assert_eq!(err.kind, McpErrorKind::InvalidParams);
    }

    #[test]
    fn parse_args_validates_limit_range() {
        let err = parse_args(&json!({"query": "db", "limit": 0})).unwrap_err();
        assert!(err.message.contains("limit"));
        let err = parse_args(&json!({"query": "db", "limit": 999})).unwrap_err();
        assert!(err.message.contains("limit"));
    }

    #[test]
    fn parse_args_default_limit_is_fifty() {
        let a = parse_args(&json!({"query": "db"})).unwrap();
        assert_eq!(a.limit, 50);
    }
}