tsafe-mcp 1.2.0

Bound-contract MCP server for tsafe — run policy-scoped commands without exposing secret values.
Documentation
//! Repo-local tooling inventory MCP tools.

use std::path::{Path, PathBuf};

use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
use tsafe_core::tooling_inventory::{
    check_inventory, suggest_keys, SuggestKey, SuggestKeysRequest, ToolingInventoryError,
};

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

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct InventoryCheckArgs {
    /// Optional repo root. Defaults to the MCP server workdir.
    root: Option<PathBuf>,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SuggestKeysArgs {
    /// Optional repo root. Defaults to the MCP server workdir.
    root: Option<PathBuf>,
    /// Vault namespace prefix, e.g. databricks/athn_dev/.
    namespace: String,
    /// Inventory section to append under.
    section: Option<String>,
    /// Relative key name or full key within namespace.
    key: String,
    purpose: String,
    consumer: String,
    rotation: String,
    reason: Option<String>,
    apply: Option<bool>,
}

pub fn check(session: &Session, raw: Value) -> Result<Value, McpError> {
    let args: InventoryCheckArgs = serde_json::from_value(raw).map_err(|err| {
        McpError::new(
            McpErrorKind::InvalidParams,
            format!("invalid tsafe_inventory_check arguments: {err}"),
        )
    })?;
    let root = resolve_root(session, args.root.as_deref())?;
    let report = check_inventory(&root).map_err(map_inventory_error)?;
    audit_call(
        session,
        "tsafe_inventory_check",
        None,
        Vec::new(),
        None,
        None,
        if report.ok {
            CallStatus::Success
        } else {
            CallStatus::Failure
        },
        if report.ok {
            None
        } else {
            Some("tooling inventory check failed")
        },
    );
    serde_json::to_value(report).map_err(|err| {
        McpError::new(
            McpErrorKind::InternalError,
            format!("serialize inventory check report: {err}"),
        )
    })
}

pub fn suggest(session: &Session, raw: Value) -> Result<Value, McpError> {
    let args: SuggestKeysArgs = serde_json::from_value(raw).map_err(|err| {
        McpError::new(
            McpErrorKind::InvalidParams,
            format!("invalid tsafe_suggest_keys arguments: {err}"),
        )
    })?;
    let root = resolve_root(session, args.root.as_deref())?;
    let report = suggest_keys(
        &root,
        SuggestKeysRequest {
            namespace: args.namespace,
            source: "mcp".to_string(),
            reason: args
                .reason
                .unwrap_or_else(|| "agent suggested missing secret slot".to_string()),
            apply: args.apply.unwrap_or(true),
            keys: vec![SuggestKey {
                key: args.key,
                purpose: args.purpose,
                consumer: args.consumer,
                rotation: args.rotation,
                section: args.section,
            }],
        },
    )
    .map_err(map_inventory_error)?;

    audit_call(
        session,
        "tsafe_suggest_keys",
        None,
        report.added_keys.clone(),
        None,
        None,
        CallStatus::Success,
        None,
    );

    serde_json::to_value(report).map_err(|err| {
        McpError::new(
            McpErrorKind::InternalError,
            format!("serialize suggestion report: {err}"),
        )
    })
}

/// `inputSchema` for `tsafe_inventory_check`, derived from
/// [`InventoryCheckArgs`] so the advertised schema and the `serde` validator
/// share one definition.
pub fn inventory_check_input_schema() -> Value {
    Value::Object(crate::tools::schema::input_schema::<InventoryCheckArgs>())
}

/// `inputSchema` for `tsafe_suggest_keys`, derived from [`SuggestKeysArgs`].
pub fn suggest_keys_input_schema() -> Value {
    Value::Object(crate::tools::schema::input_schema::<SuggestKeysArgs>())
}

pub fn inventory_check_schema() -> Value {
    json!({
        "name": "tsafe_inventory_check",
        "description": "Validate the repo-local .tsafe/tooling/keys.ini secret-slot inventory. Values are never read or returned.",
        "inputSchema": inventory_check_input_schema()
    })
}

pub fn suggest_keys_schema() -> Value {
    json!({
        "name": "tsafe_suggest_keys",
        "description": "Suggest a missing secret slot for the current repo. By default this appends metadata to .tsafe/tooling/keys.ini and writes a receipt; it never writes secret values to the vault.",
        "inputSchema": suggest_keys_input_schema()
    })
}

fn resolve_root(session: &Session, requested: Option<&Path>) -> Result<PathBuf, McpError> {
    let root = requested
        .map(Path::to_path_buf)
        .or_else(|| session.workdir.clone())
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
    let resolved = root.canonicalize().map_err(|err| {
        McpError::new(
            McpErrorKind::BadWorkdir,
            format!(
                "tooling root {} could not be resolved: {err}",
                root.display()
            ),
        )
    })?;

    if let Some(bound) = session.workdir.as_ref() {
        let bound = bound.canonicalize().map_err(|err| {
            McpError::new(
                McpErrorKind::BadWorkdir,
                format!(
                    "bound workdir {} could not be resolved: {err}",
                    bound.display()
                ),
            )
        })?;
        if !resolved.starts_with(&bound) {
            return Err(McpError::new(
                McpErrorKind::ScopeWidening,
                format!(
                    "tooling root {} is outside bound workdir {}",
                    resolved.display(),
                    bound.display()
                ),
            ));
        }
    }

    Ok(resolved)
}

fn map_inventory_error(err: ToolingInventoryError) -> McpError {
    match err {
        ToolingInventoryError::InvalidInput(message) => {
            McpError::new(McpErrorKind::InvalidParams, message)
        }
        ToolingInventoryError::Io(err) => {
            McpError::new(McpErrorKind::InternalError, format!("tooling io: {err}"))
        }
        ToolingInventoryError::Json(err) => {
            McpError::new(McpErrorKind::InternalError, format!("tooling json: {err}"))
        }
    }
}