tanuki_mcp/error/
mod.rs

1//! Error types for tanuki-mcp
2//!
3//! This module defines the error hierarchy used throughout the application.
4//! We use `thiserror` for library-style errors that are part of the API,
5//! and convert to appropriate MCP error responses at the boundary.
6
7pub mod mcp_mapper;
8
9use thiserror::Error;
10
11/// Top-level application error
12#[derive(Error, Debug)]
13pub enum AppError {
14    #[error("Configuration error: {0}")]
15    Config(#[from] ConfigError),
16
17    #[error("GitLab API error: {0}")]
18    GitLab(#[from] GitLabError),
19
20    #[error("Access denied: {0}")]
21    AccessDenied(#[from] AccessDeniedError),
22
23    #[error("Tool execution error: {0}")]
24    Tool(#[from] ToolError),
25
26    #[error("Transport error: {0}")]
27    Transport(#[from] TransportError),
28
29    #[error("Authentication error: {0}")]
30    Auth(#[from] AuthError),
31}
32
33/// Configuration-related errors
34#[derive(Error, Debug)]
35pub enum ConfigError {
36    #[error("Failed to load configuration: {0}")]
37    Load(String),
38
39    #[error("Invalid configuration: {message}")]
40    Invalid { message: String },
41
42    #[error("Missing required configuration: {field}")]
43    Missing { field: String },
44
45    #[error("Invalid regex pattern '{pattern}': {reason}")]
46    InvalidPattern { pattern: String, reason: String },
47
48    #[error("IO error: {0}")]
49    Io(#[from] std::io::Error),
50}
51
52/// GitLab API specific errors
53#[derive(Error, Debug)]
54pub enum GitLabError {
55    #[error("HTTP request failed: {0}")]
56    Request(#[from] reqwest::Error),
57
58    #[error("GitLab API error (HTTP {status}): {message}")]
59    Api { status: u16, message: String },
60
61    #[error("Rate limited, retry after {retry_after} seconds")]
62    RateLimited { retry_after: u64 },
63
64    #[error("Resource not found: {resource}")]
65    NotFound { resource: String },
66
67    #[error("Unauthorized: invalid or expired token")]
68    Unauthorized,
69
70    #[error("Forbidden: insufficient permissions for {action}")]
71    Forbidden { action: String },
72
73    #[error("Invalid response from GitLab: {0}")]
74    InvalidResponse(String),
75
76    #[error("Request timeout after {timeout_secs} seconds")]
77    Timeout { timeout_secs: u64 },
78}
79
80impl GitLabError {
81    /// Create an appropriate error from an HTTP status code and response body
82    pub fn from_response(status: u16, body: &str) -> Self {
83        match status {
84            401 => GitLabError::Unauthorized,
85            403 => GitLabError::Forbidden {
86                action: "this operation".into(),
87            },
88            404 => GitLabError::NotFound {
89                resource: "requested resource".into(),
90            },
91            429 => {
92                // Try to parse retry-after from body, default to 60
93                GitLabError::RateLimited { retry_after: 60 }
94            }
95            _ => GitLabError::Api {
96                status,
97                message: if body.is_empty() {
98                    format!("HTTP {}", status)
99                } else {
100                    body.to_string()
101                },
102            },
103        }
104    }
105}
106
107/// Access control errors
108#[derive(Error, Debug)]
109#[error("Access denied for tool '{tool}': {reason}")]
110pub struct AccessDeniedError {
111    pub tool: String,
112    pub reason: String,
113}
114
115impl AccessDeniedError {
116    pub fn new(tool: impl Into<String>, reason: impl Into<String>) -> Self {
117        Self {
118            tool: tool.into(),
119            reason: reason.into(),
120        }
121    }
122
123    pub fn read_only(tool: impl Into<String>) -> Self {
124        Self {
125            tool: tool.into(),
126            reason: "write operations are not permitted in read-only mode".into(),
127        }
128    }
129
130    pub fn denied_by_pattern(tool: impl Into<String>, pattern: impl Into<String>) -> Self {
131        Self {
132            tool: tool.into(),
133            reason: format!("denied by pattern '{}'", pattern.into()),
134        }
135    }
136
137    pub fn category_disabled(tool: impl Into<String>, category: impl Into<String>) -> Self {
138        Self {
139            tool: tool.into(),
140            reason: format!("category '{}' is disabled", category.into()),
141        }
142    }
143
144    pub fn project_restricted(tool: impl Into<String>, project: impl Into<String>) -> Self {
145        Self {
146            tool: tool.into(),
147            reason: format!("not permitted for project '{}'", project.into()),
148        }
149    }
150
151    /// Create an error indicating the tool is denied for this project but may be available for others
152    pub fn project_restricted_with_hint(
153        tool: impl Into<String>,
154        project: impl Into<String>,
155    ) -> Self {
156        Self {
157            tool: tool.into(),
158            reason: format!(
159                "not allowed for project '{}', but may be available for other projects",
160                project.into()
161            ),
162        }
163    }
164
165    /// Create an error indicating the tool is completely unavailable
166    pub fn globally_unavailable(tool: impl Into<String>) -> Self {
167        Self {
168            tool: tool.into(),
169            reason: "this tool is not available".into(),
170        }
171    }
172}
173
174/// Tool execution errors
175#[derive(Error, Debug)]
176pub enum ToolError {
177    #[error("Invalid arguments: {0}")]
178    InvalidArguments(String),
179
180    #[error("Missing required argument: {0}")]
181    MissingArgument(String),
182
183    #[error("Tool execution failed: {0}")]
184    ExecutionFailed(String),
185
186    #[error("GitLab API error: {0}")]
187    GitLab(#[from] GitLabError),
188
189    #[error("Serialization error: {0}")]
190    Serialization(#[from] serde_json::Error),
191
192    #[error("Tool not found: {0}")]
193    NotFound(String),
194
195    #[error("Access denied: {0}")]
196    AccessDenied(#[from] AccessDeniedError),
197}
198
199/// Transport layer errors
200#[derive(Error, Debug)]
201pub enum TransportError {
202    #[error("IO error: {0}")]
203    Io(#[from] std::io::Error),
204
205    #[error("JSON serialization error: {0}")]
206    Json(#[from] serde_json::Error),
207
208    #[error("Connection closed")]
209    ConnectionClosed,
210
211    #[error("Invalid message format: {0}")]
212    InvalidMessage(String),
213
214    #[error("HTTP server error: {0}")]
215    Http(String),
216}
217
218/// Authentication errors
219#[derive(Error, Debug)]
220pub enum AuthError {
221    #[error("No authentication configured")]
222    NotConfigured,
223
224    #[error("Invalid token format")]
225    InvalidToken,
226
227    #[error("Token expired")]
228    TokenExpired,
229
230    #[error("Authentication failed: {0}")]
231    Failed(String),
232}
233
234/// Result type alias for the application
235pub type Result<T> = std::result::Result<T, AppError>;
236
237/// Result type alias for tool operations
238pub type ToolResult<T> = std::result::Result<T, ToolError>;
239
240/// Result type alias for GitLab API operations
241pub type GitLabResult<T> = std::result::Result<T, GitLabError>;
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_gitlab_error_from_response() {
249        assert!(matches!(
250            GitLabError::from_response(401, ""),
251            GitLabError::Unauthorized
252        ));
253
254        assert!(matches!(
255            GitLabError::from_response(403, ""),
256            GitLabError::Forbidden { .. }
257        ));
258
259        assert!(matches!(
260            GitLabError::from_response(404, ""),
261            GitLabError::NotFound { .. }
262        ));
263
264        assert!(matches!(
265            GitLabError::from_response(429, ""),
266            GitLabError::RateLimited { .. }
267        ));
268
269        let api_err = GitLabError::from_response(500, "Internal server error");
270        assert!(matches!(api_err, GitLabError::Api { status: 500, .. }));
271    }
272
273    #[test]
274    fn test_access_denied_constructors() {
275        let err = AccessDeniedError::read_only("create_issue");
276        assert!(err.reason.contains("read-only"));
277
278        let err = AccessDeniedError::denied_by_pattern("delete_issue", "delete_.*");
279        assert!(err.reason.contains("delete_.*"));
280
281        let err = AccessDeniedError::category_disabled("list_wiki", "wiki");
282        assert!(err.reason.contains("wiki"));
283
284        let err = AccessDeniedError::project_restricted("merge_mr", "prod/app");
285        assert!(err.reason.contains("prod/app"));
286    }
287}