Skip to main content

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}