github_mcp/validation/
validator.rs1use std::collections::HashMap;
19use std::sync::{Mutex, OnceLock};
20
21use serde::Deserialize;
22use serde_json::Value;
23
24use crate::core::errors::McpifyError;
25
26#[derive(Debug, Deserialize)]
27struct OperationSchemas {
28 #[serde(rename = "inputSchema")]
29 input_schema: Value,
30 #[serde(rename = "outputSchema")]
31 output_schema: Value,
32}
33
34const SCHEMAS_BUNDLE: &[u8] = include_bytes!("generated_schemas_bundle.json.zst");
35
36fn schemas_range_for(api_version: &str) -> Option<std::ops::Range<usize>> {
39 match api_version {
40 "gh-2026-03-10" => Some(0..6_881_701),
41 "ghec-2026-03-10" => Some(6_881_701..14_474_447),
42 "ghes-3.21" => Some(14_474_447..20_865_307),
43 "ghes-3.20" => Some(20_865_307..26_961_926),
44 "ghes-3.19" => Some(26_961_926..32_715_723),
45 _ => None,
46 }
47}
48fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
51 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
52 OnceLock::new();
53 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
54 let empty = EMPTY.get_or_init(HashMap::new);
55
56 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
57 let mut cache = cache.lock().unwrap();
58 if let Some(schemas) = cache.get(api_version) {
59 return schemas;
60 }
61
62 let Some(range) = schemas_range_for(api_version) else {
63 return empty;
64 };
65 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(SCHEMAS_BUNDLE)
66 .ok()
67 .and_then(|json| {
68 json.get(range)
69 .and_then(|slice| serde_json::from_slice(slice).ok())
70 })
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 bundled_schemas_decode_for_every_known_version() {
191 for version in [
192 "gh-2026-03-10",
193 "ghec-2026-03-10",
194 "ghes-3.21",
195 "ghes-3.20",
196 "ghes-3.19",
197 ] {
198 assert!(!schemas_by_operation(version).is_empty(), "{version}");
199 }
200 }
201
202 #[test]
203 fn validate_against_reports_every_schema_violation() {
204 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
205 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
206 let schema = serde_json::json!({
207 "type": "object",
208 "required": ["name"],
209 "properties": { "name": { "type": "string" } },
210 });
211
212 let ok = validate_against(
213 cache,
214 "test_op_ok",
215 &schema,
216 &serde_json::json!({"name": "widget"}),
217 );
218 assert!(ok.is_ok());
219
220 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
221 assert!(err.is_err());
222 }
223
224 #[test]
225 fn validate_against_falls_back_to_an_always_valid_schema_when_compilation_fails() {
226 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
227 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
228 let uncompilable_schema = serde_json::json!({"type": "not-a-real-type"});
232
233 let result = validate_against(
234 cache,
235 "test_op_uncompilable_schema",
236 &uncompilable_schema,
237 &serde_json::json!({"anything": true}),
238 );
239 assert!(result.is_ok());
240 }
241}