Skip to content

Commit 080c208

Browse files
committed
feat(mcp-test-harness): implement comprehensive JSON schema validation
- Replace placeholder ResponseValidator with real schema validation using SchemaValidator - Add JSONPath field validation using jsonpath_lib for precise field checking - Implement comprehensive error expectation validation (error codes, messages) - Add regex pattern validation for string fields with caching - Support inline schemas and schema files for response validation - Implement real TestValidator with JSON-RPC 2.0 compliance checking - Add detailed validation error messages and warnings - Support numeric range validation (min/max) for field values - Add type checking for field validation (string, number, boolean, etc.) - Comprehensive MCP protocol validation for standard methods closes #122
1 parent 6a65b32 commit 080c208

2 files changed

Lines changed: 431 additions & 16 deletions

File tree

crates/mcp-test-harness/src/spec/validator.rs

Lines changed: 329 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,357 @@
11
//! Response validation for MCP test harness
22
3-
use super::schema::{ExpectedOutput, FieldValidation};
3+
use super::schema::{ExpectedOutput, FieldValidation, SchemaValidator};
44
use crate::types::ValidationResult;
5+
use jsonpath_lib as jsonpath;
6+
use regex::Regex;
7+
use serde_json::Value;
8+
use std::collections::HashMap;
59

610
/// Response validator for test expectations
711
pub struct ResponseValidator {
8-
// FUTURE: Add JSONPath evaluation, pattern matching for enhanced validation (tracked in #122)
12+
/// Schema validator for JSON schema-based validation
13+
schema_validator: SchemaValidator,
14+
/// Compiled regex patterns cache
15+
regex_cache: HashMap<String, Regex>,
916
}
1017

