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..9_892_489),
41 "ghec-2026-03-10" => Some(9_892_489..20_491_342),
42 "ghes-3.21" => Some(20_491_342..31_436_856),
43 "ghes-3.20" => Some(31_436_856..41_925_923),
44 _ => None,
45 }
46}
47fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
50 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
51 OnceLock::new();
52 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
53 let empty = EMPTY.get_or_init(HashMap::new);
54
55 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
56 let mut cache = cache.lock().unwrap();
57 if let Some(schemas) = cache.get(api_version) {
58 return schemas;
59 }
60
61 let Some(range) = schemas_range_for(api_version) else {
62 return empty;
63 };
64 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(SCHEMAS_BUNDLE)
65 .ok()
66 .and_then(|json| {
67 json.get(range)
68 .and_then(|slice| serde_json::from_slice(slice).ok())
69 })
70 .unwrap_or_default();
71 let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
72 cache.insert(api_version.to_string(), leaked);
73 leaked
74}
75
76fn validate_against(
77 cache: &Mutex<HashMap<String, jsonschema::Validator>>,
78 operation_id: &str,
79 schema: &Value,
80 data: &Value,
81) -> Result<(), Vec<String>> {
82 let mut cache = cache.lock().unwrap();
83 let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
84 jsonschema::validator_for(schema).unwrap_or_else(|_| {
85 jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
86 })
87 });
88
89 if validator.is_valid(data) {
90 return Ok(());
91 }
92 let errors = validator
93 .iter_errors(data)
94 .map(|error| error.to_string())
95 .collect::<Vec<_>>();
96 Err(errors)
97}
98
99pub fn validate_input(
102 api_version: &str,
103 operation_id: &str,
104 data: &Value,
105) -> Result<(), McpifyError> {
106 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
107 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
108
109 let empty = Value::Object(Default::default());
110 let schema = schemas_by_operation(api_version)
111 .get(operation_id)
112 .map(|schemas| &schemas.input_schema)
113 .unwrap_or(&empty);
114
115 let cache_key = format!("{api_version} {operation_id}");
116 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
117 message: format!("invalid input for '{operation_id}'"),
118 details: Some(serde_json::json!(errors)),
119 })
120}
121
122pub fn validate_output(
127 api_version: &str,
128 operation_id: &str,
129 data: &Value,
130) -> Result<(), McpifyError> {
131 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
132 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
133
134 let empty = Value::Object(Default::default());
135 let schema = schemas_by_operation(api_version)
136 .get(operation_id)
137 .map(|schemas| &schemas.output_schema)
138 .unwrap_or(&empty);
139
140 let cache_key = format!("{api_version} {operation_id}");
141 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
142 message: format!("unexpected response shape for '{operation_id}'"),
143 details: Some(serde_json::json!(errors)),
144 })
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
157 fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
158 assert!(
159 validate_input(
160 "gh-2026-03-10",
161 "__unknown_operation__",
162 &serde_json::json!({"anything": true})
163 )
164 .is_ok()
165 );
166 assert!(
167 validate_output(
168 "gh-2026-03-10",
169 "__unknown_operation__",
170 &serde_json::json!(42)
171 )
172 .is_ok()
173 );
174 }
175
176 #[test]
177 fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
178 assert!(
179 validate_input(
180 "__unknown_version__",
181 "__unknown_operation__",
182 &serde_json::json!({"anything": true})
183 )
184 .is_ok()
185 );
186 }
187
188 #[test]
189 fn bundled_schemas_decode_for_every_known_version() {
190 for version in ["gh-2026-03-10", "ghec-2026-03-10", "ghes-3.21", "ghes-3.20"] {
191 assert!(!schemas_by_operation(version).is_empty(), "{version}");
192 }
193 }
194
195 #[test]
196 fn validate_against_reports_every_schema_violation() {
197 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
198 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
199 let schema = serde_json::json!({
200 "type": "object",
201 "required": ["name"],
202 "properties": { "name": { "type": "string" } },
203 });
204
205 let ok = validate_against(
206 cache,
207 "test_op_ok",
208 &schema,
209 &serde_json::json!({"name": "widget"}),
210 );
211 assert!(ok.is_ok());
212
213 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
214 assert!(err.is_err());
215 }
216
217 #[test]
218 fn validate_against_falls_back_to_an_always_valid_schema_when_compilation_fails() {
219 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
220 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
221 let uncompilable_schema = serde_json::json!({"type": "not-a-real-type"});
225
226 let result = validate_against(
227 cache,
228 "test_op_uncompilable_schema",
229 &uncompilable_schema,
230 &serde_json::json!({"anything": true}),
231 );
232 assert!(result.is_ok());
233 }
234}