arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Typed errors for the MCP server and its tools.
//!
//! No raw `String` errors (AGENTS.md §18). [`McpError`] covers exactly the
//! failures the MCP path can encounter: a capability a tool requires that
//! the server did not grant, a tool argument that is missing/malformed/oversized,
//! an unknown method, an unknown tool, a UAG load failure, and JSON-RPC
//! serialization. There are no "future-proof" variants.
//!
//! # Redaction discipline (reuses `arcature_observe::redact`)
//!
//! The MCP error model shares the `arcature_observe::redact::ErrorCategory`
//! vocabulary (master Reservation #4) — the same rule that module establishes
//! applies here: *a possibly-secret-bearing upstream `Display` is never
//! emitted verbatim; classify and drop.* An MCP error response carries a
//! stable code and a fixed human-facing message, never the raw upstream
//! `Display` of a process/IO/serde error (which may quote a DB URL, an env
//! value, or hostile stdout). The upstream error is preserved in the
//! [`std::error::Error::source`] chain for server-side diagnostics, but the
//! wire message is the redacted category + fixed text.

use std::fmt;

use serde::Serialize;

/// A typed failure from the MCP server or one of its tools.
#[derive(Debug)]
pub(crate) enum McpError {
    /// A tool required a capability the server did not grant. Carries the
    /// capability name so the client can report *which* capability was
    /// refused. This is the capability-gating boundary (master Reservation
    /// #4): read-only tools have empty requirements; a destructive tool
    /// that lands later requires `DestructiveWrite`; `Shell` is never
    /// granted and a request for it is refused here.
    CapabilityRefused { capability: &'static str },
    /// A tool argument was missing or had the wrong shape.
    InvalidArgument { message: &'static str },
    /// A tool argument exceeded its bound (§29 abuse limits). Carries the
    /// limit name and the observed size so the client knows what bound it
    /// hit.
    ArgumentTooLarge {
        field: &'static str,
        limit: usize,
        observed: usize,
    },
    /// The JSON-RPC request named a method the server does not implement.
    /// Carries the method name (length-bounded before this point).
    UnknownMethod(String),
    /// The `tools/call` request named a tool that is not registered.
    UnknownTool(String),
    /// Loading the UAG artifact failed (typed; from [`crate::metadata::SchemaError`]).
    Schema(crate::metadata::SchemaError),
    /// Serializing the JSON-RPC response failed. In practice the responses
    /// are built from serializable types, so this is defense-in-depth.
    Serialize(serde_json::Error),
}

impl McpError {
    /// The stable, client-facing error code. Mirrors JSON-RPC's convention
    /// of integer codes; the `-32xxx` range is reserved for protocol errors,
    /// and Arcature's tool/capability codes live in a separate range.
    pub(crate) fn code(&self) -> i64 {
        match self {
            Self::CapabilityRefused { .. } => -32010,
            Self::InvalidArgument { .. } => -32011,
            Self::ArgumentTooLarge { .. } => -32012,
            Self::UnknownMethod(_) => -32601,
            Self::UnknownTool(_) => -32602,
            Self::Schema(_) => -32020,
            Self::Serialize(_) => -32603,
        }
    }

    /// The redacted, fixed human-facing message. Never includes the upstream
    /// `Display` of a process/IO/serde error (redaction discipline).
    pub(crate) fn message(&self) -> String {
        match self {
            Self::CapabilityRefused { capability } => {
                format!("capability `{capability}` is not enabled on this server")
            }
            Self::InvalidArgument { message } => format!("invalid tool argument: {message}"),
            Self::ArgumentTooLarge {
                field,
                limit,
                observed,
            } => {
                format!("tool argument `{field}` is too large (limit {limit}, observed {observed})")
            }
            Self::UnknownMethod(method) => format!("unknown JSON-RPC method: {method}"),
            Self::UnknownTool(name) => format!("unknown tool: {name}"),
            Self::Schema(_) => "cannot load the application graph".to_owned(),
            Self::Serialize(_) => "cannot serialize the MCP response".to_owned(),
        }
    }
}

impl fmt::Display for McpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message())
    }
}

