github_mcp/validation/
validator.rs1use std::collections::HashMap;
23use std::sync::{Mutex, OnceLock};
24
25use serde::Deserialize;
26use serde_json::Value;
27
28use crate::core::errors::McpifyError;
29
30#[derive(Debug, Deserialize)]
31struct OperationSchemas {
32 #[serde(rename = "inputSchema")]
33 input_schema: Value,
34 #[serde(rename = "outputSchema")]
35 output_schema: Value,
36}
37
38fn schemas_zst_for(api_version: &str) -> Option<&'static [u8]> {
40 match api_version {
41 "gh-2026-03-10" => Some(include_bytes!("generated_schemas.json.zst")),
42 "ghec-2026-03-10" => Some(include_bytes!(
43 "generated_schemas_vghec-2026-03-10.json.zst"
44 )),
45 "ghes-3.21" => Some(include_bytes!("generated_schemas_vghes-3.21.json.zst")),
46 "ghes-3.20" => Some(include_bytes!("generated_schemas_vghes-3.20.json.zst")),
47 "ghes-3.19" => Some(include_bytes!("generated_schemas_vghes-3.19.json.zst")),
48 _ => None,
49 }
50}
51fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
54 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
55 OnceLock::new();
56 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
57 let empty = EMPTY.get_or_init(HashMap::new);
58
59 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
60 let mut cache = cache.lock().unwrap();
61 if let Some(schemas) = cache.get(api_version) {
62 return schemas;
63 }
64
65 let Some(compressed) = schemas_zst_for(api_version) else {
66 return empty;
67 };
68 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(compressed)
69 .ok()
70 .and_then(|json| serde_json::from_slice(&json).ok())
71 .unwrap_or_default();
72 let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
73 cache.insert(api_version.to_string(), leaked);
74 leaked
75}
76
77fn validate_against(
78 cache: &Mutex<HashMap<String, jsonschema::Validator>>,
79 operation_id: &str,
80 schema: &Value,
81 data: &Value,
82) -> Result<(), Vec<String>> {
83 let mut cache = cache.lock().unwrap();
84 let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
85 jsonschema::validator_for(schema).unwrap_or_else(|_| {
86 jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
87 })
88 });
89
90 if validator.is_valid(data) {
91 return Ok(());
92 }
93 let errors = validator
94 .iter_errors(data)
95 .map(|error| error.to_string())
96 .collect::<Vec<_>>();
97 Err(errors)
98}
99
100pub fn validate_input(
103 api_version: &str,
104 operation_id: &str,
105 data: &Value,
106) -> Result<(), McpifyError> {
107 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
108 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
109
110 let empty = Value::Object(Default::default());
111 let schema = schemas_by_operation(api_version)
112 .get(operation_id)
113 .map(|schemas| &schemas.input_schema)
114 .unwrap_or(&empty);
115
116 let cache_key = format!("{api_version} {operation_id}");
117 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
118 message: format!("invalid input for '{operation_id}'"),
119 details: Some(serde_json::json!(errors)),
120 })
121}
122
123pub fn validate_output(
128 api_version: &str,
129 operation_id: &str,
130 data: &Value,
131) -> Result<(), McpifyError> {
132 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
133 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
134
135 let empty = Value::Object(Default::default());
136 let schema = schemas_by_operation(api_version)
137 .get(operation_id)
138 .map(|schemas| &schemas.output_schema)
139 .unwrap_or(&empty);
140
141 let cache_key = format!("{api_version} {operation_id}");
142 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
143 message: format!("unexpected response shape for '{operation_id}'"),
144 details: Some(serde_json::json!(errors)),
145 })
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
158 fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
159 assert!(
160 validate_input(
161 "gh-2026-03-10",
162 "__unknown_operation__",
163 &serde_json::json!({"anything": true})
164 )
165 .is_ok()
166 );
167 assert!(
168 validate_output(
169 "gh-2026-03-10",
170 "__unknown_operation__",
171 &serde_json::json!(42)
172 )
173 .is_ok()
174 );
175 }
176
177 #[test]
178 fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
179 assert!(
180 validate_input(
181 "__unknown_version__",
182 "__unknown_operation__",
183 &serde_json::json!({"anything": true})
184 )
185 .is_ok()
186 );
187 }
188
189 #[test]
190 fn validate_against_reports_every_schema_violation() {
191 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
192 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
193 let schema = serde_json::json!({
194 "type": "object",
195 "required": ["name"],
196 "properties": { "name": { "type": "string" } },
197 });
198
199 let ok = validate_against(
200 cache,
201 "test_op_ok",
202 &schema,
203 &serde_json::json!({"name": "widget"}),
204 );
205 assert!(ok.is_ok());
206
207 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
208 assert!(err.is_err());
209 }
210}