1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum McpxError {
14 #[error("configuration error: {0}")]
16 Config(String),
17
18 #[error("authentication failed: {0}")]
20 Auth(String),
21
22 #[error("authorization denied: {0}")]
24 Rbac(String),
25
26 #[error("rate limited: {0}")]
28 RateLimited(String),
29
30 #[error("I/O error: {0}")]
32 Io(#[from] std::io::Error),
33
34 #[error("JSON error: {0}")]
36 Json(#[from] serde_json::Error),
37
38 #[error("TOML parse error: {0}")]
40 Toml(#[from] toml::de::Error),
41
42 #[error("TLS error: {0}")]
44 Tls(String),
45
46 #[error("server startup error: {0}")]
48 Startup(String),
49
50 #[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 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
89pub 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}