impl std::error::Error for McpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Schema(err) => Some(err),
            Self::Serialize(err) => Some(err),
            Self::CapabilityRefused { .. }
            | Self::InvalidArgument { .. }
            | Self::ArgumentTooLarge { .. }
            | Self::UnknownMethod(_)
            | Self::UnknownTool(_) => None,
        }
    }
}

impl From<crate::metadata::SchemaError> for McpError {
    fn from(value: crate::metadata::SchemaError) -> Self {
        Self::Schema(value)
    }
}
impl From<serde_json::Error> for McpError {
    fn from(value: serde_json::Error) -> Self {
        Self::Serialize(value)
    }
}

/// A minimal JSON-RPC error object, serialized into the `error` field of a
/// response. The `data` field is omitted by default — it would carry the
/// redacted category only, never the upstream `Display`.
#[derive(Debug, Serialize)]
pub(crate) struct RpcError {
    pub(crate) code: i64,
    pub(crate) message: String,
}

impl RpcError {
    pub(crate) fn from_mcp(error: &McpError) -> Self {
        Self {
            code: error.code(),
            message: error.message(),
        }
    }
}

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

    #[test]
    fn capability_refused_carries_capability_name() {
        let err = McpError::CapabilityRefused {
            capability: "DestructiveWrite",
        };
        assert_eq!(err.code(), -32010);
        assert!(
            err.message().contains("DestructiveWrite"),
            "{}",
            err.message()
        );
    }

    #[test]
    fn shell_capability_is_refused_not_special_cased() {
        // `Shell` is refused through the same CapabilityRefused path as any
        // ungranted capability — there is no special "shell" branch in the
        // tools, only the capability gate.
        let err = McpError::CapabilityRefused {
            capability: "Shell",
        };
        assert_eq!(err.code(), -32010);
        assert!(err.message().contains("Shell"));
    }

    #[test]
    fn argument_too_large_reports_limit_and_observed() {
        let err = McpError::ArgumentTooLarge {
            field: "route_name",
            limit: 256,
            observed: 100_000,
        };
        let msg = err.message();
        assert!(msg.contains("256"), "{msg}");
        assert!(msg.contains("100000"), "{msg}");
    }

    #[test]
    fn unknown_method_and_tool_carry_the_name() {
        let m = McpError::UnknownMethod("foo/bar".to_owned());
        assert!(m.message().contains("foo/bar"));
        let t = McpError::UnknownTool("nope".to_owned());
        assert!(t.message().contains("nope"));
    }

    #[test]
    fn schema_error_message_is_redacted() {
        let schema = crate::metadata::SchemaError::IncompatibleSchema {
            found: 99,
            expected: 1,
        };
        let err = McpError::from(schema);
        // The wire message is the fixed redaction, NOT the upstream Display
        // (which would mention versions and a path).
        assert_eq!(err.message(), "cannot load the application graph");
        // The source chain preserves the typed upstream error for diagnostics.
        assert!(std::error::Error::source(&err).is_some());
    }

    #[test]
    fn error_category_is_reused_from_observe() {
        // Confirms the redaction vocabulary is shared, not reinvented: the
        // MCP error model references the same `ErrorCategory` type the
        // observability crate owns.
        use arcature_observe::redact::ErrorCategory;
        let _ = ErrorCategory::Backend;
        let _ = ErrorCategory::Auth;
        assert_eq!(ErrorCategory::Backend.to_string(), "backend");
    }

    #[test]
    fn rpc_error_serializes_code_and_message() {
        let err = McpError::UnknownTool("x".to_owned());
        let rpc = RpcError::from_mcp(&err);
        let json = serde_json::to_string(&rpc).expect("serialize");
        assert!(json.contains("\"code\":-32602"));
        assert!(json.contains("unknown tool: x"));
    }
}