Skip to main content

nika_mcp/validation/
enhancer.rs

1//! Error Enhancer Module (Layer 3)
2//!
3//! Enhances MCP error messages with better context.
4//!
5//! ## Design
6//!
7//! - Parses MCP error messages to identify patterns
8//! - Adds "did you mean?" suggestions based on schema
9//! - Includes required fields in error messages
10//!
11//! ## Usage
12//!
13//! ```rust,ignore
14//! use nika_mcp::validation::{ErrorEnhancer, ToolSchemaCache};
15//!
16//! let cache = ToolSchemaCache::new();
17//! cache.populate("novanet", &tools)?;
18//!
19//! let enhancer = ErrorEnhancer::new(&cache);
20//! let enhanced = enhancer.enhance("novanet", "novanet_context", original_error);
21//! ```
22
23use super::schema_cache::{CachedSchema, ToolSchemaCache};
24use crate::error::McpError;
25
26/// Enhances MCP errors with better context
27pub struct ErrorEnhancer<'a> {
28    cache: &'a ToolSchemaCache,
29}
30
31impl<'a> ErrorEnhancer<'a> {
32    /// Create a new enhancer with access to schema cache
33    pub fn new(cache: &'a ToolSchemaCache) -> Self {
34        Self { cache }
35    }
36
37    /// Enhance an MCP error with better context
38    pub fn enhance(&self, server: &str, tool: &str, error: McpError) -> McpError {
39        let McpError::McpToolError {
40            tool: tool_name,
41            reason,
42            error_code,
43        } = &error
44        else {
45            return error; // Only enhance McpToolError
46        };
47
48        // Try to parse the error message
49        let enhanced_reason = self.enhance_reason(server, tool, reason);
50
51        McpError::McpToolError {
52            tool: tool_name.clone(),
53            reason: enhanced_reason,
54            error_code: *error_code,
55        }
56    }
57
58    /// Enhance a raw error reason string
59    fn enhance_reason(&self, server: &str, tool: &str, reason: &str) -> String {
60        let Some(schema_ref) = self.cache.get(server, tool) else {
61            return reason.to_string();
62        };
63
64        let schema = schema_ref.value();
65        let reason_lower = reason.to_lowercase();
66
67        // Missing field pattern
68        if reason_lower.contains("missing field") {
69            return self.enhance_missing_field(reason, schema);
70        }
71
72        // Unknown field pattern
73        if reason_lower.contains("unknown field") || reason_lower.contains("unexpected") {
74            return self.enhance_unknown_field(reason, schema);
75        }
76
77        // Add required fields hint for any error
78        if !schema.required.is_empty() {
79            format!(
80                "{}. Required: [{}]. Available: [{}]",
81                reason,
82                schema.required.join(", "),
83                schema.properties.join(", ")
84            )
85        } else {
86            reason.to_string()
87        }
88    }
89
90    /// Enhance "missing field" errors
91    fn enhance_missing_field(&self, reason: &str, schema: &CachedSchema) -> String {
92        format!(
93            "{}. Required: [{}]. Available: [{}]",
94            reason,
95            schema.required.join(", "),
96            schema.properties.join(", ")
97        )
98    }
99
100    /// Enhance "unknown field" errors
101    fn enhance_unknown_field(&self, reason: &str, schema: &CachedSchema) -> String {
102        format!(
103            "{}. Valid fields: [{}]",
104            reason,
105            schema.properties.join(", ")
106        )
107    }
108}
109
110// ============================================================================
111// TESTS (TDD)
112// ============================================================================
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::types::ToolDefinition;
118    use serde_json::json;
119
120    // ========================================================================
121    // Test: Enhance missing field error
122    // ========================================================================
123    #[test]
124    fn test_enhance_missing_field_error() {
125        let cache = ToolSchemaCache::new();
126        cache
127            .populate(
128                "novanet",
129                &[
130                    ToolDefinition::new("novanet_context").with_input_schema(json!({
131                        "type": "object",
132                        "properties": {
133                            "entity": { "type": "string" },
134                            "locale": { "type": "string" }
135                        },
136                        "required": ["entity"]
137                    })),
138                ],
139            )
140            .unwrap();
141
142        let enhancer = ErrorEnhancer::new(&cache);
143        let original = McpError::McpToolError {
144            tool: "novanet_context".to_string(),
145            reason: "missing field `entity`".to_string(),
146            error_code: None,
147        };
148
149        let enhanced = enhancer.enhance("novanet", "novanet_context", original);
150
151        let McpError::McpToolError { reason, .. } = enhanced else {
152            panic!("Expected McpToolError");
153        };
154
155        assert!(reason.contains("Required:"));
156        assert!(reason.contains("entity"));
157        assert!(reason.contains("Available:"));
158    }
159
160    // ========================================================================
161    // Test: Enhance unknown field error
162    // ========================================================================
163    #[test]
164    fn test_enhance_unknown_field_error() {
165        let cache = ToolSchemaCache::new();
166        cache
167            .populate(
168                "novanet",
169                &[ToolDefinition::new("tool").with_input_schema(json!({
170                    "type": "object",
171                    "properties": {
172                        "entity": {},
173                        "locale": {}
174                    }
175                }))],
176            )
177            .unwrap();
178
179        let enhancer = ErrorEnhancer::new(&cache);
180        let original = McpError::McpToolError {
181            tool: "tool".to_string(),
182            reason: "unknown field `wrong_name`".to_string(),
183            error_code: None,
184        };
185
186        let enhanced = enhancer.enhance("novanet", "tool", original);
187
188        let McpError::McpToolError { reason, .. } = enhanced else {
189            panic!("Expected McpToolError");
190        };
191
192        assert!(reason.contains("Valid fields:"));
193        assert!(reason.contains("entity"));
194        assert!(reason.contains("locale"));
195    }
196
197    // ========================================================================
198    // Test: Pass through non-MCP errors
199    // ========================================================================
200    #[test]
201    fn test_enhance_passes_through_non_mcp_errors() {
202        let cache = ToolSchemaCache::new();
203        let enhancer = ErrorEnhancer::new(&cache);
204
205        let original = McpError::ParseError {
206            details: "test".to_string(),
207        };
208        let enhanced = enhancer.enhance("s", "t", original);
209
210        assert!(matches!(enhanced, McpError::ParseError { .. }));
211    }
212
213    // ========================================================================
214    // Test: No schema returns original
215    // ========================================================================
216    #[test]
217    fn test_enhance_no_schema_returns_original() {
218        let cache = ToolSchemaCache::new();
219        let enhancer = ErrorEnhancer::new(&cache);
220
221        let original = McpError::McpToolError {
222            tool: "unknown".to_string(),
223            reason: "error".to_string(),
224            error_code: None,
225        };
226
227        let enhanced = enhancer.enhance("s", "unknown", original);
228
229        let McpError::McpToolError { reason, .. } = enhanced else {
230            panic!("Expected McpToolError");
231        };
232        assert_eq!(reason, "error");
233    }
234
235    // ========================================================================
236    // Test: Generic error gets required fields hint
237    // ========================================================================
238    #[test]
239    fn test_enhance_generic_error_adds_hint() {
240        let cache = ToolSchemaCache::new();
241        cache
242            .populate(
243                "s",
244                &[ToolDefinition::new("t").with_input_schema(json!({
245                    "type": "object",
246                    "properties": {
247                        "a": {},
248                        "b": {}
249                    },
250                    "required": ["a"]
251                }))],
252            )
253            .unwrap();
254
255        let enhancer = ErrorEnhancer::new(&cache);
256        let original = McpError::McpToolError {
257            tool: "t".to_string(),
258            reason: "some generic error".to_string(),
259            error_code: None,
260        };
261
262        let enhanced = enhancer.enhance("s", "t", original);
263
264        let McpError::McpToolError { reason, .. } = enhanced else {
265            panic!("Expected McpToolError");
266        };
267
268        assert!(reason.contains("Required:"));
269        assert!(reason.contains("a"));
270        assert!(reason.contains("Available:"));
271    }
272
273    // ========================================================================
274    // Test: No required fields, no hint added
275    // ========================================================================
276    #[test]
277    fn test_enhance_no_required_no_hint() {
278        let cache = ToolSchemaCache::new();
279        cache
280            .populate(
281                "s",
282                &[ToolDefinition::new("t").with_input_schema(json!({
283                    "type": "object",
284                    "properties": {
285                        "optional_field": {}
286                    }
287                    // No "required" array
288                }))],
289            )
290            .unwrap();
291
292        let enhancer = ErrorEnhancer::new(&cache);
293        let original = McpError::McpToolError {
294            tool: "t".to_string(),
295            reason: "some error".to_string(),
296            error_code: None,
297        };
298
299        let enhanced = enhancer.enhance("s", "t", original);
300
301        let McpError::McpToolError { reason, .. } = enhanced else {
302            panic!("Expected McpToolError");
303        };
304
305        // Should return original reason since no required fields
306        assert_eq!(reason, "some error");
307    }
308
309    // ========================================================================
310    // Test: Case insensitive pattern matching
311    // ========================================================================
312    #[test]
313    fn test_enhance_case_insensitive() {
314        let cache = ToolSchemaCache::new();
315        cache
316            .populate(
317                "s",
318                &[ToolDefinition::new("t").with_input_schema(json!({
319                    "type": "object",
320                    "properties": { "field": {} },
321                    "required": ["field"]
322                }))],
323            )
324            .unwrap();
325
326        let enhancer = ErrorEnhancer::new(&cache);
327
328        // Test with uppercase "Missing Field"
329        let original = McpError::McpToolError {
330            tool: "t".to_string(),
331            reason: "Missing Field `field`".to_string(),
332            error_code: None,
333        };
334
335        let enhanced = enhancer.enhance("s", "t", original);
336
337        let McpError::McpToolError { reason, .. } = enhanced else {
338            panic!("Expected McpToolError");
339        };
340
341        assert!(reason.contains("Required:"));
342    }
343}