mcp_probe_core/
validation.rs

1//! Parameter validation utilities for MCP tool execution.
2//!
3//! This module provides reusable parameter validation logic that can be used across
4//! interactive TUI mode, non-interactive CLI mode, and validation engines.
5
6use anyhow::Result;
7use serde_json::Value;
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// Parameter validation errors
12#[derive(Error, Debug, Clone)]
13pub enum ValidationError {
14    /// Schema compilation failed due to invalid JSON Schema
15    #[error("Schema compilation failed: {0}")]
16    SchemaError(String),
17
18    /// A required parameter is missing from the input
19    #[error("Parameter '{field}' is required but missing")]
20    MissingRequired {
21        /// The name of the missing required field
22        field: String,
23    },
24
25    /// Parameter validation failed against the schema
26    #[error("Parameter '{field}' validation failed: {reason}")]
27    ValidationFailed {
28        /// The name of the field that failed validation
29        field: String,
30        /// The reason why validation failed
31        reason: String,
32    },
33
34    /// Value transformation failed during auto-correction
35    #[error("Value transformation failed for '{field}': {reason}")]
36    TransformationFailed {
37        /// The name of the field where transformation failed
38        field: String,
39        /// The reason why transformation failed
40        reason: String,
41    },
42
43    /// The provided JSON Schema is malformed or invalid
44    #[error("JSON Schema is invalid: {0}")]
45    InvalidSchema(String),
46}
47
48/// Result of parameter validation
49#[derive(Debug, Clone)]
50pub struct ValidationResult {
51    /// Whether validation passed
52    pub is_valid: bool,
53    /// List of validation errors
54    pub errors: Vec<ValidationError>,
55    /// List of warnings (non-blocking issues)
56    pub warnings: Vec<String>,
57    /// Transformed/cleaned parameters ready for use
58    pub validated_params: Value,
59    /// Applied transformations (for logging/debugging)
60    pub transformations: Vec<String>,
61}
62
63/// Parameter validation and transformation engine
64pub struct ParameterValidator {
65    /// Whether to apply automatic transformations
66    pub auto_transform: bool,
67    /// Whether to be strict about unknown properties
68    pub strict_mode: bool,
69}
70
71impl Default for ParameterValidator {
72    fn default() -> Self {
73        Self {
74            auto_transform: true,
75            strict_mode: false,
76        }
77    }
78}
79
80impl ParameterValidator {
81    /// Create a new parameter validator
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Create a strict validator (no auto-transforms, strict schema compliance)
87    pub fn strict() -> Self {
88        Self {
89            auto_transform: false,
90            strict_mode: true,
91        }
92    }
93
94    /// Validate parameters against a JSON Schema
95    pub fn validate(&self, schema: &Value, params: &Value) -> ValidationResult {
96        let mut result = ValidationResult {
97            is_valid: true,
98            errors: Vec::new(),
99            warnings: Vec::new(),
100            validated_params: params.clone(),
101            transformations: Vec::new(),
102        };
103
104        // Validate schema syntax
105        if let Err(e) = self.validate_schema_syntax(schema) {
106            result.is_valid = false;
107            result.errors.push(e);
108            return result;
109        }
110
111        // Apply transformations if enabled
112        if self.auto_transform {
113            if let Err(e) = self.apply_transformations(schema, &mut result) {
114                result.is_valid = false;
115                result.errors.push(e);
116                return result;
117            }
118        }
119
120        // Perform basic validation against schema
121        if let Err(e) = self.validate_against_schema(schema, &result.validated_params) {
122            result.is_valid = false;
123            result.errors.push(e);
124        }
125
126        // Check for required fields
127        if let Err(e) = self.check_required_fields(schema, &result.validated_params) {
128            result.is_valid = false;
129            result.errors.push(e);
130        }
131
132        result
133    }
134
135    /// Validate JSON Schema syntax (simplified - no external deps for now)
136    fn validate_schema_syntax(&self, schema: &Value) -> Result<(), ValidationError> {
137        // Basic validation - ensure it's an object with proper structure
138        if !schema.is_object() {
139            return Err(ValidationError::InvalidSchema(
140                "Schema must be a JSON object".to_string(),
141            ));
142        }
143        Ok(())
144    }
145
146    /// Validate parameters against schema (simplified validation)
147    fn validate_against_schema(
148        &self,
149        schema: &Value,
150        params: &Value,
151    ) -> Result<(), ValidationError> {
152        // Get the properties from the schema
153        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
154            if let Some(params_obj) = params.as_object() {
155                for (field_name, field_schema) in properties {
156                    if let Some(param_value) = params_obj.get(field_name) {
157                        // Check basic type validation
158                        if let Some(expected_type) =
159                            field_schema.get("type").and_then(|t| t.as_str())
160                        {
161                            let valid_type = match expected_type {
162                                "string" => param_value.is_string(),
163                                "number" => param_value.is_number(),
164                                "integer" => {
165                                    param_value.is_number()
166                                        && param_value.as_f64().is_some_and(|n| n.fract() == 0.0)
167                                }
168                                "boolean" => param_value.is_boolean(),
169                                "array" => param_value.is_array(),
170                                "object" => param_value.is_object(),
171                                _ => true, // Allow unknown types
172                            };
173
174                            if !valid_type {
175                                return Err(ValidationError::ValidationFailed {
176                                    field: field_name.clone(),
177                                    reason: format!(
178                                        "Expected type '{}' but got '{}'",
179                                        expected_type,
180                                        if param_value.is_string() {
181                                            "string"
182                                        } else if param_value.is_number() {
183                                            "number"
184                                        } else if param_value.is_boolean() {
185                                            "boolean"
186                                        } else if param_value.is_array() {
187                                            "array"
188                                        } else if param_value.is_object() {
189                                            "object"
190                                        } else {
191                                            "null"
192                                        }
193                                    ),
194                                });
195                            }
196                        }
197                    }
198                }
199            }
200        }
201        Ok(())
202    }
203
204    /// Apply automatic transformations to parameters
205    fn apply_transformations(
206        &self,
207        schema: &Value,
208        result: &mut ValidationResult,
209    ) -> Result<(), ValidationError> {
210        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
211            if let Value::Object(ref mut params_map) = result.validated_params {
212                let mut transformations = Vec::new();
213
214                for (field_name, field_schema) in properties {
215                    if let Some(param_value) = params_map.get_mut(field_name) {
216                        let field_transformations =
217                            self.transform_field_value(field_name, field_schema, param_value)?;
218                        transformations.extend(field_transformations);
219                    }
220                }
221
222                result.transformations.extend(transformations);
223            }
224        }
225        Ok(())
226    }
227
228    /// Transform a single field value based on its schema
229    fn transform_field_value(
230        &self,
231        field_name: &str,
232        field_schema: &Value,
233        param_value: &mut Value,
234    ) -> Result<Vec<String>, ValidationError> {
235        let mut transformations = Vec::new();
236
237        // URL auto-prefixing for string fields that look like URLs
238        if let Some("string") = field_schema.get("type").and_then(|t| t.as_str()) {
239            if let Some(description) = field_schema.get("description").and_then(|d| d.as_str()) {
240                let desc_lower = description.to_lowercase();
241                if desc_lower.contains("url")
242                    || desc_lower.contains("uri")
243                    || field_name.to_lowercase().contains("url")
244                {
245                    if let Value::String(url_str) = param_value {
246                        let original_url = url_str.clone();
247                        if let Some(fixed_url) = self.auto_fix_url(&original_url) {
248                            *param_value = Value::String(fixed_url.clone());
249                            transformations.push(format!(
250                                "Auto-prefixed URL in '{}': '{}' → '{}'",
251                                field_name, original_url, fixed_url
252                            ));
253                        }
254                    }
255                }
256            }
257        }
258
259        // Number type coercion
260        if let Some("number") = field_schema.get("type").and_then(|t| t.as_str()) {
261            if let Value::String(str_val) = param_value {
262                let original_str = str_val.clone();
263                if let Ok(num_val) = original_str.parse::<f64>() {
264                    *param_value = Value::Number(serde_json::Number::from_f64(num_val).unwrap());
265                    transformations.push(format!(
266                        "Converted string to number in '{}': '{}' → {}",
267                        field_name, original_str, num_val
268                    ));
269                }
270            }
271        }
272
273        // Integer type coercion
274        if let Some("integer") = field_schema.get("type").and_then(|t| t.as_str()) {
275            if let Value::String(str_val) = param_value {
276                let original_str = str_val.clone();
277                if let Ok(int_val) = original_str.parse::<i64>() {
278                    *param_value = Value::Number(serde_json::Number::from(int_val));
279                    transformations.push(format!(
280                        "Converted string to integer in '{}': '{}' → {}",
281                        field_name, original_str, int_val
282                    ));
283                }
284            }
285        }
286
287        // Boolean type coercion
288        if let Some("boolean") = field_schema.get("type").and_then(|t| t.as_str()) {
289            if let Value::String(str_val) = param_value {
290                let original_str = str_val.clone();
291                let bool_val = match original_str.to_lowercase().as_str() {
292                    "true" | "yes" | "1" | "on" => Some(true),
293                    "false" | "no" | "0" | "off" => Some(false),
294                    _ => None,
295                };
296                if let Some(bool_val) = bool_val {
297                    *param_value = Value::Bool(bool_val);
298                    transformations.push(format!(
299                        "Converted string to boolean in '{}': '{}' → {}",
300                        field_name, original_str, bool_val
301                    ));
302                }
303            }
304        }
305
306        Ok(transformations)
307    }
308
309    /// Auto-fix URL format issues
310    fn auto_fix_url(&self, url: &str) -> Option<String> {
311        if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
312            return None; // Already valid or empty
313        }
314
315        // Auto-prefix based on common patterns
316        if url.starts_with("localhost")
317            || url.starts_with("127.0.0.1")
318            || url.starts_with("0.0.0.0")
319        {
320            Some(format!("http://{}", url))
321        } else if url.contains('.') && !url.contains(' ') {
322            // Looks like a domain name
323            Some(format!("https://{}", url))
324        } else {
325            None
326        }
327    }
328
329    /// Check for required fields
330    fn check_required_fields(&self, schema: &Value, params: &Value) -> Result<(), ValidationError> {
331        if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
332            if let Some(params_obj) = params.as_object() {
333                for required_field in required {
334                    if let Some(field_name) = required_field.as_str() {
335                        if !params_obj.contains_key(field_name) {
336                            return Err(ValidationError::MissingRequired {
337                                field: field_name.to_string(),
338                            });
339                        }
340                    }
341                }
342            }
343        }
344        Ok(())
345    }
346
347    /// Quick validation check (returns only boolean)
348    pub fn is_valid(&self, schema: &Value, params: &Value) -> bool {
349        self.validate(schema, params).is_valid
350    }
351
352    /// Extract parameter hints from schema (for UI display)
353    pub fn extract_parameter_hints(&self, schema: &Value) -> HashMap<String, ParameterHint> {
354        let mut hints = HashMap::new();
355
356        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
357            let required_fields: Vec<String> = schema
358                .get("required")
359                .and_then(|r| r.as_array())
360                .map(|arr| {
361                    arr.iter()
362                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
363                        .collect()
364                })
365                .unwrap_or_default();
366
367            for (field_name, field_schema) in properties {
368                let hint = ParameterHint {
369                    name: field_name.clone(),
370                    param_type: field_schema
371                        .get("type")
372                        .and_then(|t| t.as_str())
373                        .unwrap_or("string")
374                        .to_string(),
375                    description: field_schema
376                        .get("description")
377                        .and_then(|d| d.as_str())
378                        .map(|s| s.to_string()),
379                    required: required_fields.contains(field_name),
380                    default_value: field_schema.get("default").cloned(),
381                    enum_values: field_schema.get("enum").and_then(|e| e.as_array()).cloned(),
382                    format: field_schema
383                        .get("format")
384                        .and_then(|f| f.as_str())
385                        .map(|s| s.to_string()),
386                    pattern: field_schema
387                        .get("pattern")
388                        .and_then(|p| p.as_str())
389                        .map(|s| s.to_string()),
390                    min_length: field_schema.get("minLength").and_then(|m| m.as_u64()),
391                    max_length: field_schema.get("maxLength").and_then(|m| m.as_u64()),
392                };
393                hints.insert(field_name.clone(), hint);
394            }
395        }
396
397        hints
398    }
399}
400
401/// Parameter hint information extracted from JSON Schema
402#[derive(Debug, Clone)]
403pub struct ParameterHint {
404    /// The parameter name
405    pub name: String,
406    /// The parameter type (string, number, boolean, etc.)
407    pub param_type: String,
408    /// Optional description of the parameter
409    pub description: Option<String>,
410    /// Whether this parameter is required
411    pub required: bool,
412    /// Default value for the parameter, if any
413    pub default_value: Option<Value>,
414    /// Allowed enum values, if the parameter is an enum
415    pub enum_values: Option<Vec<Value>>,
416    /// Format constraint (e.g., "uri", "email", "date-time")
417    pub format: Option<String>,
418    /// Regex pattern the value must match
419    pub pattern: Option<String>,
420    /// Minimum length for string values
421    pub min_length: Option<u64>,
422    /// Maximum length for string values
423    pub max_length: Option<u64>,
424}
425
426/// Convenience function for quick parameter validation
427pub fn validate_parameters(schema: &Value, params: &Value) -> ValidationResult {
428    ParameterValidator::new().validate(schema, params)
429}
430
431/// Convenience function for strict parameter validation (no transformations)
432pub fn validate_parameters_strict(schema: &Value, params: &Value) -> ValidationResult {
433    ParameterValidator::strict().validate(schema, params)
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use serde_json::json;
440
441    #[test]
442    fn test_url_auto_prefixing() {
443        let schema = json!({
444            "type": "object",
445            "properties": {
446                "url": {
447                    "type": "string",
448                    "description": "The URL to navigate to"
449                }
450            },
451            "required": ["url"]
452        });
453
454        let params = json!({"url": "www.google.com"});
455        let validator = ParameterValidator::new();
456        let result = validator.validate(&schema, &params);
457
458        assert!(result.is_valid);
459        assert_eq!(result.validated_params["url"], "https://www.google.com");
460        assert!(!result.transformations.is_empty());
461    }
462
463    #[test]
464    fn test_localhost_url_prefixing() {
465        let schema = json!({
466            "type": "object",
467            "properties": {
468                "url": {
469                    "type": "string",
470                    "description": "The URL to navigate to"
471                }
472            }
473        });
474
475        let params = json!({"url": "localhost:3000"});
476        let validator = ParameterValidator::new();
477        let result = validator.validate(&schema, &params);
478
479        assert!(result.is_valid);
480        assert_eq!(result.validated_params["url"], "http://localhost:3000");
481    }
482
483    #[test]
484    fn test_type_coercion() {
485        let schema = json!({
486            "type": "object",
487            "properties": {
488                "width": {"type": "number"},
489                "height": {"type": "integer"},
490                "visible": {"type": "boolean"}
491            }
492        });
493
494        let params = json!({
495            "width": "800.5",
496            "height": "600",
497            "visible": "true"
498        });
499
500        let validator = ParameterValidator::new();
501        let result = validator.validate(&schema, &params);
502
503        assert!(result.is_valid);
504        assert_eq!(result.validated_params["width"], 800.5);
505        assert_eq!(result.validated_params["height"], 600);
506        assert_eq!(result.validated_params["visible"], true);
507        assert_eq!(result.transformations.len(), 3);
508    }
509
510    #[test]
511    fn test_required_field_validation() {
512        let schema = json!({
513            "type": "object",
514            "properties": {
515                "url": {"type": "string"}
516            },
517            "required": ["url"]
518        });
519
520        let params = json!({});
521        let validator = ParameterValidator::new();
522        let result = validator.validate(&schema, &params);
523
524        assert!(!result.is_valid);
525        assert!(result
526            .errors
527            .iter()
528            .any(|e| matches!(e, ValidationError::MissingRequired { field } if field == "url")));
529    }
530
531    #[test]
532    fn test_strict_mode_no_transforms() {
533        let schema = json!({
534            "type": "object",
535            "properties": {
536                "url": {"type": "string"}
537            }
538        });
539
540        let params = json!({"url": "www.google.com"});
541        let validator = ParameterValidator::strict();
542        let result = validator.validate(&schema, &params);
543
544        // In strict mode, no auto-transforms should occur
545        assert_eq!(result.validated_params["url"], "www.google.com");
546        assert!(result.transformations.is_empty());
547    }
548}