1118
impl ResponseValidator {
1219
/// Create a new response validator
1320
pub fn new() -> Self {
14-
Self {}
21+
Self {
22+
schema_validator: SchemaValidator::new(),
23+
regex_cache: HashMap::new(),
24+
}
25+
}
26+
27+
/// Create a response validator with a base directory for schema files
28+
pub fn with_base_dir<P: AsRef<std::path::Path>>(base_dir: P) -> Self {
29+
Self {
30+
schema_validator: SchemaValidator::with_base_dir(base_dir),
31+
regex_cache: HashMap::new(),
32+
}
1533
}
1634

1735
/// Validate a response against expected output specification
1836
pub fn validate_response(
37+
&mut self,
38+
response: &serde_json::Value,
39+
expected: &ExpectedOutput,
40+
) -> ValidationResult {
41+
let mut errors = Vec::new();
42+
let mut warnings = Vec::new();
43+
44+
// 1. Validate error expectation
45+
let error_validation = self.validate_error_expectation(response, expected);
46+
if !error_validation.valid {
47+
errors.extend(error_validation.errors);
48+
}
49+
warnings.extend(error_validation.warnings);
50+
51+
// 2. Validate against JSON schema if provided
52+
if let Some(schema_validation) = self.validate_schema(response, expected) {
53+
if !schema_validation.valid {
54+
errors.extend(schema_validation.errors);
55+
}
56+
warnings.extend(schema_validation.warnings);
57+
}
58+
59+
// 3. Validate specific fields
60+
for field in &expected.fields {
61+
let field_validation = self.validate_field(response, field);
62+
if !field_validation.valid {
63+
errors.extend(field_validation.errors);
64+
}
65+
warnings.extend(field_validation.warnings);
66+
}
67+
68+
// 4. Check for extra fields if not allowed
69+
if !expected.allow_extra_fields {
70+
let extra_validation = self.validate_no_extra_fields(response, expected);
71+
if !extra_validation.valid {
72+
errors.extend(extra_validation.errors);
73+
}
74+
warnings.extend(extra_validation.warnings);
75+
}
76+
77+
if errors.is_empty() {
78+
let mut result = ValidationResult::success();
79+
for warning in warnings {
80+
result = result.with_warning(warning);
81+
}
82+
result
83+
} else {
84+
let mut result = ValidationResult::error(errors.join("; "));
85+
for warning in warnings {
86+
result = result.with_warning(warning);
87+
}
88+
result
89+
}
90+
}
91+
92+
/// Validate error expectation (whether error should or shouldn't occur)
93+
fn validate_error_expectation(
1994
&self,
20-
_response: &serde_json::Value,
21-
_expected: &ExpectedOutput,
95+
response: &Value,
96+
expected: &ExpectedOutput,
2297
) -> ValidationResult {
23-
// FUTURE: Implement comprehensive response validation (tracked in #122)
98+
let has_error = response.get("error").is_some();
99+
100+
if expected.error && !has_error {
101+
return ValidationResult::error("Expected error response but got success");
102+
}
103+
104+
if !expected.error && has_error {
105+
return ValidationResult::error("Expected success response but got error");
106+
}
107+
108+
// If error is expected, validate error details
109+
if expected.error && has_error {
110+
let error_obj = response.get("error").unwrap();
111+
112+
// Validate error code if specified
113+
if let Some(expected_code) = expected.error_code {
114+
if let Some(code) = error_obj.get("code").and_then(|c| c.as_i64()) {
115+
if code != expected_code as i64 {
116+
return ValidationResult::error(format!(
117+
"Expected error code {} but got {}",
118+
expected_code, code
119+
));
120+
}
121+
} else {
122+
return ValidationResult::error("Error response missing 'code' field");
123+
}
124+
}
125+
126+
// Validate error message contains expected text
127+
if let Some(expected_message) = &expected.error_message_contains {
128+
if let Some(message) = error_obj.get("message").and_then(|m| m.as_str()) {
129+
if !message.contains(expected_message) {
130+
return ValidationResult::error(format!(
131+
"Error message '{}' does not contain expected text '{}'",
132+
message, expected_message
133+
));
134+
}
135+
} else {
136+
return ValidationResult::error("Error response missing 'message' field");
137+
}
138+
}
139+
}
140+
24141
ValidationResult::success()
25142
}
26143

27-
/// Validate a specific field
144+
/// Validate response against JSON schema
145+
fn validate_schema(
146+
&mut self,
147+
response: &Value,
148+
expected: &ExpectedOutput,
149+
) -> Option<ValidationResult> {
150+
// Try schema file first, then inline schema
151+
if let Some(schema_file) = &expected.schema_file {
152+
match self.schema_validator.validate(response, schema_file) {
153+
Ok(_) => Some(ValidationResult::success()),
154+
Err(e) => Some(ValidationResult::error(format!(
155+
"Schema validation failed: {}",
156+
e
157+
))),
158+
}
159+
} else if let Some(schema) = &expected.schema {
160+
match self.schema_validator.validate_inline(response, schema) {
161+
Ok(_) => Some(ValidationResult::success()),
162+
Err(e) => Some(ValidationResult::error(format!(
163+
"Inline schema validation failed: {}",
164+
e
165+
))),
166+
}
167+
} else {
168+
None
169+
}
170+
}
171+
172+
/// Validate a specific field using JSONPath
28173
pub fn validate_field(
174+
&mut self,
175+
response: &Value,
176+
field: &FieldValidation,
177+
) -> ValidationResult {
178+
// Extract field value using JSONPath
179+
let field_values = match jsonpath::select(response, &field.path) {
180+
Ok(values) => values,
181+
Err(e) => {
182+
if field.required {
183+
return ValidationResult::error(format!(
184+
"Required field '{}' not found: {}",
185+
field.path, e
186+
));
187+
} else {
188+
return ValidationResult::success(); // Optional field not found is OK
189+
}
190+
}
191+
};
192+
193+
// Check if field is required but not found
194+
if field.required && field_values.is_empty() {
195+
return ValidationResult::error(format!("Required field '{}' not found", field.path));
196+
}
197+
198+
// If optional field not found, validation passes
199+
if field_values.is_empty() {
200+
return ValidationResult::success();
201+
}
202+
203+
// Validate each found value
204+
let mut errors = Vec::new();
205+
let mut warnings = Vec::new();
206+
207+
for value in field_values {
208+
let field_validation = self.validate_field_value(value, field);
209+
if !field_validation.valid {
210+
errors.extend(field_validation.errors);
211+
}
212+
warnings.extend(field_validation.warnings);
213+
}
214+
215+
if errors.is_empty() {
216+
let mut result = ValidationResult::success();
217+
for warning in warnings {
218+
result = result.with_warning(warning);
219+
}
220+
result
221+
} else {
222+
let mut result = ValidationResult::error(errors.join("; "));
223+
for warning in warnings {
224+
result = result.with_warning(warning);
225+
}
226+
result
227+
}
228+
}
229+
230+
/// Validate a single field value against field validation rules
231+
fn validate_field_value(&mut self, value: &Value, field: &FieldValidation) -> ValidationResult {
232+
// Check exact value match
233+
if let Some(expected_value) = &field.value {
234+
if value != expected_value {
235+
return ValidationResult::error(format!(
236+
"Field '{}' expected value {:?} but got {:?}",
237+
field.path, expected_value, value
238+
));
239+
}
240+
}
241+
242+
// Check field type
243+
if let Some(expected_type) = &field.field_type {
244+
if !self.check_field_type(value, expected_type) {
245+
return ValidationResult::error(format!(
246+
"Field '{}' expected type '{}' but got type '{}'",
247+
field.path,
248+
expected_type,
249+
self.get_value_type(value)
250+
));
251+
}
252+
}
253+
254+
// Check pattern for strings
255+
if let Some(pattern) = &field.pattern {
256+
if let Some(string_value) = value.as_str() {
257+
match self.get_or_compile_regex(pattern) {
258+
Ok(regex) => {
259+
if !regex.is_match(string_value) {
260+
return ValidationResult::error(format!(
261+
"Field '{}' value '{}' does not match pattern '{}'",
262+
field.path, string_value, pattern
263+
));
264+
}
265+
}
266+
Err(e) => {
267+
return ValidationResult::error(format!(
268+
"Invalid regex pattern '{}': {}",
269+
pattern, e
270+
));
271+
}
272+
}
273+
} else {
274+
return ValidationResult::error(format!(
275+
"Field '{}' pattern validation requires string value, got {}",
276+
field.path,
277+
self.get_value_type(value)
278+
));
279+
}
280+
}
281+
282+
// Check numeric ranges
283+
if let Some(min_val) = field.min {
284+
if let Some(num_value) = value.as_f64() {
285+
if num_value < min_val {
286+
return ValidationResult::error(format!(
287+
"Field '{}' value {} is less than minimum {}",
288+
field.path, num_value, min_val
289+
));
290+
}
291+
}
292+
}
293+
294+
if let Some(max_val) = field.max {
295+
if let Some(num_value) = value.as_f64() {
296+
if num_value > max_val {
297+
return ValidationResult::error(format!(
298+
"Field '{}' value {} is greater than maximum {}",
299+
field.path, num_value, max_val
300+
));
301+
}
302+
}
303+
}
304+
305+
ValidationResult::success()
306+
}
307+
308+
/// Check if extra fields are present when they shouldn't be
309+
fn validate_no_extra_fields(
29310
&self,
30-
_response: &serde_json::Value,
31-
_field: &FieldValidation,
311+
_response: &Value,
312+
_expected: &ExpectedOutput,
32313
) -> ValidationResult {
33-
// FUTURE: Implement field validation using JSONPath (tracked in #122)
314+
// FUTURE: Implement comprehensive extra field validation (tracked in #124)
315+
// This would require comparing response fields against expected schema structure.
316+
// Currently, we rely on JSON schema validation to catch unauthorized extra fields.
34317
ValidationResult::success()
35318
}
319+
320+
/// Check if a value matches the expected type
321+
fn check_field_type(&self, value: &Value, expected_type: &str) -> bool {
322+
match expected_type.to_lowercase().as_str() {
323+
"string" => value.is_string(),
324+
"number" | "numeric" => value.is_number(),
325+
"integer" | "int" => value.is_i64() || value.is_u64(),
326+
"float" | "double" => value.is_f64(),
327+
"boolean" | "bool" => value.is_boolean(),
328+
"array" => value.is_array(),
329+
"object" => value.is_object(),
330+
"null" => value.is_null(),
331+
_ => false, // Unknown type
332+
}
333+
}
334+
335+
/// Get the type name of a JSON value
336+
fn get_value_type(&self, value: &Value) -> &'static str {
337+
match value {
338+
Value::String(_) => "string",
339+
Value::Number(_) => "number",
340+
Value::Bool(_) => "boolean",
341+
Value::Array(_) => "array",
342+
Value::Object(_) => "object",
343+
Value::Null => "null",
344+
}
345+
}
346+
347+
/// Get or compile a regex pattern, using cache for performance
348+
fn get_or_compile_regex(&mut self, pattern: &str) -> Result<&Regex, regex::Error> {
349+
if !self.regex_cache.contains_key(pattern) {
350+
let regex = Regex::new(pattern)?;
351+
self.regex_cache.insert(pattern.to_string(), regex);
352+
}
353+
Ok(self.regex_cache.get(pattern).unwrap())
354+
}
36355
}
37356

38357
impl Default for ResponseValidator {

0 commit comments

Comments
 (0)