1use axum::{
2 http::StatusCode,
3 response::{IntoResponse, Response},
4};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum RmcpServerKitError {
36 #[error("configuration error: {0}")]
38 Config(String),
39
40 #[error("authentication failed: {0}")]
42 Auth(String),
43
44 #[error("authorization denied: {0}")]
46 Rbac(String),
47
48 #[error("rate limited: {0}")]
50 RateLimited(String),
51
52 #[error("rate limited: {message} (retry after {retry_after:?})")]
59 RateLimitedFor {
60 message: String,
62 retry_after: std::time::Duration,
64 },
65
66 #[error("I/O error: {0}")]
68 Io(#[from] std::io::Error),
69
70 #[error("JSON error: {0}")]
72 Json(#[from] serde_json::Error),
73
74 #[error("TOML parse error: {0}")]
76 Toml(#[from] toml::de::Error),
77
78 #[error("TLS error: {0}")]
80 Tls(String),
81
82 #[error("server startup error: {0}")]
84 Startup(String),
85
86 #[error("internal error: {0}")]
93 Internal(String),
94
95 #[cfg(feature = "metrics")]
97 #[error("metrics error: {0}")]
98 Metrics(String),
99}
100
101#[deprecated(
103 since = "3.7.0",
104 note = "renamed to `RmcpServerKitError`; the `mcpx` name predates the crate rename"
105)]
106pub type McpxError = RmcpServerKitError;
107
108fn retry_after_secs(wait: std::time::Duration) -> u64 {
112 let mut secs = wait.as_secs();
113 if wait.subsec_nanos() > 0 {
114 secs = secs.saturating_add(1);
115 }
116 secs.max(1)
117}
118
119impl RmcpServerKitError {
120 #[must_use]
133 pub fn client_message(&self) -> std::borrow::Cow<'_, str> {
134 use std::borrow::Cow;
135 match self {
136 Self::Auth(msg) | Self::Rbac(msg) | Self::RateLimited(msg) => Cow::Borrowed(msg),
137 Self::RateLimitedFor { message, .. } => Cow::Borrowed(message),
138 Self::Config(_)
140 | Self::Io(_)
141 | Self::Json(_)
142 | Self::Toml(_)
143 | Self::Tls(_)
144 | Self::Startup(_)
145 | Self::Internal(_) => Cow::Borrowed("internal server error"),
146 #[cfg(feature = "metrics")]
147 Self::Metrics(_) => Cow::Borrowed("internal server error"),
148 }
149 }
150}
151
152impl IntoResponse for RmcpServerKitError {
153 fn into_response(self) -> Response {
154 let (status, client_msg) = match self {
155 Self::Auth(msg) => (StatusCode::UNAUTHORIZED, msg),
156 Self::Rbac(msg) => (StatusCode::FORBIDDEN, msg),
157 Self::RateLimited(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
158 Self::RateLimitedFor {
159 message,
160 retry_after,
161 } => {
162 return (
163 StatusCode::TOO_MANY_REQUESTS,
164 [(
165 axum::http::header::RETRY_AFTER,
166 retry_after_secs(retry_after).to_string(),
167 )],
168 message,
169 )
170 .into_response();
171 }
172 other @ (Self::Config(_)
175 | Self::Io(_)
176 | Self::Json(_)
177 | Self::Toml(_)
178 | Self::Tls(_)
179 | Self::Startup(_)
180 | Self::Internal(_)) => {
181 tracing::error!(error = %other, "internal error");
182 (
183 StatusCode::INTERNAL_SERVER_ERROR,
184 "internal server error".into(),
185 )
186 }
187 #[cfg(feature = "metrics")]
188 other @ Self::Metrics(_) => {
189 tracing::error!(error = %other, "internal error");
190 (
191 StatusCode::INTERNAL_SERVER_ERROR,
192 "internal server error".into(),
193 )
194 }
195 };
196 (status, client_msg).into_response()
197 }
198}
199
200pub type Result<T> = std::result::Result<T, RmcpServerKitError>;
202
203#[cfg(test)]
204mod tests {
205 use axum::{http::StatusCode, response::IntoResponse};
206 use http_body_util::BodyExt;
207
208 use super::*;
209
210 async fn status_of(err: RmcpServerKitError) -> (StatusCode, String) {
211 let resp = err.into_response();
212 let status = resp.status();
213 let body = resp.into_body().collect().await.unwrap().to_bytes();
214 (status, String::from_utf8(body.to_vec()).unwrap())
215 }
216
217 #[tokio::test]
218 async fn auth_error_returns_401() {
219 let (status, body) = status_of(RmcpServerKitError::Auth("bad token".into())).await;
220 assert_eq!(status, StatusCode::UNAUTHORIZED);
221 assert!(body.contains("bad token"));
222 }
223
224 #[tokio::test]
225 async fn rbac_error_returns_403() {
226 let (status, body) = status_of(RmcpServerKitError::Rbac("denied".into())).await;
227 assert_eq!(status, StatusCode::FORBIDDEN);
228 assert!(body.contains("denied"));
229 }
230
231 #[tokio::test]
232 async fn rate_limited_error_returns_429() {
233 let (status, body) = status_of(RmcpServerKitError::RateLimited("slow down".into())).await;
234 assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
235 assert!(body.contains("slow down"));
236 }
237
238 #[tokio::test]
239 async fn legacy_rate_limited_has_no_retry_after_header() {
240 let resp = RmcpServerKitError::RateLimited("slow down".into()).into_response();
241 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
242 assert!(
243 !resp.headers().contains_key(axum::http::header::RETRY_AFTER),
244 "legacy variant must stay headerless"
245 );
246 }
247
248 #[tokio::test]
249 async fn rate_limited_for_sets_retry_after_header() {
250 let resp = RmcpServerKitError::RateLimitedFor {
251 message: "slow down".into(),
252 retry_after: std::time::Duration::from_millis(1500),
253 }
254 .into_response();
255 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
256 let header = resp
257 .headers()
258 .get(axum::http::header::RETRY_AFTER)
259 .expect("Retry-After present")
260 .to_str()
261 .unwrap()
262 .to_owned();
263 assert_eq!(header, "2", "1.5s must round UP to 2");
264 let body = resp.into_body().collect().await.unwrap().to_bytes();
265 assert_eq!(body.as_ref(), b"slow down");
266 }
267
268 #[test]
269 fn retry_after_secs_rounds_up_and_never_zero() {
270 use std::time::Duration;
271 assert_eq!(retry_after_secs(Duration::ZERO), 1, "zero floors to 1");
272 assert_eq!(retry_after_secs(Duration::from_millis(1)), 1);
273 assert_eq!(retry_after_secs(Duration::from_millis(999)), 1);
274 assert_eq!(retry_after_secs(Duration::from_secs(1)), 1, "exact stays");
275 assert_eq!(retry_after_secs(Duration::from_millis(1001)), 2, "ceil");
276 assert_eq!(retry_after_secs(Duration::from_secs(60)), 60);
277 }
278
279 #[tokio::test]
280 async fn config_error_returns_500() {
281 let (status, body) = status_of(RmcpServerKitError::Config("bad".into())).await;
282 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
283 assert_eq!(
284 body, "internal server error",
285 "must not leak internal detail"
286 );
287 }
288
289 #[tokio::test]
290 async fn io_error_returns_500() {
291 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
292 let (status, body) = status_of(RmcpServerKitError::from(io_err)).await;
293 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
294 assert_eq!(
295 body, "internal server error",
296 "must not leak internal detail"
297 );
298 }
299
300 #[tokio::test]
301 async fn tls_error_returns_500() {
302 let (status, body) = status_of(RmcpServerKitError::Tls("bad cert".into())).await;
303 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
304 assert_eq!(
305 body, "internal server error",
306 "must not leak internal detail"
307 );
308 }
309
310 #[tokio::test]
311 async fn startup_error_returns_500() {
312 let (status, body) = status_of(RmcpServerKitError::Startup("bind failed".into())).await;
313 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
314 assert_eq!(
315 body, "internal server error",
316 "must not leak internal detail"
317 );
318 }
319
320 #[cfg(feature = "metrics")]
321 #[tokio::test]
322 async fn metrics_error_returns_500() {
323 let (status, body) = status_of(RmcpServerKitError::Metrics("dup metric".into())).await;
324 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
325 assert_eq!(
326 body, "internal server error",
327 "must not leak internal detail"
328 );
329 }
330
331 #[test]
332 fn display_preserves_message() {
333 let err = RmcpServerKitError::Auth("unauthorized".into());
334 assert_eq!(err.to_string(), "authentication failed: unauthorized");
335
336 let err = RmcpServerKitError::Rbac("forbidden".into());
337 assert_eq!(err.to_string(), "authorization denied: forbidden");
338
339 let err = RmcpServerKitError::RateLimited("throttled".into());
340 assert_eq!(err.to_string(), "rate limited: throttled");
341 }
342
343 #[test]
344 fn client_message_exposes_client_facing_text_and_hides_internal_detail() {
345 assert_eq!(
347 RmcpServerKitError::Auth("bad token".into()).client_message(),
348 "bad token"
349 );
350 assert_eq!(
351 RmcpServerKitError::Rbac("nope".into()).client_message(),
352 "nope"
353 );
354 assert_eq!(
355 RmcpServerKitError::RateLimited("slow down".into()).client_message(),
356 "slow down"
357 );
358 assert_eq!(
359 RmcpServerKitError::RateLimitedFor {
360 message: "too many".into(),
361 retry_after: std::time::Duration::from_secs(1),
362 }
363 .client_message(),
364 "too many"
365 );
366
367 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "secret/path/leak");
369 assert_eq!(
370 RmcpServerKitError::from(io_err).client_message(),
371 "internal server error"
372 );
373 assert_eq!(
374 RmcpServerKitError::Tls("private key /etc/certs/server.key".into()).client_message(),
375 "internal server error"
376 );
377 assert_eq!(
378 RmcpServerKitError::Config("bind 10.0.0.5:8443 failed".into()).client_message(),
379 "internal server error"
380 );
381 }
382
383 #[tokio::test]
384 async fn client_message_matches_into_response_body() {
385 for err in [
387 RmcpServerKitError::Auth("a".into()),
388 RmcpServerKitError::Rbac("b".into()),
389 RmcpServerKitError::RateLimited("c".into()),
390 RmcpServerKitError::Config("d".into()),
391 RmcpServerKitError::Tls("e".into()),
392 RmcpServerKitError::Internal("f".into()),
393 ] {
394 let expected = err.client_message().into_owned();
395 let (_status, body) = status_of(err).await;
396 assert_eq!(body, expected, "client_message must equal the wire body");
397 }
398 }
399
400 #[tokio::test]
401 async fn internal_variant_is_500_and_leaks_nothing() {
402 let (status, body) = status_of(RmcpServerKitError::Internal(
403 "argon2id hashing failed: oom".into(),
404 ))
405 .await;
406 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
407 assert_eq!(body, "internal server error");
408 assert!(
409 !body.contains("argon2"),
410 "upstream error detail must never reach the client body"
411 );
412 }
413
414 #[test]
415 fn internal_client_message_is_generic() {
416 assert_eq!(
417 RmcpServerKitError::Internal("salt encoding failed: bad length".into())
418 .client_message(),
419 "internal server error"
420 );
421 }
422
423 const CLIENT_FACING_CTORS: &[&str] = &[
442 "RmcpServerKitError::Auth",
443 "RmcpServerKitError::Rbac",
444 "RmcpServerKitError::RateLimited",
445 ];
446
447 const ERROR_BINDINGS: &[&str] = &["e", "err", "error", "source"];
451
452 fn production_source(src: &str) -> String {
461 let lines: Vec<&str> = src.lines().collect();
462 let mut out = String::with_capacity(src.len());
463 for (i, line) in lines.iter().enumerate() {
464 let trimmed = line.trim_start();
465 if trimmed == "#[cfg(test)]"
466 && lines
467 .get(i + 1)
468 .is_some_and(|next| next.trim_start().starts_with("mod tests"))
469 {
470 break;
471 }
472 if trimmed.starts_with("//") {
473 continue;
474 }
475 out.push_str(line);
476 out.push('\n');
477 }
478 out
479 }
480
481 fn find_error_interpolations(src: &str) -> Vec<String> {
484 let scanned = production_source(src);
485 let mut hits = Vec::new();
486 for ctor in CLIENT_FACING_CTORS {
487 let mut from = 0_usize;
488 while let Some(rel) = scanned.get(from..).and_then(|s| s.find(ctor)) {
489 let start = from + rel;
490 let rest = scanned.get(start..).unwrap_or_default();
491 let end = rest.find(';').map_or(400, |i| i.min(400));
494 let window = rest.get(..end).unwrap_or(rest);
495 if ERROR_BINDINGS.iter().any(|b| {
496 window.contains(&format!("{{{b}}}"))
497 || window.contains(&format!("{{{b}:"))
498 || window.contains(&format!(", {b})"))
499 }) {
500 hits.push(window.split_whitespace().collect::<Vec<_>>().join(" "));
501 }
502 from = start + ctor.len();
503 }
504 }
505 hits
506 }
507
508 #[test]
509 fn client_facing_variants_do_not_interpolate_upstream_errors() {
510 let src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
511 let entries = std::fs::read_dir(&src_dir).expect("src/ is readable");
512 let mut offenders: Vec<String> = Vec::new();
513 let mut scanned_files = 0_usize;
514 for entry in entries {
515 let path = entry.expect("dir entry").path();
516 if path.extension().is_none_or(|ext| ext != "rs") {
517 continue;
518 }
519 let src = std::fs::read_to_string(&path).expect("source file is readable");
520 scanned_files += 1;
521 for hit in find_error_interpolations(&src) {
522 offenders.push(format!("{}: {hit}", path.display()));
523 }
524 }
525 assert!(
526 scanned_files > 10,
527 "guard scanned only {scanned_files} files; the walk is broken"
528 );
529 assert!(
530 offenders.is_empty(),
531 "client-facing error variants must not carry upstream error text \
532 (see the invariant on RmcpServerKitError); use Internal instead:\n{}",
533 offenders.join("\n")
534 );
535 }
536
537 #[test]
538 #[allow(
539 clippy::literal_string_with_formatting_args,
540 reason = "the format-shaped text is the fixture under test, not a format call"
541 )]
542 fn guard_detects_a_synthetic_violation() {
543 let offending = "fn f() { RmcpServerKitError::Auth(format!(\"hashing failed: {e}\")); }";
546 assert_eq!(find_error_interpolations(offending).len(), 1);
547
548 let positional = "fn f() { RmcpServerKitError::Rbac(format!(\"bad: {}\", err)); }";
549 assert_eq!(find_error_interpolations(positional).len(), 1);
550
551 let debug_spec = "fn f() { RmcpServerKitError::RateLimited(format!(\"x {error:?}\")); }";
552 assert_eq!(find_error_interpolations(debug_spec).len(), 1);
553 }
554
555 #[test]
556 fn guard_allows_caller_known_interpolation() {
557 let allowed = "fn f() { RmcpServerKitError::Rbac(format!(\"{tool_name} denied for role '{role}'\")); }";
559 assert!(find_error_interpolations(allowed).is_empty());
560
561 let arg = "fn f() { RmcpServerKitError::Rbac(format!(\"argument '{arg_key}' must be a string for tool '{tool_name}'\")); }";
562 assert!(find_error_interpolations(arg).is_empty());
563 }
564
565 #[test]
566 fn guard_ignores_comments_and_test_modules() {
567 let in_comment = "/// BAD: RmcpServerKitError::Auth(format!(\"{e}\"))\nfn f() {}";
568 assert!(find_error_interpolations(in_comment).is_empty());
569
570 let in_tests = "fn ok() {}\n#[cfg(test)]\nmod tests {\n RmcpServerKitError::Auth(format!(\"{e}\"));\n}";
571 assert!(find_error_interpolations(in_tests).is_empty());
572
573 let const_then_code = "#[cfg(test)]\nconst X: &[&str] = &[];\nfn f() { RmcpServerKitError::Auth(format!(\"{e}\")); }";
575 assert_eq!(find_error_interpolations(const_then_code).len(), 1);
576 }
577}