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 security violation error.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use mcp_execution_core::Error;
222 ///
223 /// let err = Error::SecurityViolation {
224 /// reason: "Unauthorized access".to_string(),
225 /// };
226 /// assert!(err.is_security_error());
227 /// ```
228 #[must_use]
229 pub const fn is_security_error(&self) -> bool {
230 matches!(self, Self::SecurityViolation { .. })
231 }
232
233 /// Returns `true` if this is a validation error.
234 ///
235 /// # Examples
236 ///
237 /// ```
238 /// use mcp_execution_core::Error;
239 ///
240 /// let err = Error::ValidationError {
241 /// field: "skill_name".to_string(),
242 /// reason: "Invalid characters".to_string(),
243 /// };
244 /// assert!(err.is_validation_error());
245 /// ```
246 #[must_use]
247 pub const fn is_validation_error(&self) -> bool {
248 matches!(self, Self::ValidationError { .. })
249 }
250
251 /// Returns `true` if this is a script generation error.
252 ///
253 /// # Examples
254 ///
255 /// ```
256 /// use mcp_execution_core::Error;
257 ///
258 /// let err = Error::ScriptGenerationError {
259 /// tool: "send_message".to_string(),
260 /// message: "Template rendering failed".to_string(),
261 /// source: None,
262 /// };
263 /// assert!(err.is_script_generation_error());
264 /// ```
265 #[must_use]
266 pub const fn is_script_generation_error(&self) -> bool {
267 matches!(self, Self::ScriptGenerationError { .. })
268 }
269
270 /// Returns `true` if this is a resource-limit-exceeded error.
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use mcp_execution_core::{Error, ServerId};
276 /// use mcp_execution_core::ResourceKind;
277 ///
278 /// let err = Error::ResourceLimitExceeded {
279 /// resource: ResourceKind::ToolCount {
280 /// server_id: ServerId::new("github").unwrap(),
281 /// },
282 /// actual: 1500,
283 /// limit: 1000,
284 /// };
285 /// assert!(err.is_resource_limit_exceeded());
286 /// ```
287 #[must_use]
288 pub const fn is_resource_limit_exceeded(&self) -> bool {
289 matches!(self, Self::ResourceLimitExceeded { .. })
290 }
291}
292
293/// Result type alias for MCP operations.
294///
295/// This is a convenience alias for `Result<T, Error>` used throughout
296/// the codebase.
297///
298/// # Examples
299///
300/// ```
301/// use mcp_execution_core::{Result, Error};
302///
303/// fn validate_input(value: i32) -> Result<i32> {
304/// if value < 0 {
305/// return Err(Error::InvalidArgument(
306/// "Value must be non-negative".to_string(),
307/// ));
308/// }
309/// Ok(value)
310/// }
311///
312/// assert!(validate_input(5).is_ok());
313/// assert!(validate_input(-1).is_err());
314/// ```
315pub type Result<T> = std::result::Result<T, Error>;
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_security_error_detection() {
323 let err = Error::SecurityViolation {
324 reason: "Access denied".to_string(),
325 };
326 assert!(err.is_security_error());
327 }
328
329 #[test]
330 fn test_error_display() {
331 let err = Error::SecurityViolation {
332 reason: "Unauthorized".to_string(),
333 };
334 let display = format!("{err}");
335 assert!(display.contains("Security policy violation"));
336 assert!(display.contains("Unauthorized"));
337 }
338
339 #[test]
340 fn test_resource_limit_exceeded_detection() {
341 let err = Error::ResourceLimitExceeded {
342 resource: ResourceKind::ToolCount {
343 server_id: crate::ServerId::new("github").unwrap(),
344 },
345 actual: 1500,
346 limit: 1000,
347 };
348 assert!(err.is_resource_limit_exceeded());
349 assert!(!err.is_security_error());
350 let display = format!("{err}");
351 assert!(display.contains("tool count for server 'github'"));
352 assert!(display.contains("1500"));
353 assert!(display.contains("1000"));
354 }
355
356 #[test]
357 fn test_resource_kind_display_variants() {
358 assert_eq!(ResourceKind::ToolNameLength.to_string(), "tool name length");
359 assert_eq!(
360 ResourceKind::DescriptionLength {
361 tool_name: "send_message".to_string()
362 }
363 .to_string(),
364 "description length for tool 'send_message'"
365 );
366 assert_eq!(
367 ResourceKind::InputSchemaSize {
368 tool_name: "send_message".to_string()
369 }
370 .to_string(),
371 "input_schema size for tool 'send_message'"
372 );
373 assert_eq!(
374 ResourceKind::OutputSchemaSize {
375 tool_name: "send_message".to_string()
376 }
377 .to_string(),
378 "output_schema size for tool 'send_message'"
379 );
380 assert_eq!(
381 ResourceKind::GeneratedOutputSize.to_string(),
382 "generated output size"
383 );
384 assert_eq!(
385 ResourceKind::GeneratedFileCount.to_string(),
386 "generated file count"
387 );
388 }
389
390 #[test]
391 fn test_duplicate_generated_file_path_display() {
392 let err = Error::DuplicateGeneratedFilePath {
393 path: "index.ts".to_string(),
394 };
395 assert!(!err.is_resource_limit_exceeded());
396 let display = format!("{err}");
397 assert!(display.contains("index.ts"));
398 }
399
400 #[test]
401 fn test_result_alias() {
402 #[expect(
403 clippy::unnecessary_wraps,
404 reason = "Function must return Result to test the type alias, even though the Ok \
405 path is infallible."
406 )]
407 fn returns_ok() -> Result<i32> {
408 Ok(42)
409 }
410
411 fn returns_err() -> Result<i32> {
412 Err(Error::InvalidArgument("test error".to_string()))
413 }
414
415 assert_eq!(returns_ok().unwrap(), 42);
416 assert!(returns_err().is_err());
417 }
418}