Skip to main content

rmcp_server_kit/
error.rs

1use axum::{
2    http::StatusCode,
3    response::{IntoResponse, Response},
4};
5use thiserror::Error;
6
7/// Generic MCP server error type.
8///
9/// Application crates should define their own error types and convert
10/// from/into `McpxError` where needed.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum McpxError {
14    /// Configuration parsing or validation failed.
15    #[error("configuration error: {0}")]
16    Config(String),
17
18    /// Authentication failed (bad/missing credential).
19    #[error("authentication failed: {0}")]
20    Auth(String),
21
22    /// Authorization (RBAC) denied the request.
23    #[error("authorization denied: {0}")]
24    Rbac(String),
25
26    /// Request was rejected by a rate limiter.
27    #[error("rate limited: {0}")]
28    RateLimited(String),
29
30    /// Underlying I/O error.
31    #[error("I/O error: {0}")]
32    Io(#[from] std::io::Error),
33
34    /// JSON (de)serialization error.
35    #[error("JSON error: {0}")]
36    Json(#[from] serde_json::Error),
37
38    /// TOML parse error (configuration loading).
39    #[error("TOML parse error: {0}")]
40    Toml(#[from] toml::de::Error),
41
42    /// TLS configuration failure (certificate load, key parse, rustls config).
43    #[error("TLS error: {0}")]
44    Tls(String),
45
46    /// Server startup failure (binding, listener, runtime initialization).
47    #[error("server startup error: {0}")]
48    Startup(String),
49
50    /// Metrics registration failure (e.g. Prometheus duplicate or invalid metric).
51    #[cfg(feature = "metrics")]
52    #[error("metrics error: {0}")]
53    Metrics(String),
54}
55
56impl IntoResponse for McpxError {
57    fn into_response(self) -> Response {
58        let (status, client_msg) = match self {
59            Self::Auth(msg) => (StatusCode::UNAUTHORIZED, msg),
60            Self::Rbac(msg) => (StatusCode::FORBIDDEN, msg),
61            Self::RateLimited(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
62            // All remaining variants are internal - return a generic 500
63            // to avoid leaking implementation details.
64            other @ (Self::Config(_)
65            | Self::Io(_)
66            | Self::Json(_)
67            | Self::Toml(_)
68            | Self::Tls(_)
69            | Self::Startup(_)) => {
70                tracing::error!(error = %other, "internal error");
71                (
72                    StatusCode::INTERNAL_SERVER_ERROR,
73                    "internal server error".into(),
74                )
75            }
76            #[cfg(feature = "metrics")]
77            other @ Self::Metrics(_) => {
78                tracing::error!(error = %other, "internal error");
79                (
80                    StatusCode::INTERNAL_SERVER_ERROR,
81                    "internal server error".into(),
82                )
83            }
84        };
85        (status, client_msg).into_response()
86    }
87}
88
89/// Convenience `Result` alias bound to [`McpxError`].
90pub type Result<T> = std::result::Result<T, McpxError>;
91
92#[cfg(test)]
93mod tests {
94    use axum::{http::StatusCode, response::IntoResponse};
95    use http_body_util::BodyExt;
96
97    use super::*;
98
99    async fn status_of(err: McpxError) -> (StatusCode, String) {
100        let resp = err.into_response();
101        let status = resp.status();
102        let body = resp.into_body().collect().await.unwrap().to_bytes();
103        (status, String::from_utf8(body.to_vec()).unwrap())
104    }
105
106    #[tokio::test]
107    async fn auth_error_returns_401() {
108        let (status, body) = status_of(McpxError::Auth("bad token".into())).await;
109        assert_eq!(status, StatusCode::UNAUTHORIZED);
110        assert!(body.contains("bad token"));
111    }
112
113    #[tokio::test]
114    async fn rbac_error_returns_403() {
115        let (status, body) = status_of(McpxError::Rbac("denied".into())).await;
116        assert_eq!(status, StatusCode::FORBIDDEN);
117        assert!(body.contains("denied"));
118    }
119
120    #[tokio::test]
121    async fn rate_limited_error_returns_429() {
122        let (status, body) = status_of(McpxError::RateLimited("slow down".into())).await;
123        assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
124        assert!(body.contains("slow down"));
125    }
126
127    #[tokio::test]
128    async fn config_error_returns_500() {
129        let (status, body) = status_of(McpxError::Config("bad".into())).await;
130        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
131        assert_eq!(
132            body, "internal server error",
133            "must not leak internal detail"
134        );
135    }
136
137    #[tokio::test]
138    async fn io_error_returns_500() {
139        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
140        let (status, body) = status_of(McpxError::from(io_err)).await;
141        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
142        assert_eq!(
143            body, "internal server error",
144            "must not leak internal detail"
145        );
146    }
147
148    #[tokio::test]
149    async fn tls_error_returns_500() {
150        let (status, body) = status_of(McpxError::Tls("bad cert".into())).await;
151        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
152        assert_eq!(
153            body, "internal server error",
154            "must not leak internal detail"
155        );
156    }
157
158    #[tokio::test]
159    async fn startup_error_returns_500() {
160        let (status, body) = status_of(McpxError::Startup("bind failed".into())).await;
161        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
162        assert_eq!(
163            body, "internal server error",
164            "must not leak internal detail"
165        );
166    }
167
168    #[cfg(feature = "metrics")]
169    #[tokio::test]
170    async fn metrics_error_returns_500() {
171        let (status, body) = status_of(McpxError::Metrics("dup metric".into())).await;
172        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
173        assert_eq!(
174            body, "internal server error",
175            "must not leak internal detail"
176        );
177    }
178
179    #[test]
180    fn display_preserves_message() {
181        let err = McpxError::Auth("unauthorized".into());
182        assert_eq!(err.to_string(), "authentication failed: unauthorized");
183
184        let err = McpxError::Rbac("forbidden".into());
185        assert_eq!(err.to_string(), "authorization denied: forbidden");
186
187        let err = McpxError::RateLimited("throttled".into());
188        assert_eq!(err.to_string(), "rate limited: throttled");
189    }
190}