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 `RmcpServerKitError` where needed.
11///
12/// # Client-facing message invariant
13///
14/// The `String` payloads of [`Auth`](Self::Auth), [`Rbac`](Self::Rbac),
15/// [`RateLimited`](Self::RateLimited), and the `message` field of
16/// [`RateLimitedFor`](Self::RateLimitedFor) are rendered **verbatim to the
17/// HTTP client** by [`IntoResponse`]. Construction sites MUST keep these
18/// client-safe: no internal error text, source-error chains, file paths, IPs,
19/// SQL, or dependency details. Internal-only variants (`Config`, `Io`, `Json`,
20/// `Toml`, `Tls`, `Startup`, `Internal`, `Metrics`) are collapsed to a generic
21/// `"internal server error"` body and their detail is logged server-side only.
22/// Use [`client_message`](Self::client_message) to obtain the exact body that
23/// will be sent to the client for any variant.
24///
25/// The recurring mistake is interpolating an upstream error into one of these
26/// variants - `map_err(|e| Auth(format!("... {e}")))`. That publishes a
27/// dependency's error chain to unauthenticated callers, and usually attaches
28/// the wrong status besides (a crypto or I/O fault is a `500`, not a `401`).
29/// Route such failures to [`Internal`](Self::Internal) instead. A heuristic
30/// regression test in this module's test suite scans `src/` for that specific
31/// shape; it is a tripwire, not a proof, so the invariant still rests on
32/// review.
33#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum RmcpServerKitError {
36    /// Configuration parsing or validation failed.
37    #[error("configuration error: {0}")]
38    Config(String),
39
40    /// Authentication failed (bad/missing credential).
41    #[error("authentication failed: {0}")]
42    Auth(String),
43
44    /// Authorization (RBAC) denied the request.
45    #[error("authorization denied: {0}")]
46    Rbac(String),
47
48    /// Request was rejected by a rate limiter.
49    #[error("rate limited: {0}")]
50    RateLimited(String),
51
52    /// Request was rejected by a rate limiter that knows the wait time.
53    ///
54    /// Renders as HTTP 429 with a `Retry-After` header (RFC 9110
55    /// delta-seconds: the duration is rounded **up** to whole seconds,
56    /// minimum `1`) and the message as a plain-text body. The legacy
57    /// [`RateLimited`](Self::RateLimited) variant remains headerless.
58    #[error("rate limited: {message} (retry after {retry_after:?})")]
59    RateLimitedFor {
60        /// Plain-text client-facing message (response body).
61        message: String,
62        /// Best-effort wait until the next request could be admitted.
63        retry_after: std::time::Duration,
64    },
65
66    /// Underlying I/O error.
67    #[error("I/O error: {0}")]
68    Io(#[from] std::io::Error),
69
70    /// JSON (de)serialization error.
71    #[error("JSON error: {0}")]
72    Json(#[from] serde_json::Error),
73
74    /// TOML parse error (configuration loading).
75    #[error("TOML parse error: {0}")]
76    Toml(#[from] toml::de::Error),
77
78    /// TLS configuration failure (certificate load, key parse, rustls config).
79    #[error("TLS error: {0}")]
80    Tls(String),
81
82    /// Server startup failure (binding, listener, runtime initialization).
83    #[error("server startup error: {0}")]
84    Startup(String),
85
86    /// Internal failure with no client-actionable cause.
87    ///
88    /// Detail is logged server-side and collapsed to `"internal server error"`
89    /// on the wire. Use this for runtime faults that are neither configuration
90    /// nor startup problems -- e.g. a cryptographic primitive failing -- rather
91    /// than reaching for a client-facing variant.
92    #[error("internal error: {0}")]
93    Internal(String),
94
95    /// Metrics registration failure (e.g. Prometheus duplicate or invalid metric).
96    #[cfg(feature = "metrics")]
97    #[error("metrics error: {0}")]
98    Metrics(String),
99}
100
101/// Deprecated compatibility alias for the pre-rename public error type.
102#[deprecated(
103    since = "3.7.0",
104    note = "renamed to `RmcpServerKitError`; the `mcpx` name predates the crate rename"
105)]
106pub type McpxError = RmcpServerKitError;
107
108/// Render a wait [`Duration`](std::time::Duration) as RFC 9110
109/// `Retry-After` delta-seconds: rounded **up** to whole seconds, never
110/// below `1` (a `0` would invite an immediate retry storm).
111fn 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    /// The exact body this error sends to the HTTP client.
121    ///
122    /// Client-facing variants ([`Auth`](Self::Auth), [`Rbac`](Self::Rbac),
123    /// [`RateLimited`](Self::RateLimited), [`RateLimitedFor`](Self::RateLimitedFor))
124    /// return their message verbatim; all internal variants return the generic
125    /// `"internal server error"` so implementation detail never leaks on the
126    /// wire. This is the single source of truth for the client body - the
127    /// [`IntoResponse`] impl uses it - so callers can assert or reuse the
128    /// client-safe text without duplicating the mapping.
129    ///
130    /// See the type-level "Client-facing message invariant" for the contract
131    /// construction sites must uphold.
132    #[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            // Internal variants: never leak detail to the client.
139            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            // All remaining variants are internal - return a generic 500
173            // to avoid leaking implementation details.
174            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
200/// Convenience `Result` alias bound to [`RmcpServerKitError`].
201pub 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        // Client-facing variants: message passes through verbatim.
346        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        // Internal variants: detail is hidden behind a generic body.
368        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        // The accessor and the wire body must agree for every variant we test.
386        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    // -----------------------------------------------------------------
424    // Heuristic regression guard for the client-facing message invariant.
425    //
426    // This is a TRIPWIRE, NOT A PROOF. It catches one specific shape: an
427    // upstream error interpolated into a client-facing variant, which is
428    // the defect that actually shipped (`generate_api_key` embedded a
429    // `password_hash::Error` chain in `Auth`).
430    //
431    // It does NOT catch: an error bound to a differently-named variable;
432    // an error stringified first (`let s = e.to_string()`); a non-error
433    // internal detail such as a file path or upstream URL; or anything
434    // constructed by a downstream crate. Do not read a pass here as the
435    // invariant being enforced -- it is still upheld by review.
436    // -----------------------------------------------------------------
437
438    /// Variant constructors whose payload reaches the HTTP client verbatim.
439    /// `RateLimited` also substring-matches `RateLimitedFor`, which is
440    /// intended -- its `message` field has the same exposure.
441    const CLIENT_FACING_CTORS: &[&str] = &[
442        "RmcpServerKitError::Auth",
443        "RmcpServerKitError::Rbac",
444        "RmcpServerKitError::RateLimited",
445    ];
446
447    /// Binding names that conventionally hold an upstream error. Kept
448    /// deliberately narrow: broadening to `cause`/`detail`/`msg` would
449    /// produce false positives on legitimate caller-known echoes.
450    const ERROR_BINDINGS: &[&str] = &["e", "err", "error", "source"];
451
452    /// Drop comment lines and everything from the `#[cfg(test)]` test
453    /// module onward.
454    ///
455    /// Comments are stripped so documenting the anti-pattern cannot make
456    /// this guard flag its own prose. The test-module cut anchors on
457    /// `#[cfg(test)]` *followed by* `mod tests`, because `config.rs`
458    /// applies `#[cfg(test)]` to several consts near the top of the file
459    /// and cutting at the first occurrence would silently skip the module.
460    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    /// Return a snippet for every client-facing construction that
482    /// interpolates an error-shaped binding.
483    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                // The construction ends at the statement terminator; cap the
492                // window so a missing `;` cannot bleed into later code.
493                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        // Without this, a broken matcher would be indistinguishable from a
544        // clean codebase and the guard would rot into a no-op.
545        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        // The five real rbac.rs sites echo caller-supplied names on purpose.
558        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        // A `#[cfg(test)]` const must NOT truncate the scan (config.rs shape).
574        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}