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}