tsafe-mcp 0.1.0

First-party MCP server for tsafe — exposes action-shaped tools to MCP-aware hosts over stdio JSON-RPC.
Documentation
//! JSON-RPC error code table and `McpError` enum.
//!
//! The codes here match the table in `docs/architecture/mcp-server-design.md`
//! §5.4 exactly. Standard JSON-RPC 2.0 codes (`-32700`, `-32600`, `-32601`,
//! `-32602`, `-32603`) use their spec definitions; the server-defined range
//! `-32001..=-32010` covers tsafe-mcp specifics (agent unreachable, scope
//! widening, biometric denied, etc).

use serde::Serialize;
use serde_json::json;
use thiserror::Error;

/// All error kinds returned by tsafe-mcp.
///
/// Each variant maps to a single JSON-RPC error code and a default operator-
/// visible message from design §5.4. Callers attach extra `data` for
/// per-field diagnostics via [`McpError::with_data`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum McpErrorKind {
    // Standard JSON-RPC 2.0 codes.
    ParseError,
    InvalidRequest,
    MethodNotFound,
    InvalidParams,
    InternalError,
    // Server-defined codes per design §5.4.
    AgentNotRunning,
    ProfileNotFound,
    KeyOutOfScope,
    KeyMissing,
    RunTimeout,
    RevealDisabled,
    BiometricDenied,
    ScopeWidening,
    InstallTargetUnknown,
    InstallConfigMalformed,
}

impl McpErrorKind {
    /// JSON-RPC error code for this kind.
    pub fn code(&self) -> i32 {
        match self {
            McpErrorKind::ParseError => -32_700,
            McpErrorKind::InvalidRequest => -32_600,
            McpErrorKind::MethodNotFound => -32_601,
            McpErrorKind::InvalidParams => -32_602,
            McpErrorKind::InternalError => -32_603,
            McpErrorKind::AgentNotRunning => -32_001,
            McpErrorKind::ProfileNotFound => -32_002,
            McpErrorKind::KeyOutOfScope => -32_003,
            McpErrorKind::KeyMissing => -32_004,
            McpErrorKind::RunTimeout => -32_005,
            McpErrorKind::RevealDisabled => -32_006,
            McpErrorKind::BiometricDenied => -32_007,
            McpErrorKind::ScopeWidening => -32_008,
            McpErrorKind::InstallTargetUnknown => -32_009,
            McpErrorKind::InstallConfigMalformed => -32_010,
        }
    }

    /// Default operator-visible message for this kind (design §5.4).
    pub fn default_message(&self) -> &'static str {
        match self {
            McpErrorKind::ParseError => "Invalid JSON-RPC frame",
            McpErrorKind::InvalidRequest => "Invalid request: missing required fields",
            McpErrorKind::MethodNotFound => "Method not found",
            McpErrorKind::InvalidParams => "Invalid parameters",
            McpErrorKind::InternalError => "Internal server error",
            McpErrorKind::AgentNotRunning => {
                "tsafe-agent not running. Run `tsafe agent unlock --profile <p>` and reload the host."
            }
            McpErrorKind::ProfileNotFound => "Profile not found",
            McpErrorKind::KeyOutOfScope => {
                "Key is outside the configured scope for this server"
            }
            McpErrorKind::KeyMissing => "Key not found in profile",
            McpErrorKind::RunTimeout => "Command exceeded timeout",
            McpErrorKind::RevealDisabled => {
                "tsafe_reveal is not enabled on this server. Restart with --allow-reveal."
            }
            McpErrorKind::BiometricDenied => "Biometric verification declined",
            McpErrorKind::ScopeWidening => {
                "Request-time scope or profile widening is not allowed"
            }
            McpErrorKind::InstallTargetUnknown => {
                "Unknown host. Supported: claude, cursor, continue, windsurf, codex"
            }
            McpErrorKind::InstallConfigMalformed => "Existing host config is not valid",
        }
    }
}

