Skip to main content

tsafe_mcp/
errors.rs

1//! JSON-RPC error code table and `McpError` enum.
2//!
3//! The codes here match the table in `docs/architecture/mcp-server-design.md`
4//! §5.4 exactly. Standard JSON-RPC 2.0 codes (`-32700`, `-32600`, `-32601`,
5//! `-32602`, `-32603`) use their spec definitions; the server-defined range
6//! `-32001..=-32010` covers tsafe-mcp specifics (agent unreachable, scope
7//! widening, biometric denied, etc).
8
9use serde::Serialize;
10use serde_json::json;
11use thiserror::Error;
12
13/// All error kinds returned by tsafe-mcp.
14///
15/// Each variant maps to a single JSON-RPC error code and a default operator-
16/// visible message from design §5.4. Callers attach extra `data` for
17/// per-field diagnostics via [`McpError::with_data`].
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19pub enum McpErrorKind {
20    // Standard JSON-RPC 2.0 codes.
21    ParseError,
22    InvalidRequest,
23    MethodNotFound,
24    InvalidParams,
25    InternalError,
26    // Server-defined codes per design §5.4.
27    AgentNotRunning,
28    ProfileNotFound,
29    KeyOutOfScope,
30    KeyMissing,
31    RunTimeout,
32    RevealDisabled,
33    BiometricDenied,
34    ScopeWidening,
35    InstallTargetUnknown,
36    InstallConfigMalformed,
37}
38
39impl McpErrorKind {
40    /// JSON-RPC error code for this kind.
41    pub fn code(&self) -> i32 {
42        match self {
43            McpErrorKind::ParseError => -32_700,
44            McpErrorKind::InvalidRequest => -32_600,
45            McpErrorKind::MethodNotFound => -32_601,
46            McpErrorKind::InvalidParams => -32_602,
47            McpErrorKind::InternalError => -32_603,
48            McpErrorKind::AgentNotRunning => -32_001,
49            McpErrorKind::ProfileNotFound => -32_002,
50            McpErrorKind::KeyOutOfScope => -32_003,
51            McpErrorKind::KeyMissing => -32_004,
52            McpErrorKind::RunTimeout => -32_005,
53            McpErrorKind::RevealDisabled => -32_006,
54            McpErrorKind::BiometricDenied => -32_007,
55            McpErrorKind::ScopeWidening => -32_008,
56            McpErrorKind::InstallTargetUnknown => -32_009,
57            McpErrorKind::InstallConfigMalformed => -32_010,
58        }
59    }
60
61    /// Default operator-visible message for this kind (design §5.4).
62    pub fn default_message(&self) -> &'static str {
63        match self {
64            McpErrorKind::ParseError => "Invalid JSON-RPC frame",
65            McpErrorKind::InvalidRequest => "Invalid request: missing required fields",
66            McpErrorKind::MethodNotFound => "Method not found",
67            McpErrorKind::InvalidParams => "Invalid parameters",
68            McpErrorKind::InternalError => "Internal server error",
69            McpErrorKind::AgentNotRunning => {
70                "tsafe-agent not running. Run `tsafe agent unlock --profile <p>` and reload the host."
71            }
72            McpErrorKind::ProfileNotFound => "Profile not found",
73            McpErrorKind::KeyOutOfScope => {
74                "Key is outside the configured scope for this server"
75            }
76            McpErrorKind::KeyMissing => "Key not found in profile",
77            McpErrorKind::RunTimeout => "Command exceeded timeout",
78            McpErrorKind::RevealDisabled => {
79                "tsafe_reveal is not enabled on this server. Restart with --allow-reveal."
80            }
81            McpErrorKind::BiometricDenied => "Biometric verification declined",
82            McpErrorKind::ScopeWidening => {
83                "Request-time scope or profile widening is not allowed"
84            }
85            McpErrorKind::InstallTargetUnknown => {
86                "Unknown host. Supported: claude, cursor, continue, windsurf, codex"
87            }
88            McpErrorKind::InstallConfigMalformed => "Existing host config is not valid",
89        }
90    }
91}
92
93/// Error returned by every tsafe-mcp code path that can fail.
94///
95/// `code` and `message` are emitted directly as the JSON-RPC `error.code` /
96/// `error.message`. `data` is the optional `error.data` object containing
97/// per-error diagnostics (key name, field name, etc).
98#[derive(Debug, Clone, Error)]
99#[error("{message}")]
100pub struct McpError {
101    pub kind: McpErrorKind,
102    pub code: i32,
103    pub message: String,
104    pub data: Option<serde_json::Value>,
105}
106
107impl McpError {
108    /// Construct a fresh error using the kind's default message with an
109    /// additional human-readable detail appended.
110    pub fn new<S: Into<String>>(kind: McpErrorKind, detail: S) -> Self {
111        let detail = detail.into();
112        let base = kind.default_message();
113        let message = if detail.is_empty() {
114            base.to_string()
115        } else if detail.starts_with(base) {
116            // Caller already included the base message verbatim.
117            detail
118        } else {
119            format!("{base}: {detail}")
120        };
121        Self {
122            kind,
123            code: kind.code(),
124            message,
125            data: None,
126        }
127    }
128
129    /// Convenience for "use the default message verbatim" — most kinds in
130    /// §5.4 have a fixed phrasing.
131    pub fn kind_only(kind: McpErrorKind) -> Self {
132        Self {
133            kind,
134            code: kind.code(),
135            message: kind.default_message().to_string(),
136            data: None,
137        }
138    }
139
140    /// Attach structured `data` (key name, field path, etc) for the JSON-RPC
141    /// error object.
142    pub fn with_data(mut self, data: serde_json::Value) -> Self {
143        self.data = Some(data);
144        self
145    }
146
147    /// Build the JSON-RPC 2.0 `error` object for this error.
148    pub fn to_rpc_error_object(&self) -> serde_json::Value {
149        match &self.data {
150            Some(d) => json!({ "code": self.code, "message": self.message, "data": d }),
151            None => json!({ "code": self.code, "message": self.message }),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn every_kind_returns_the_design_doc_code() {
162        let pairs: &[(McpErrorKind, i32)] = &[
163            (McpErrorKind::ParseError, -32_700),
164            (McpErrorKind::InvalidRequest, -32_600),
165            (McpErrorKind::MethodNotFound, -32_601),
166            (McpErrorKind::InvalidParams, -32_602),
167            (McpErrorKind::InternalError, -32_603),
168            (McpErrorKind::AgentNotRunning, -32_001),
169            (McpErrorKind::ProfileNotFound, -32_002),
170            (McpErrorKind::KeyOutOfScope, -32_003),
171            (McpErrorKind::KeyMissing, -32_004),
172            (McpErrorKind::RunTimeout, -32_005),
173            (McpErrorKind::RevealDisabled, -32_006),
174            (McpErrorKind::BiometricDenied, -32_007),
175            (McpErrorKind::ScopeWidening, -32_008),
176            (McpErrorKind::InstallTargetUnknown, -32_009),
177            (McpErrorKind::InstallConfigMalformed, -32_010),
178        ];
179        for (kind, code) in pairs {
180            assert_eq!(kind.code(), *code, "kind {kind:?}");
181            let err = McpError::kind_only(*kind);
182            assert_eq!(err.code, *code);
183        }
184    }
185
186    #[test]
187    fn rpc_error_object_includes_data_when_present() {
188        let err = McpError::new(McpErrorKind::KeyOutOfScope, "key 'demo/foo'")
189            .with_data(json!({"key": "demo/foo"}));
190        let obj = err.to_rpc_error_object();
191        assert_eq!(obj["code"], -32_003);
192        assert!(obj["message"].as_str().unwrap().contains("demo/foo"));
193        assert_eq!(obj["data"]["key"], "demo/foo");
194    }
195
196    #[test]
197    fn default_messages_match_design_5_4() {
198        assert!(McpErrorKind::AgentNotRunning
199            .default_message()
200            .contains("tsafe-agent not running"));
201        assert!(McpErrorKind::RevealDisabled
202            .default_message()
203            .contains("--allow-reveal"));
204        assert!(McpErrorKind::InstallTargetUnknown
205            .default_message()
206            .contains("claude, cursor, continue, windsurf, codex"));
207        assert!(McpErrorKind::ScopeWidening
208            .default_message()
209            .contains("scope or profile widening"));
210    }
211}