mcp_execution_core/error.rs
1//! Error types for MCP Code Execution.
2//!
3//! This module provides a comprehensive error hierarchy with contextual information
4//! following Microsoft Rust Guidelines for error handling.
5//!
6//! # Examples
7//!
8//! ```
9//! use mcp_execution_core::{Error, Result};
10//!
11//! fn connect_to_server(name: &str) -> Result<()> {
12//! if name.is_empty() {
13//! return Err(Error::ValidationError {
14//! field: "name".to_string(),
15//! reason: "Server name cannot be empty".to_string(),
16//! });
17//! }
18//! Ok(())
19//! }
20//!
21//! let err = connect_to_server("").unwrap_err();
22//! assert!(err.is_validation_error());
23//! ```
24
25use crate::ServerId;
26use std::fmt;
27use thiserror::Error;
28
29/// Identifies which bounded resource a [`Error::ResourceLimitExceeded`] rejection concerns.
30///
31/// Closes the free-form `resource: String` field this replaced (issue #317) into a fixed set
32/// of variants, so a call site can no longer report a resource category via an arbitrary,
33/// typo-prone string. Each variant carries whatever context (server or tool identity) is
34/// needed to reproduce the same human-readable message the old ad hoc strings rendered; see
35/// [`ResourceKind`]'s [`Display`](fmt::Display) impl for the exact wording.
36///
37/// # Examples
38///
39/// ```
40/// use mcp_execution_core::ResourceKind;
41/// use mcp_execution_core::ServerId;
42///
43/// let kind = ResourceKind::ToolCount {
44/// server_id: ServerId::new("github").unwrap(),
45/// };
46/// assert_eq!(kind.to_string(), "tool count for server 'github'");
47/// assert_eq!(ResourceKind::ToolNameLength.to_string(), "tool name length");
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ResourceKind {
51 /// Number of tools a server reported (or that codegen would emit files for).
52 ToolCount {
53 /// The server whose tool count exceeded the limit.
54 server_id: ServerId,
55 },
56 /// Length of a single tool's name.
57 ToolNameLength,
58 /// Length of a single tool's description.
59 DescriptionLength {
60 /// Name of the tool whose description exceeded the limit.
61 tool_name: String,
62 },
63 /// Serialized size (bytes) of a tool's input JSON Schema.
64 InputSchemaSize {
65 /// Name of the tool whose input schema exceeded the limit.
66 tool_name: String,
67 },
68 /// Serialized size (bytes) of a tool's output JSON Schema.
69 OutputSchemaSize {
70 /// Name of the tool whose output schema exceeded the limit.
71 tool_name: String,
72 },
73 /// Total size (bytes) of all files generated by one `generate` call.
74 GeneratedOutputSize,
75 /// Total number of files produced by one `generate` call.
76 GeneratedFileCount,
77}
78
79impl fmt::Display for ResourceKind {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::ToolCount { server_id } => write!(f, "tool count for server '{server_id}'"),
83 Self::ToolNameLength => f.write_str("tool name length"),
84 Self::DescriptionLength { tool_name } => {
85 write!(f, "description length for tool '{tool_name}'")
86 }
87 Self::InputSchemaSize { tool_name } => {
88 write!(f, "input_schema size for tool '{tool_name}'")
89 }
90 Self::OutputSchemaSize { tool_name } => {
91 write!(f, "output_schema size for tool '{tool_name}'")
92 }
93 Self::GeneratedOutputSize => f.write_str("generated output size"),
94 Self::GeneratedFileCount => f.write_str("generated file count"),
95 }
96 }
97}
98
99/// Main error type for MCP Code Execution.
100///
101/// All errors in the system use this type, providing consistent error handling
102/// across all crates in the workspace.
103#[derive(Error, Debug)]
104pub enum Error {
105 /// MCP server connection failed.
106 ///
107 /// This error occurs when attempting to connect to an MCP server and
108 /// the connection fails due to network issues, authentication failures,
109 /// or server unavailability.
110 #[error("MCP server connection failed: {server}")]
111 ConnectionFailed {
112 /// Name or identifier of the server that failed to connect
113 server: String,
114 /// Underlying error cause
115 #[source]
116 source: Box<dyn std::error::Error + Send + Sync>,
117 },
118
119 /// Security policy violation.
120 ///
121 /// Raised when an operation violates configured security policies,
122 /// such as attempting to access forbidden resources or exceeding
123 /// resource limits.
124 #[error("Security policy violation: {reason}")]
125 SecurityViolation {
126 /// Description of the security violation
127 reason: String,
128 },
129
130 /// Timeout error.
131 ///
132 /// Occurs when an operation exceeds its configured timeout limit.
133 #[error("Operation timed out after {duration_secs}s: {operation}")]
134 Timeout {
135 /// Name of the operation that timed out
136 operation: String,
137 /// Duration in seconds before timeout occurred
138 duration_secs: u64,
139 },
140
141 /// Serialization/deserialization error.
142 ///
143 /// Raised when JSON or other data format conversion fails.
144 #[error("Serialization error: {message}")]
145 SerializationError {
146 /// Description of the serialization failure
147 message: String,
148 /// Underlying serde error
149 #[source]
150 source: Option<serde_json::Error>,
151 },
152
153 /// Invalid argument error.
154 ///
155 /// Raised when CLI arguments or function parameters are invalid.
156 #[error("Invalid argument: {0}")]
157 InvalidArgument(String),
158
159 /// Validation error for domain types.
160 ///
161 /// Raised when creating or validating domain types like `SkillName`,
162 /// `SkillDescription`, etc. that have specific format requirements.
163 #[error("Validation error in {field}: {reason}")]
164 ValidationError {
165 /// The field that failed validation
166 field: String,
167 /// Detailed reason for the validation failure
168 reason: String,
169 },
170
171 /// Script generation failed.
172 ///
173 /// Raised when generating TypeScript scripts from tool schemas fails.
174 #[error("Script generation failed for tool '{tool}': {message}")]
175 ScriptGenerationError {
176 /// The tool name that failed to generate
177 tool: String,
178 /// Description of the generation failure
179 message: String,
180 /// Optional underlying error
181 #[source]
182 source: Option<Box<dyn std::error::Error + Send + Sync>>,
183 },
184
185 /// A server- or attacker-controlled quantity exceeded a configured upper bound.
186 ///
187 /// Raised when a value that ultimately originates from an untrusted MCP server response
188 /// (tool count, a tool's name/description length, its schema size, etc.) exceeds one of
189 /// the resource-exhaustion (CWE-400) protections in
190 /// [`mcp_execution_introspector`](https://docs.rs/mcp-execution-introspector) or
191 /// [`mcp_execution_codegen`](https://docs.rs/mcp-execution-codegen).
192 #[error("resource limit exceeded for {resource}: {actual} exceeds limit of {limit}")]
193 ResourceLimitExceeded {
194 /// Which bounded resource was exceeded.
195 resource: ResourceKind,
196 /// The actual observed size/count that triggered the rejection.
197 actual: usize,
198 /// The configured maximum allowed for this resource.
199 limit: usize,
200 },
201
202 /// A generated file's path collides with one already present in the same output.
203 ///
204 /// Raised when adding a file to a generated-code collection would silently overwrite
205 /// a file already added at the same path — e.g. a tool name that sanitizes to a
206 /// generator's own reserved output filename (like `index`) slipping past name
207 /// disambiguation and colliding with the fixed `index.ts` re-export (issue #312).
208 #[error("duplicate generated file path: {path}")]
209 DuplicateGeneratedFilePath {
210 /// The path that was already present when a second file was added at it.
211 path: String,
212 },
213}
214
215impl Error {
216 /// Returns `true` if this is a connection error.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use mcp_execution_core::Error;
222 ///
223 /// let err = Error::ConnectionFailed {
224 /// server: "test".to_string(),
225 /// source: "connection refused".into(),
226 /// };
227 /// assert!(err.is_connection_error());
228 /// ```
229 #[must_use]
230 pub const fn is_connection_error(&self) -> bool {
231 matches!(self, Self::ConnectionFailed { .. })
232 }
233
234 /// Returns `true` if this is a security violation error.
235 ///
236 /// # Examples
237 ///
238 /// ```
239 /// use mcp_execution_core::Error;
240 ///
241 /// let err = Error::SecurityViolation {
242 /// reason: "Unauthorized access".to_string(),
243 /// };
244 /// assert!(err.is_security_error());
245 /// ```
246 #[must_use]
247 pub const fn is_security_error(&self) -> bool {
248 matches!(self, Self::SecurityViolation { .. })
249 }
250
251 /// Returns `true` if this is a timeout error.
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// use mcp_execution_core::Error;
257 ///
258 /// let err = Error::Timeout {
259 /// operation: "execute_code".to_string(),
260 /// duration_secs: 30,
261 /// };
262 /// assert!(err.is_timeout());
263 /// ```
264 #[must_use]
265 pub const fn is_timeout(&self) -> bool {
266 matches!(self, Self::Timeout { .. })
267 }
268
269 /// Returns `true` if this is a validation error.
270 ///
271 /// # Examples
272 ///
273 /// ```
274 /// use mcp_execution_core::Error;
275 ///
276 /// let err = Error::ValidationError {
277 /// field: "skill_name".to_string(),
278 /// reason: "Invalid characters".to_string(),
279 /// };
280 /// assert!(err.is_validation_error());
281 /// ```
282 #[must_use]
283 pub const fn is_validation_error(&self) -> bool {
284 matches!(self, Self::ValidationError { .. })
285 }
286
287 /// Returns `true` if this is a script generation error.
288 ///
289 /// # Examples
290 ///
291 /// ```
292 /// use mcp_execution_core::Error;
293 ///
294 /// let err = Error::ScriptGenerationError {
295 /// tool: "send_message".to_string(),
296 /// message: "Template rendering failed".to_string(),
297 /// source: None,
298 /// };
299 /// assert!(err.is_script_generation_error());
300 /// ```
301 #[must_use]
302 pub const fn is_script_generation_error(&self) -> bool {
303 matches!(self, Self::ScriptGenerationError { .. })
304 }
305
306 /// Returns `true` if this is a resource-limit-exceeded error.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// use mcp_execution_core::{Error, ServerId};
312 /// use mcp_execution_core::ResourceKind;
313 ///
314 /// let err = Error::ResourceLimitExceeded {
315 /// resource: ResourceKind::ToolCount {
316 /// server_id: ServerId::new("github").unwrap(),
317 /// },
318 /// actual: 1500,
319 /// limit: 1000,
320 /// };
321 /// assert!(err.is_resource_limit_exceeded());
322 /// ```
323 #[must_use]
324 pub const fn is_resource_limit_exceeded(&self) -> bool {
325 matches!(self, Self::ResourceLimitExceeded { .. })
326 }
327
328 /// Returns `true` if this is a duplicate-generated-file-path error.
329 ///
330 /// # Examples
331 ///
332 /// ```
333 /// use mcp_execution_core::Error;
334 ///
335 /// let err = Error::DuplicateGeneratedFilePath {
336 /// path: "index.ts".to_string(),
337 /// };
338 /// assert!(err.is_duplicate_generated_file_path());
339 /// ```
340 #[must_use]
341 pub const fn is_duplicate_generated_file_path(&self) -> bool {
342 matches!(self, Self::DuplicateGeneratedFilePath { .. })
343 }
344}
345
346/// Result type alias for MCP operations.
347///
348/// This is a convenience alias for `Result<T, Error>` used throughout
349/// the codebase.
350///
351/// # Examples
352///
353/// ```
354/// use mcp_execution_core::{Result, Error};
355///
356/// fn validate_input(value: i32) -> Result<i32> {
357/// if value < 0 {
358/// return Err(Error::InvalidArgument(
359/// "Value must be non-negative".to_string(),
360/// ));
361/// }
362/// Ok(value)
363/// }
364///
365/// assert!(validate_input(5).is_ok());
366/// assert!(validate_input(-1).is_err());
367/// ```
368pub type Result<T> = std::result::Result<T, Error>;
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn test_connection_error_detection() {
376 let err = Error::ConnectionFailed {
377 server: "test-server".to_string(),
378 source: "network error".into(),
379 };
380 assert!(err.is_connection_error());
381 assert!(!err.is_security_error());
382 }
383
384 #[test]
385 fn test_security_error_detection() {
386 let err = Error::SecurityViolation {
387 reason: "Access denied".to_string(),
388 };
389 assert!(err.is_security_error());
390 assert!(!err.is_connection_error());
391 }
392
393 #[test]
394 fn test_timeout_error_detection() {
395 let err = Error::Timeout {
396 operation: "long_operation".to_string(),
397 duration_secs: 60,
398 };
399 assert!(err.is_timeout());
400 assert!(!err.is_validation_error());
401 }
402
403 #[test]
404 fn test_error_display() {
405 let err = Error::SecurityViolation {
406 reason: "Unauthorized".to_string(),
407 };
408 let display = format!("{err}");
409 assert!(display.contains("Security policy violation"));
410 assert!(display.contains("Unauthorized"));
411 }
412
413 #[test]
414 fn test_resource_limit_exceeded_detection() {
415 let err = Error::ResourceLimitExceeded {
416 resource: ResourceKind::ToolCount {
417 server_id: crate::ServerId::new("github").unwrap(),
418 },
419 actual: 1500,
420 limit: 1000,
421 };
422 assert!(err.is_resource_limit_exceeded());
423 assert!(!err.is_security_error());
424 let display = format!("{err}");
425 assert!(display.contains("tool count for server 'github'"));
426 assert!(display.contains("1500"));
427 assert!(display.contains("1000"));
428 }
429
430 #[test]
431 fn test_resource_kind_display_variants() {
432 assert_eq!(ResourceKind::ToolNameLength.to_string(), "tool name length");
433 assert_eq!(
434 ResourceKind::DescriptionLength {
435 tool_name: "send_message".to_string()
436 }
437 .to_string(),
438 "description length for tool 'send_message'"
439 );
440 assert_eq!(
441 ResourceKind::InputSchemaSize {
442 tool_name: "send_message".to_string()
443 }
444 .to_string(),
445 "input_schema size for tool 'send_message'"
446 );
447 assert_eq!(
448 ResourceKind::OutputSchemaSize {
449 tool_name: "send_message".to_string()
450 }
451 .to_string(),
452 "output_schema size for tool 'send_message'"
453 );
454 assert_eq!(
455 ResourceKind::GeneratedOutputSize.to_string(),
456 "generated output size"
457 );
458 assert_eq!(
459 ResourceKind::GeneratedFileCount.to_string(),
460 "generated file count"
461 );
462 }
463
464 #[test]
465 fn test_duplicate_generated_file_path_detection() {
466 let err = Error::DuplicateGeneratedFilePath {
467 path: "index.ts".to_string(),
468 };
469 assert!(err.is_duplicate_generated_file_path());
470 assert!(!err.is_resource_limit_exceeded());
471 let display = format!("{err}");
472 assert!(display.contains("index.ts"));
473 }
474
475 #[test]
476 fn test_result_alias() {
477 // Function must return Result to test the type alias, even though the Ok path is infallible.
478 #[allow(clippy::unnecessary_wraps)]
479 fn returns_ok() -> Result<i32> {
480 Ok(42)
481 }
482
483 fn returns_err() -> Result<i32> {
484 Err(Error::InvalidArgument("test error".to_string()))
485 }
486
487 assert_eq!(returns_ok().unwrap(), 42);
488 assert!(returns_err().is_err());
489 }
490}