use serde::Serialize;
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum McpErrorKind {
ParseError,
InvalidRequest,
MethodNotFound,
InvalidParams,
InternalError,
AgentNotRunning,
ProfileNotFound,
KeyOutOfScope,
KeyMissing,
RunTimeout,
RevealDisabled,
BiometricDenied,
ScopeWidening,
InstallTargetUnknown,
InstallConfigMalformed,
}
impl McpErrorKind {
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,
}
}
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",
}
}
}
#[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 {
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) {
detail
} else {
format!("{base}: {detail}")
};
Self {
kind,
code: kind.code(),
message,
data: None,
}
}
pub fn kind_only(kind: McpErrorKind) -> Self {
Self {
kind,
code: kind.code(),
message: kind.default_message().to_string(),
data: None,
}
}
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
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"));
}
}