/// Error returned by every tsafe-mcp code path that can fail.
///
/// `code` and `message` are emitted directly as the JSON-RPC `error.code` /
/// `error.message`. `data` is the optional `error.data` object containing
/// per-error diagnostics (key name, field name, etc).
#[derive(Debug, Clone, Error)]
#[error("{message}")]
pub struct McpError {
    pub kind: McpErrorKind,
    pub code: i32,
    pub message: String,
    pub data: Option<serde_json::Value>,
}

impl McpError {
    /// Construct a fresh error using the kind's default message with an
    /// additional human-readable detail appended.
    pub fn new<S: Into<String>>(kind: McpErrorKind, detail: S) -> Self {
        let detail = detail.into();
        let base = kind.default_message();
        let message = if detail.is_empty() {
            base.to_string()
        } else if detail.starts_with(base) {
            // Caller already included the base message verbatim.
            detail
        } else {
            format!("{base}: {detail}")
        };
        Self {
            kind,
            code: kind.code(),
            message,
            data: None,
        }
    }

    /// Convenience for "use the default message verbatim" — most kinds in
    /// §5.4 have a fixed phrasing.
    pub fn kind_only(kind: McpErrorKind) -> Self {
        Self {
            kind,
            code: kind.code(),
            message: kind.default_message().to_string(),
            data: None,
        }
    }

    /// Attach structured `data` (key name, field path, etc) for the JSON-RPC
    /// error object.
    pub fn with_data(mut self, data: serde_json::Value) -> Self {
        self.data = Some(data);
        self
    }

    /// Build the JSON-RPC 2.0 `error` object for this error.
    pub fn to_rpc_error_object(&self) -> serde_json::Value {
        match &self.data {
            Some(d) => json!({ "code": self.code, "message": self.message, "data": d }),
            None => json!({ "code": self.code, "message": self.message }),
        }
    }
}

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

    #[test]
    fn every_kind_returns_the_design_doc_code() {
        let pairs: &[(McpErrorKind, i32)] = &[
            (McpErrorKind::ParseError, -32_700),
            (McpErrorKind::InvalidRequest, -32_600),
            (McpErrorKind::MethodNotFound, -32_601),
            (McpErrorKind::InvalidParams, -32_602),
            (McpErrorKind::InternalError, -32_603),
            (McpErrorKind::AgentNotRunning, -32_001),
            (McpErrorKind::ProfileNotFound, -32_002),
            (McpErrorKind::KeyOutOfScope, -32_003),
            (McpErrorKind::KeyMissing, -32_004),
            (McpErrorKind::RunTimeout, -32_005),
            (McpErrorKind::RevealDisabled, -32_006),
            (McpErrorKind::BiometricDenied, -32_007),
            (McpErrorKind::ScopeWidening, -32_008),
            (McpErrorKind::InstallTargetUnknown, -32_009),
            (McpErrorKind::InstallConfigMalformed, -32_010),
        ];
        for (kind, code) in pairs {
            assert_eq!(kind.code(), *code, "kind {kind:?}");
            let err = McpError::kind_only(*kind);
            assert_eq!(err.code, *code);
        }
    }

    #[test]
    fn rpc_error_object_includes_data_when_present() {
        let err = McpError::new(McpErrorKind::KeyOutOfScope, "key 'demo/foo'")
            .with_data(json!({"key": "demo/foo"}));
        let obj = err.to_rpc_error_object();
        assert_eq!(obj["code"], -32_003);
        assert!(obj["message"].as_str().unwrap().contains("demo/foo"));
        assert_eq!(obj["data"]["key"], "demo/foo");
    }

    #[test]
    fn default_messages_match_design_5_4() {
        assert!(McpErrorKind::AgentNotRunning
            .default_message()
            .contains("tsafe-agent not running"));
        assert!(McpErrorKind::RevealDisabled
            .default_message()
            .contains("--allow-reveal"));
        assert!(McpErrorKind::InstallTargetUnknown
            .default_message()
            .contains("claude, cursor, continue, windsurf, codex"));
        assert!(McpErrorKind::ScopeWidening
            .default_message()
            .contains("scope or profile widening"));
    }
}