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 '{field_name}': '{original_url}' → '{fixed_url}'"
251                            ));
252                        }
253                    }
254                }
255            }
256        }
257
258        // Number type coercion
259        if let Some("number") = field_schema.get("type").and_then(|t| t.as_str()) {
260            if let Value::String(str_val) = param_value {
261                let original_str = str_val.clone();
262                if let Ok(num_val) = original_str.parse::<f64>() {
263                    *param_value = Value::Number(serde_json::Number::from_f64(num_val).unwrap());
264                    transformations.push(format!(
265                        "Converted string to number in '{field_name}': '{original_str}' → {num_val}"
266                    ));
267                }
268            }
269        }
270
271        // Integer type coercion
272        if let Some("integer") = field_schema.get("type").and_then(|t| t.as_str()) {
273            if let Value::String(str_val) = param_value {
274                let original_str = str_val.clone();
275                if let Ok(int_val) = original_str.parse::<i64>() {
276                    *param_value = Value::Number(serde_json::Number::from(int_val));
277                    transformations.push(format!(
278                        "Converted string to integer in '{field_name}': '{original_str}' → {int_val}"
279                    ));
280                }
281            }
282        }
283
284        // Boolean type coercion
285        if let Some("boolean") = field_schema.get("type").and_then(|t| t.as_str()) {
286            if let Value::String(str_val) = param_value {
287                let original_str = str_val.clone();
288                let bool_val = match original_str.to_lowercase().as_str() {
289                    "true" | "yes" | "1" | "on" => Some(true),
290                    "false" | "no" | "0" | "off" => Some(false),
291                    _ => None,
292                };
293                if let Some(bool_val) = bool_val {
294                    *param_value = Value::Bool(bool_val);
295                    transformations.push(format!(
296                        "Converted string to boolean in '{field_name}': '{original_str}' → {bool_val}"
297                    ));
298                }
299            }
300        }
301
302        Ok(transformations)
303    }
304
305    /// Auto-fix URL format issues
306    fn auto_fix_url(&self, url: &str) -> Option<String> {
307        if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
308            return None; // Already valid or empty
309        }
310
311        // Auto-prefix based on common patterns
312        if url.starts_with("localhost")
313            || url.starts_with("127.0.0.1")
314            || url.starts_with("0.0.0.0")
315        {
316            Some(format!("http://{url}"))
317        } else if url.contains('.') && !url.contains(' ') {
318            // Looks like a domain name
319            Some(format!("https://{url}"))
320        } else {
321            None
322        }
323    }
324
325    /// Check for required fields
326    fn check_required_fields(&self, schema: &Value, params: &Value) -> Result<(), ValidationError> {
327        if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
328            if let Some(params_obj) = params.as_object() {
329                for required_field in required {
330                    if let Some(field_name) = required_field.as_str() {
331                        if !params_obj.contains_key(field_name) {
332                            return Err(ValidationError::MissingRequired {
333                                field: field_name.to_string(),
334                            });
335                        }
336                    }
337                }
338            }
339        }
340        Ok(())
341    }
342
343    /// Quick validation check (returns only boolean)
344    pub fn is_valid(&self, schema: &Value, params: &Value) -> bool {
345        self.validate(schema, params).is_valid
346    }
347
348    /// Extract parameter hints from schema (for UI display)
349    pub fn extract_parameter_hints(&self, schema: &Value) -> HashMap<String, ParameterHint> {
350        let mut hints = HashMap::new();
351
352        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
353            let required_fields: Vec<String> = schema
354                .get("required")
355                .and_then(|r| r.as_array())
356                .map(|arr| {
357                    arr.iter()
358                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
359                        .collect()
360                })
361                .unwrap_or_default();
362
363            for (field_name, field_schema) in properties {
364                let hint = ParameterHint {
365                    name: field_name.clone(),
366                    param_type: field_schema
367                        .get("type")
368                        .and_then(|t| t.as_str())
369                        .unwrap_or("string")
370                        .to_string(),
371                    description: field_schema
372                        .get("description")
373                        .and_then(|d| d.as_str())
374                        .map(|s| s.to_string()),
375                    required: required_fields.contains(field_name),
376                    default_value: field_schema.get("default").cloned(),
377                    enum_values: field_schema.get("enum").and_then(|e| e.as_array()).cloned(),
378                    format: field_schema
379                        .get("format")
380                        .and_then(|f| f.as_str())
381                        .map(|s| s.to_string()),
382                    pattern: field_schema
383                        .get("pattern")
384                        .and_then(|p| p.as_str())
385                        .map(|s| s.to_string()),
386                    min_length: field_schema.get("minLength").and_then(|m| m.as_u64()),
387                    max_length: field_schema.get("maxLength").and_then(|m| m.as_u64()),
388                };
389                hints.insert(field_name.clone(), hint);
390            }
391        }
392
393        hints
394    }
395}
396
397/// Parameter hint information extracted from JSON Schema
398#[derive(Debug, Clone)]
399pub struct ParameterHint {
400    /// The parameter name
401    pub name: String,
402    /// The parameter type (string, number, boolean, etc.)
403    pub param_type: String,
404    /// Optional description of the parameter
405    pub description: Option<String>,
406    /// Whether this parameter is required
407    pub required: bool,
408    /// Default value for the parameter, if any
409    pub default_value: Option<Value>,
410    /// Allowed enum values, if the parameter is an enum
411    pub enum_values: Option<Vec<Value>>,
412    /// Format constraint (e.g., "uri", "email", "date-time")
413    pub format: Option<String>,
414    /// Regex pattern the value must match
415    pub pattern: Option<String>,
416    /// Minimum length for string values
417    pub min_length: Option<u64>,
418    /// Maximum length for string values
419    pub max_length: Option<u64>,
420}
421
422/// Convenience function for quick parameter validation
423pub fn validate_parameters(schema: &Value, params: &Value) -> ValidationResult {
424    ParameterValidator::new().validate(schema, params)
425}
426
427/// Convenience function for strict parameter validation (no transformations)
428pub fn validate_parameters_strict(schema: &Value, params: &Value) -> ValidationResult {
429    ParameterValidator::strict().validate(schema, params)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use serde_json::json;
436
437    #[test]
438    fn test_url_auto_prefixing() {
439        let schema = json!({
440            "type": "object",
441            "properties": {
442                "url": {
443                    "type": "string",
444                    "description": "The URL to navigate to"
445                }
446            },
447            "required": ["url"]
448        });
449
450        let params = json!({"url": "www.google.com"});
451        let validator = ParameterValidator::new();
452        let result = validator.validate(&schema, &params);
453
454        assert!(result.is_valid);
455        assert_eq!(result.validated_params["url"], "https://www.google.com");
456        assert!(!result.transformations.is_empty());
457    }
458
459    #[test]
460    fn test_localhost_url_prefixing() {
461        let schema = json!({
462            "type": "object",
463            "properties": {
464                "url": {
465                    "type": "string",
466                    "description": "The URL to navigate to"
467                }
468            }
469        });
470
471        let params = json!({"url": "localhost:3000"});
472        let validator = ParameterValidator::new();
473        let result = validator.validate(&schema, &params);
474
475        assert!(result.is_valid);
476        assert_eq!(result.validated_params["url"], "http://localhost:3000");
477    }
478
479    #[test]
480    fn test_type_coercion() {
481        let schema = json!({
482            "type": "object",
483            "properties": {
484                "width": {"type": "number"},
485                "height": {"type": "integer"},
486                "visible": {"type": "boolean"}
487            }
488        });
489
490        let params = json!({
491            "width": "800.5",
492            "height": "600",
493            "visible": "true"
494        });
495
496        let validator = ParameterValidator::new();
497        let result = validator.validate(&schema, &params);
498
499        assert!(result.is_valid);
500        assert_eq!(result.validated_params["width"], 800.5);
501        assert_eq!(result.validated_params["height"], 600);
502        assert_eq!(result.validated_params["visible"], true);
503        assert_eq!(result.transformations.len(), 3);
504    }
505
506    #[test]
507    fn test_required_field_validation() {
508        let schema = json!({
509            "type": "object",
510            "properties": {
511                "url": {"type": "string"}
512            },
513            "required": ["url"]
514        });
515
516        let params = json!({});
517        let validator = ParameterValidator::new();
518        let result = validator.validate(&schema, &params);
519
520        assert!(!result.is_valid);
521        assert!(result
522            .errors
523            .iter()
524            .any(|e| matches!(e, ValidationError::MissingRequired { field } if field == "url")));
525    }
526
527    #[test]
528    fn test_strict_mode_no_transforms() {
529        let schema = json!({
530            "type": "object",
531            "properties": {
532                "url": {"type": "string"}
533            }
534        });
535
536        let params = json!({"url": "www.google.com"});
537        let validator = ParameterValidator::strict();
538        let result = validator.validate(&schema, &params);
539
540        // In strict mode, no auto-transforms should occur
541        assert_eq!(result.validated_params["url"], "www.google.com");
542        assert!(result.transformations.is_empty());
543    }
544}