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}