mcpkit_core/error/
mod.rs

1//! Unified error handling for the MCP SDK.
2//!
3//! This module provides a single, context-rich error type that replaces
4//! the nested error hierarchies found in other implementations.
5//!
6//! # Design Philosophy
7//!
8//! - **Single error type**: All errors flow through [`McpError`]
9//! - **Rich context**: Errors preserve context through the entire call stack
10//! - **JSON-RPC compatible**: Easy conversion to JSON-RPC error responses
11//! - **Diagnostic-friendly**: Integrates with [`miette`] for detailed error reports
12//! - **Size-optimized**: Large error variants are boxed to keep `Result<T, McpError>` small
13//!
14//! # Error Handling Patterns
15//!
16//! The SDK has two distinct error handling patterns for different scenarios:
17//!
18//! ## Pattern 1: `Result<T, McpError>` - For SDK/Framework Errors
19//!
20//! Use `Result<T, McpError>` for errors that indicate something went wrong
21//! with the MCP protocol, transport, or SDK internals:
22//!
23//! - **Transport failures**: Connection lost, timeout, I/O errors
24//! - **Protocol errors**: Invalid JSON-RPC, version mismatch, missing fields
25//! - **Resource not found**: Requested resource/tool/prompt doesn't exist
26//! - **Capability errors**: Feature not supported by client/server
27//! - **Internal errors**: Unexpected SDK state, serialization failures
28//!
29//! These errors typically indicate the request cannot be completed and
30//! require intervention (reconnection, configuration change, bug fix).
31//!
32//! ```rust,no_run
33//! # use mcpkit_core::error::McpError;
34//! # struct Tool;
35//! # struct ListResult { tools: Vec<Tool> }
36//! # struct Client;
37//! # impl Client {
38//! #     fn has_tools(&self) -> bool { true }
39//! #     fn ensure_capability(&self, _: &str, _: bool) -> Result<(), McpError> { Ok(()) }
40//! #     async fn request(&self, _: &str, _: Option<()>) -> Result<ListResult, McpError> { Ok(ListResult { tools: vec![] }) }
41//! async fn list_tools(&self) -> Result<Vec<Tool>, McpError> {
42//!     self.ensure_capability("tools", self.has_tools())?;
43//!     // Transport errors propagate as McpError
44//!     let result = self.request("tools/list", None).await?;
45//!     Ok(result.tools)
46//! }
47//! # }
48//! ```
49//!
50//! ## Pattern 2: `ToolOutput::RecoverableError` - For User/LLM-Correctable Errors
51//!
52//! Use [`ToolOutput::error()`](crate::types::ToolOutput::error) for errors that the LLM
53//! can potentially self-correct by adjusting its input:
54//!
55//! - **Validation failures**: Invalid argument format, out-of-range values
56//! - **Business logic errors**: Division by zero, empty query, invalid date
57//! - **Missing optional data**: Lookup returned no results
58//! - **Rate limiting**: Too many requests (suggest retry)
59//!
60//! These errors are returned to the LLM with `is_error: true` in the response,
61//! allowing the model to understand what went wrong and try again.
62//!
63//! ```rust,no_run
64//! # use mcpkit_core::types::ToolOutput;
65//! # struct Calculator;
66//! # impl Calculator {
67//! // #[tool(description = "Divide two numbers")]
68//! async fn divide(&self, a: f64, b: f64) -> ToolOutput {
69//!     if b == 0.0 {
70//!         return ToolOutput::error_with_suggestion(
71//!             "Cannot divide by zero",
72//!             "Use a non-zero divisor",
73//!         );
74//!     }
75//!     ToolOutput::text((a / b).to_string())
76//! }
77//! # }
78//! ```
79//!
80//! ## Decision Guide
81//!
82//! | Scenario | Use | Reason |
83//! |----------|-----|--------|
84//! | Database connection failed | `McpError` | Infrastructure issue |
85//! | User provided invalid email format | `ToolOutput::error` | LLM can fix input |
86//! | Tool doesn't exist | `McpError` | Protocol/discovery issue |
87//! | Search returned no results | `ToolOutput::text("No results")` | Expected outcome |
88//! | API rate limit exceeded | `ToolOutput::error_with_suggestion` | Temporary, can retry |
89//! | Authentication required | `McpError` | Configuration issue |
90//! | Invalid number format in input | `ToolOutput::error` | LLM can fix input |
91//!
92//! ## Context Chaining
93//!
94//! For `McpError`, use context chaining to provide detailed diagnostics:
95//!
96//! ```rust
97//! use mcpkit_core::error::{McpError, McpResultExt};
98//!
99//! fn fetch_data() -> Result<String, McpError> {
100//!     let user_id = 42;
101//!     // Errors automatically get context
102//!     let result: Result<(), McpError> = Err(McpError::resource_not_found("user://42"));
103//!     result
104//!         .context("Failed to fetch user data")
105//!         .with_context(|| format!("User ID: {}", user_id))?;
106//!     Ok("data".to_string())
107//! }
108//! ```
109
110pub mod codes;
111mod context;
112mod details;
113mod jsonrpc;
114mod transport;
115mod types;
116
117// Re-export all public types
118pub use codes::*;
119pub use context::McpResultExt;
120pub use details::{
121    BoxError, HandshakeDetails, InvalidParamsDetails, ToolExecutionDetails, TransportDetails,
122};
123pub use jsonrpc::JsonRpcError;
124pub use transport::{TransportContext, TransportErrorKind};
125pub use types::McpError;
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_error_size_is_small() {
133        // Verify that McpError is reasonably small (64 bytes or less on 64-bit).
134        // This ensures Result<T, McpError> doesn't bloat return values.
135        let size = std::mem::size_of::<McpError>();
136        assert!(
137            size <= 64,
138            "McpError is {size} bytes, should be <= 64 bytes. Consider boxing more variants."
139        );
140
141        // Also verify Result<(), McpError> is small
142        let result_size = std::mem::size_of::<Result<(), McpError>>();
143        assert!(
144            result_size <= 72,
145            "Result<(), McpError> is {result_size} bytes, should be <= 72 bytes."
146        );
147    }
148
149    #[test]
150    fn test_error_codes() {
151        assert_eq!(McpError::parse("test").code(), PARSE_ERROR);
152        assert_eq!(McpError::invalid_request("test").code(), INVALID_REQUEST);
153        assert_eq!(McpError::method_not_found("test").code(), METHOD_NOT_FOUND);
154        assert_eq!(McpError::invalid_params("m", "test").code(), INVALID_PARAMS);
155        assert_eq!(McpError::internal("test").code(), INTERNAL_ERROR);
156        assert_eq!(
157            McpError::transport(TransportErrorKind::ConnectionFailed, "test").code(),
158            SERVER_ERROR_START
159        );
160        assert_eq!(
161            McpError::tool_error("tool", "test").code(),
162            SERVER_ERROR_START - 1
163        );
164        assert_eq!(
165            McpError::handshake_failed("test").code(),
166            SERVER_ERROR_START - 5
167        );
168    }
169
170    #[test]
171    fn test_context_chaining() {
172        fn inner() -> Result<(), McpError> {
173            Err(McpError::resource_not_found("test://resource"))
174        }
175
176        fn outer() -> Result<(), McpError> {
177            inner().context("Failed in outer")?;
178            Ok(())
179        }
180
181        let err = outer().unwrap_err();
182        assert!(err.to_string().contains("Failed in outer"));
183
184        // Verify code propagates through context
185        assert_eq!(err.code(), RESOURCE_NOT_FOUND);
186    }
187
188    #[test]
189    fn test_json_rpc_error_conversion() {
190        let err = McpError::method_not_found_with_suggestions(
191            "unknown_method",
192            vec!["tools/list".to_string(), "resources/list".to_string()],
193        );
194
195        let json_err: JsonRpcError = (&err).into();
196        assert_eq!(json_err.code, METHOD_NOT_FOUND);
197        assert!(json_err.message.contains("unknown_method"));
198        assert!(json_err.data.is_some());
199    }
200
201    #[test]
202    fn test_json_rpc_error_conversion_boxed_variants() {
203        // Test InvalidParams (boxed)
204        let err = McpError::invalid_params_detailed(
205            "test_method",
206            "invalid value",
207            Some("args.count".to_string()),
208            Some("number".to_string()),
209            Some("string".to_string()),
210        );
211        let json_err: JsonRpcError = (&err).into();
212        assert_eq!(json_err.code, INVALID_PARAMS);
213        let data = json_err.data.unwrap();
214        assert_eq!(data["method"], "test_method");
215        assert_eq!(data["param_path"], "args.count");
216
217        // Test Transport (boxed)
218        let err = McpError::transport_with_context(
219            TransportErrorKind::ConnectionFailed,
220            "connection refused",
221            TransportContext::new("websocket").with_remote_addr("ws://localhost:8080"),
222        );
223        let json_err: JsonRpcError = (&err).into();
224        assert_eq!(json_err.code, SERVER_ERROR_START);
225        assert!(json_err.data.is_some());
226
227        // Test ToolExecution (boxed)
228        let err = McpError::tool_error_detailed(
229            "calculator",
230            "division by zero",
231            true,
232            Some(serde_json::json!({"operation": "divide"})),
233        );
234        let json_err: JsonRpcError = (&err).into();
235        assert!(json_err.data.is_some());
236        let data = json_err.data.unwrap();
237        assert_eq!(data["operation"], "divide");
238
239        // Test HandshakeFailed (boxed)
240        let err = McpError::handshake_failed_with_versions(
241            "version mismatch",
242            Some("2024-11-05".to_string()),
243            Some("2025-11-25".to_string()),
244        );
245        let json_err: JsonRpcError = (&err).into();
246        assert!(json_err.data.is_some());
247        let data = json_err.data.unwrap();
248        assert_eq!(data["client_version"], "2024-11-05");
249        assert_eq!(data["server_version"], "2025-11-25");
250    }
251
252    #[test]
253    fn test_recoverable_errors() {
254        assert!(McpError::invalid_params("m", "test").is_recoverable());
255        assert!(McpError::resource_not_found("uri").is_recoverable());
256        assert!(!McpError::internal("test").is_recoverable());
257
258        // Test boxed tool execution with recoverable flag
259        let recoverable_tool = McpError::tool_error_detailed("tool", "error", true, None);
260        assert!(recoverable_tool.is_recoverable());
261
262        let non_recoverable_tool = McpError::tool_error_detailed("tool", "error", false, None);
263        assert!(!non_recoverable_tool.is_recoverable());
264    }
265
266    #[test]
267    fn test_boxed_error_display() {
268        // Ensure Display works correctly for boxed variants
269        let err = McpError::invalid_params("method", "bad params");
270        assert!(err.to_string().contains("method"));
271        assert!(err.to_string().contains("bad params"));
272
273        let err = McpError::transport(TransportErrorKind::Timeout, "connection timed out");
274        assert!(err.to_string().contains("timeout"));
275        assert!(err.to_string().contains("connection timed out"));
276
277        let err = McpError::tool_error("my_tool", "tool failed");
278        assert!(err.to_string().contains("my_tool"));
279        assert!(err.to_string().contains("tool failed"));
280
281        let err = McpError::handshake_failed("protocol mismatch");
282        assert!(err.to_string().contains("protocol mismatch"));
283    }
284
285    #[test]
286    fn test_io_error_conversion() {
287        let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
288        let mcp_err: McpError = io_err.into();
289
290        // Should be converted to Transport error with appropriate kind
291        if let McpError::Transport(details) = mcp_err {
292            assert_eq!(details.kind, TransportErrorKind::ConnectionFailed);
293            assert!(details.message.contains("refused"));
294        } else {
295            panic!("Expected Transport error variant");
296        }
297    }
298}