Skip to main content

turul_http_mcp_server/middleware/
error.rs

1//! Middleware error types
2
3use std::fmt;
4
5/// JSON-RPC 2.0 error codes for middleware errors
6///
7/// These codes are used when converting `MiddlewareError` to `JsonRpcError`.
8///
9/// MCP 2026-07-28 partitions JSON-RPC's `-32000..-32099` implementation-defined
10/// range: `-32000..-32019` is the legacy sub-range — new codes MUST NOT be
11/// allocated in it and new implementations SHOULD NOT use it at all — and
12/// `-32020..-32099` is reserved for the specification. New codes for purposes
13/// the specification does not define SHOULD be allocated outside the JSON-RPC
14/// reserved range `-32768..-32000`. See
15/// [Error Codes](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes).
16///
17/// The three codes below predate that partition and are frozen as legacy
18/// allocations; nothing new may join them, except `UNAUTHORIZED` — relocated
19/// below to correct a `MUST NOT` violation.
20///
21/// `UNAUTHORIZED` used to be `-32002`, one of the codes 2026-07-28 names as
22/// forbidden for this version to emit — it meant "resource not found" in
23/// 2025-11-25 and earlier, so a 2026 permission denial was wire-indistinguishable
24/// from a missing resource. It is now `-32005`. This trades that `MUST NOT`
25/// violation for a `SHOULD NOT`: `-32005` still sits in the legacy
26/// `-32000..-32019` sub-range the spec says new implementations should avoid
27/// entirely, rather than in the unreserved space above `-32099` the spec
28/// recommends for new codes. The spec's recommended range is unreachable for
29/// these three: [`map_middleware_error_to_jsonrpc`] builds them with
30/// `JsonRpcErrorObject::server_error`, whose `assert!` requires the code to lie
31/// in `-32099..=-32000` (a release decision of the sibling `turul-rpc` crate,
32/// not this one) and panics otherwise. `INVALID_REQUEST` and `INTERNAL_ERROR`
33/// are standard JSON-RPC codes and use their own constructors, so the assert
34/// does not apply to them.
35pub mod error_codes {
36    /// Authentication required (-32001)
37    pub const UNAUTHENTICATED: i64 = -32001;
38    /// Permission denied (-32005). Relocated from -32002, which 2026-07-28
39    /// forbids implementations of this version from emitting.
40    pub const UNAUTHORIZED: i64 = -32005;
41    /// Rate limit exceeded (-32003)
42    pub const RATE_LIMIT_EXCEEDED: i64 = -32003;
43    /// Invalid request (standard JSON-RPC error)
44    pub const INVALID_REQUEST: i64 = -32600;
45    /// Internal error (standard JSON-RPC error)
46    pub const INTERNAL_ERROR: i64 = -32603;
47}
48
49/// Errors that can occur during middleware execution
50///
51/// These errors are converted to `McpError` by the framework and then to
52/// JSON-RPC error responses. Middleware should use semantic error types
53/// rather than creating JSON-RPC errors directly.
54///
55/// # Conversion Chain
56///
57/// ```text
58/// MiddlewareError → McpError → JsonRpcError → HTTP/Lambda response
59/// ```
60///
61/// # JSON-RPC Error Codes
62///
63/// Each error variant maps to a specific JSON-RPC error code (see [`error_codes`]):
64///
65/// - `Unauthenticated` → `-32001` "Authentication required"
66/// - `Unauthorized` → `-32005` "Permission denied"
67/// - `RateLimitExceeded` → `-32003` "Rate limit exceeded"
68/// - `InvalidRequest` → `-32600` (standard Invalid Request), with the message in
69///   `data.reason`
70/// - `Internal` → `-32603` (standard Internal error)
71/// - `Custom{code, msg}` → `-32603`; the `code` string is application-level and
72///   has no JSON-RPC number, so it does not reach the wire
73/// - `HttpChallenge` → no JSON-RPC code; answered as a raw 401/403 before dispatch
74///
75/// # Examples
76///
77/// ```rust,no_run
78/// use turul_http_mcp_server::middleware::{MiddlewareError, McpMiddleware, RequestContext, SessionInjection};
79/// use turul_mcp_session_storage::SessionView;
80/// use async_trait::async_trait;
81///
82/// struct ApiKeyAuth {
83///     valid_key: String,
84/// }
85///
86/// #[async_trait]
87/// impl McpMiddleware for ApiKeyAuth {
88///     async fn before_dispatch(
89///         &self,
90///         ctx: &mut RequestContext<'_>,
91///         _session: Option<&dyn SessionView>,
92///         _injection: &mut SessionInjection,
93///     ) -> Result<(), MiddlewareError> {
94///         let key = ctx.metadata()
95///             .get("api-key")
96///             .and_then(|v| v.as_str())
97///             .ok_or_else(|| MiddlewareError::Unauthorized("Missing API key".into()))?;
98///
99///         if key != self.valid_key {
100///             return Err(MiddlewareError::Unauthorized("Invalid API key".into()));
101///         }
102///
103///         Ok(())
104///     }
105/// }
106/// ```
107#[derive(Debug, Clone, PartialEq)]
108pub enum MiddlewareError {
109    /// Authentication required but not provided
110    Unauthenticated(String),
111
112    /// Authentication provided but insufficient permissions
113    Unauthorized(String),
114
115    /// Rate limit exceeded
116    RateLimitExceeded {
117        /// Human-readable message
118        message: String,
119        /// Seconds until limit resets
120        retry_after: Option<u64>,
121    },
122
123    /// Request validation failed
124    InvalidRequest(String),
125
126    /// Internal middleware error (should not expose to client)
127    Internal(String),
128
129    /// Custom error with code and message
130    Custom {
131        /// Error code (for structured error handling)
132        code: String,
133        /// Human-readable message
134        message: String,
135    },
136
137    /// HTTP-level challenge response (401/403 with WWW-Authenticate header)
138    ///
139    /// Used for OAuth 2.1 Bearer token challenges. This variant is handled
140    /// exclusively at the transport level (pre-session phase) and produces
141    /// a raw HTTP response — it NEVER reaches `map_middleware_error_to_jsonrpc()`.
142    ///
143    /// An `unreachable!()` guard in that function catches programming errors.
144    HttpChallenge {
145        /// HTTP status code (401 or 403)
146        status: u16,
147        /// WWW-Authenticate header value (e.g., `Bearer realm="mcp", resource_metadata="..."`)
148        www_authenticate: String,
149        /// Optional JSON error body
150        body: Option<String>,
151    },
152}
153
154impl fmt::Display for MiddlewareError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            Self::Unauthenticated(msg) => write!(f, "Authentication required: {}", msg),
158            Self::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
159            Self::RateLimitExceeded {
160                message,
161                retry_after,
162            } => {
163                if let Some(seconds) = retry_after {
164                    write!(f, "{} (retry after {} seconds)", message, seconds)
165                } else {
166                    write!(f, "{}", message)
167                }
168            }
169            Self::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
170            Self::Internal(msg) => write!(f, "Internal middleware error: {}", msg),
171            Self::Custom { code, message } => write!(f, "{}: {}", code, message),
172            Self::HttpChallenge {
173                status,
174                www_authenticate,
175                ..
176            } => write!(f, "HTTP {} WWW-Authenticate: {}", status, www_authenticate),
177        }
178    }
179}
180
181impl std::error::Error for MiddlewareError {}
182
183impl MiddlewareError {
184    /// Create an unauthenticated error
185    pub fn unauthenticated(msg: impl Into<String>) -> Self {
186        Self::Unauthenticated(msg.into())
187    }
188
189    /// Create an unauthorized error
190    pub fn unauthorized(msg: impl Into<String>) -> Self {
191        Self::Unauthorized(msg.into())
192    }
193
194    /// Create a rate limit error
195    pub fn rate_limit(msg: impl Into<String>, retry_after: Option<u64>) -> Self {
196        Self::RateLimitExceeded {
197            message: msg.into(),
198            retry_after,
199        }
200    }
201
202    /// Create an invalid request error
203    pub fn invalid_request(msg: impl Into<String>) -> Self {
204        Self::InvalidRequest(msg.into())
205    }
206
207    /// Create an internal error
208    pub fn internal(msg: impl Into<String>) -> Self {
209        Self::Internal(msg.into())
210    }
211
212    /// Create a custom error
213    pub fn custom(code: impl Into<String>, message: impl Into<String>) -> Self {
214        Self::Custom {
215            code: code.into(),
216            message: message.into(),
217        }
218    }
219
220    /// Create an HTTP challenge error (401/403 with WWW-Authenticate header)
221    ///
222    /// Used for OAuth 2.1 Bearer token challenges. Handled at transport level only.
223    pub fn http_challenge(status: u16, www_authenticate: impl Into<String>) -> Self {
224        Self::HttpChallenge {
225            status,
226            www_authenticate: www_authenticate.into(),
227            body: None,
228        }
229    }
230
231    /// Create an HTTP challenge error with a response body
232    pub fn http_challenge_with_body(
233        status: u16,
234        www_authenticate: impl Into<String>,
235        body: impl Into<String>,
236    ) -> Self {
237        Self::HttpChallenge {
238            status,
239            www_authenticate: www_authenticate.into(),
240            body: Some(body.into()),
241        }
242    }
243}
244
245/// Convert a middleware rejection into the JSON-RPC error response the client sees.
246///
247/// The sole owner of this mapping. Both transports call it, so the code a client
248/// receives cannot differ by which handler served the request.
249///
250/// The constructor is chosen by code class, not uniformly: `-32600` and `-32603`
251/// are standard JSON-RPC codes with their own constructors, while
252/// `JsonRpcErrorObject::server_error` asserts the code lies in the
253/// implementation-defined `-32099..=-32000`. Routing the standard codes through
254/// `server_error` tripped that assert, so `InvalidRequest`, `Internal` and
255/// `Custom` aborted the request instead of answering it.
256///
257/// `Custom` reports `-32603`: its `code` is a free-form application string with
258/// no JSON-RPC number, and inventing one would put it in a range the spec governs.
259///
260/// # Panics
261///
262/// On `HttpChallenge`, which the transport answers as a raw 401/403 before
263/// dispatch and must never reach here.
264pub fn map_middleware_error_to_jsonrpc(
265    err: MiddlewareError,
266    request_id: turul_rpc::RequestId,
267) -> turul_rpc::JsonRpcResponse {
268    use turul_rpc::error::JsonRpcErrorObject;
269
270    let error_obj = match err {
271        MiddlewareError::Unauthenticated(msg) => JsonRpcErrorObject::server_error(
272            error_codes::UNAUTHENTICATED,
273            &msg,
274            None::<serde_json::Value>,
275        ),
276        MiddlewareError::Unauthorized(msg) => JsonRpcErrorObject::server_error(
277            error_codes::UNAUTHORIZED,
278            &msg,
279            None::<serde_json::Value>,
280        ),
281        MiddlewareError::RateLimitExceeded {
282            message,
283            retry_after,
284        } => JsonRpcErrorObject::server_error(
285            error_codes::RATE_LIMIT_EXCEEDED,
286            &message,
287            retry_after.map(|s| serde_json::json!({ "retryAfter": s })),
288        ),
289        MiddlewareError::InvalidRequest(msg) => {
290            JsonRpcErrorObject::invalid_request(Some(serde_json::json!({ "reason": msg })))
291        }
292        MiddlewareError::Internal(msg) => JsonRpcErrorObject::internal_error(Some(msg)),
293        MiddlewareError::Custom { message, .. } => {
294            JsonRpcErrorObject::internal_error(Some(message))
295        }
296        MiddlewareError::HttpChallenge { .. } => {
297            unreachable!("HttpChallenge must be caught at transport level before JSON-RPC dispatch")
298        }
299    };
300
301    turul_rpc::JsonRpcResponse::Error(turul_rpc::JsonRpcError::new(Some(request_id), error_obj))
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_error_display() {
310        let err = MiddlewareError::unauthenticated("Missing token");
311        assert_eq!(err.to_string(), "Authentication required: Missing token");
312
313        let err = MiddlewareError::unauthorized("Insufficient permissions");
314        assert_eq!(err.to_string(), "Unauthorized: Insufficient permissions");
315
316        let err = MiddlewareError::rate_limit("Too many requests", Some(60));
317        assert_eq!(
318            err.to_string(),
319            "Too many requests (retry after 60 seconds)"
320        );
321
322        let err = MiddlewareError::rate_limit("Too many requests", None);
323        assert_eq!(err.to_string(), "Too many requests");
324
325        let err = MiddlewareError::invalid_request("Malformed params");
326        assert_eq!(err.to_string(), "Invalid request: Malformed params");
327
328        let err = MiddlewareError::internal("Database connection failed");
329        assert_eq!(
330            err.to_string(),
331            "Internal middleware error: Database connection failed"
332        );
333
334        let err = MiddlewareError::custom("CUSTOM_ERROR", "Something went wrong");
335        assert_eq!(err.to_string(), "CUSTOM_ERROR: Something went wrong");
336    }
337
338    /// Every variant a middleware can return must produce a response. Three of
339    /// them used to panic: `-32600`/`-32603` fall outside the
340    /// `-32099..=-32000` that `JsonRpcErrorObject::server_error` asserts, and
341    /// all six were routed through it, so `InvalidRequest`, `Internal` and
342    /// `Custom` aborted the request instead of answering it.
343    #[test]
344    fn every_returnable_variant_maps_to_a_response_without_panicking() {
345        let id = turul_rpc::RequestId::Number(1);
346        let cases: Vec<(MiddlewareError, i64)> = vec![
347            (MiddlewareError::unauthenticated("no token"), -32001),
348            (MiddlewareError::unauthorized("wrong scope"), -32005),
349            (MiddlewareError::rate_limit("slow down", Some(60)), -32003),
350            (MiddlewareError::invalid_request("malformed"), -32600),
351            (MiddlewareError::internal("db down"), -32603),
352            (MiddlewareError::custom("APP_CODE", "boom"), -32603),
353        ];
354
355        for (err, expected) in cases {
356            let label = err.to_string();
357            let response = map_middleware_error_to_jsonrpc(err, id.clone());
358            let turul_rpc::JsonRpcResponse::Error(e) = response else {
359                panic!("{label} must map to an error response");
360            };
361            assert_eq!(e.error.code, expected, "{label} must answer {expected}");
362        }
363    }
364
365    /// `retryAfter` is the one piece of data the mapping carries through, and a
366    /// client uses it to decide when to retry.
367    #[test]
368    fn rate_limit_carries_retry_after_but_only_when_given() {
369        let id = turul_rpc::RequestId::Number(1);
370
371        let with = map_middleware_error_to_jsonrpc(
372            MiddlewareError::rate_limit("slow down", Some(30)),
373            id.clone(),
374        );
375        let turul_rpc::JsonRpcResponse::Error(e) = with else {
376            panic!("expected an error response");
377        };
378        assert_eq!(
379            e.error.data.as_ref().and_then(|d| d.get("retryAfter")),
380            Some(&serde_json::json!(30))
381        );
382
383        let without =
384            map_middleware_error_to_jsonrpc(MiddlewareError::rate_limit("slow down", None), id);
385        let turul_rpc::JsonRpcResponse::Error(e) = without else {
386            panic!("expected an error response");
387        };
388        assert!(
389            e.error.data.is_none(),
390            "no retry_after means no data object: {:?}",
391            e.error.data
392        );
393    }
394
395    /// `UNAUTHENTICATED` and `RATE_LIMIT_EXCEEDED` are frozen legacy
396    /// allocations. `UNAUTHORIZED` is not frozen at its old value: `-32002` is
397    /// a code 2026-07-28 lists among those implementations of this version
398    /// MUST NOT emit, so it was relocated to `-32005`. None of the three may
399    /// enter the spec-reserved sub-range, and `UNAUTHORIZED` specifically must
400    /// never again be `-32002`.
401    #[test]
402    fn middleware_codes_are_frozen_legacy_allocations() {
403        const FROZEN: [(&str, i64); 3] = [
404            ("UNAUTHENTICATED", -32001),
405            ("UNAUTHORIZED", -32005),
406            ("RATE_LIMIT_EXCEEDED", -32003),
407        ];
408        assert_eq!(error_codes::UNAUTHENTICATED, FROZEN[0].1);
409        assert_eq!(error_codes::UNAUTHORIZED, FROZEN[1].1);
410        assert_eq!(error_codes::RATE_LIMIT_EXCEEDED, FROZEN[2].1);
411        assert_ne!(
412            error_codes::UNAUTHORIZED,
413            -32002,
414            "UNAUTHORIZED must never regress to -32002 — 2026-07-28 forbids \
415             implementations of this version from emitting it, and it means \
416             resource-not-found to every conformant peer"
417        );
418
419        for (name, code) in FROZEN {
420            assert!(
421                !(-32099..=-32020).contains(&code),
422                "{name} emits {code}, inside the spec-reserved -32020..-32099 \
423                 sub-range; implementations must not emit codes there that the \
424                 specification does not define"
425            );
426        }
427    }
428
429    /// The per-constant guard above did not catch `session_handler.rs`, which
430    /// emitted the literal `-32002` directly rather than through `error_codes`.
431    /// This scans the crate's own source for the literal, so a new emit site
432    /// fails regardless of how it is constructed. Source-level rather than
433    /// wire-level on purpose: the invariant is "this code appears in no emit
434    /// path", which no single request can demonstrate.
435    #[test]
436    fn no_source_file_emits_the_forbidden_resource_not_found_code() {
437        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
438        let mut offenders = Vec::new();
439        let mut stack = vec![src];
440        while let Some(dir) = stack.pop() {
441            for entry in std::fs::read_dir(&dir).expect("read src dir") {
442                let path = entry.expect("dir entry").path();
443                if path.is_dir() {
444                    stack.push(path);
445                    continue;
446                }
447                if path.extension().is_none_or(|e| e != "rs") {
448                    continue;
449                }
450                // This file names the code in its own assertions and in this
451                // scan; the constants it defines are pinned by
452                // `middleware_codes_are_frozen_legacy_allocations` instead.
453                if path.file_name().is_some_and(|f| f == "error.rs")
454                    && path.parent().is_some_and(|d| d.ends_with("middleware"))
455                {
456                    continue;
457                }
458                let text = std::fs::read_to_string(&path).expect("read source");
459                for (n, line) in text.lines().enumerate() {
460                    let code = line.trim_start();
461                    if code.starts_with("//") {
462                        continue;
463                    }
464                    if code.contains("-32002") {
465                        offenders.push(format!("{}:{}: {}", path.display(), n + 1, code.trim()));
466                    }
467                }
468            }
469        }
470        assert!(
471            offenders.is_empty(),
472            "2026-07-28 forbids implementations of this version from emitting \
473             -32002, which means resource-not-found to every conformant peer:\n{}",
474            offenders.join("\n")
475        );
476    }
477
478    #[test]
479    fn test_error_equality() {
480        let err1 = MiddlewareError::unauthenticated("test");
481        let err2 = MiddlewareError::unauthenticated("test");
482        assert_eq!(err1, err2);
483
484        let err3 = MiddlewareError::rate_limit("test", Some(60));
485        let err4 = MiddlewareError::rate_limit("test", Some(60));
486        assert_eq!(err3, err4);
487    }
488
489    #[test]
490    fn test_http_challenge_variant_display() {
491        let err = MiddlewareError::http_challenge(401, "Bearer realm=\"mcp\"");
492        assert_eq!(
493            err.to_string(),
494            "HTTP 401 WWW-Authenticate: Bearer realm=\"mcp\""
495        );
496
497        let err = MiddlewareError::http_challenge(403, "Bearer error=\"insufficient_scope\"");
498        assert_eq!(
499            err.to_string(),
500            "HTTP 403 WWW-Authenticate: Bearer error=\"insufficient_scope\""
501        );
502    }
503
504    #[test]
505    fn test_http_challenge_constructor() {
506        let err = MiddlewareError::http_challenge(401, "Bearer realm=\"mcp\"");
507        match &err {
508            MiddlewareError::HttpChallenge {
509                status,
510                www_authenticate,
511                body,
512            } => {
513                assert_eq!(*status, 401);
514                assert_eq!(www_authenticate, "Bearer realm=\"mcp\"");
515                assert!(body.is_none());
516            }
517            _ => panic!("Expected HttpChallenge variant"),
518        }
519
520        let err_with_body = MiddlewareError::http_challenge_with_body(
521            401,
522            "Bearer realm=\"mcp\"",
523            r#"{"error":"unauthorized"}"#,
524        );
525        match &err_with_body {
526            MiddlewareError::HttpChallenge {
527                status,
528                www_authenticate,
529                body,
530            } => {
531                assert_eq!(*status, 401);
532                assert_eq!(www_authenticate, "Bearer realm=\"mcp\"");
533                assert_eq!(body.as_deref(), Some(r#"{"error":"unauthorized"}"#));
534            }
535            _ => panic!("Expected HttpChallenge variant"),
536        }
537    }
538
539    #[test]
540    fn test_http_challenge_roundtrip_equality() {
541        let err1 = MiddlewareError::http_challenge(401, "Bearer realm=\"mcp\"");
542        let err2 = MiddlewareError::http_challenge(401, "Bearer realm=\"mcp\"");
543        assert_eq!(err1, err2);
544
545        let err3 = MiddlewareError::http_challenge(401, "Bearer realm=\"mcp\"");
546        let err4 = MiddlewareError::http_challenge(403, "Bearer realm=\"mcp\"");
547        assert_ne!(err3, err4);
548    }
549}