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!("generated_schemas_vghec-2026-03-10.json.zst")),
43 "ghes-3.21" => Some(include_bytes!("generated_schemas_vghes-3.21.json.zst")),
44 "ghes-3.20" => Some(include_bytes!("generated_schemas_vghes-3.20.json.zst")),
45 "ghes-3.19" => Some(include_bytes!("generated_schemas_vghes-3.19.json.zst")),
46 "ghes-3.18" => Some(include_bytes!("generated_schemas_vghes-3.18.json.zst")),
47 "ghes-3.17" => Some(include_bytes!("generated_schemas_vghes-3.17.json.zst")),
48 "ghes-3.16" => Some(include_bytes!("generated_schemas_vghes-3.16.json.zst")),
49 "ghes-3.15" => Some(include_bytes!("generated_schemas_vghes-3.15.json.zst")),
50 "ghes-3.14" => Some(include_bytes!("generated_schemas_vghes-3.14.json.zst")),
51 "ghes-3.13" => Some(include_bytes!("generated_schemas_vghes-3.13.json.zst")),
52 "ghes-3.12" => Some(include_bytes!("generated_schemas_vghes-3.12.json.zst")),
53 "ghes-3.11" => Some(include_bytes!("generated_schemas_vghes-3.11.json.zst")),
54 "ghes-3.10" => Some(include_bytes!("generated_schemas_vghes-3.10.json.zst")),
55 "ghes-3.9" => Some(include_bytes!("generated_schemas_vghes-3.9.json.zst")),
56 "ghes-3.8" => Some(include_bytes!("generated_schemas_vghes-3.8.json.zst")),
57 "ghes-3.7" => Some(include_bytes!("generated_schemas_vghes-3.7.json.zst")),
58 "ghes-3.6" => Some(include_bytes!("generated_schemas_vghes-3.6.json.zst")),
59 "ghes-3.5" => Some(include_bytes!("generated_schemas_vghes-3.5.json.zst")),
60 "ghes-3.4" => Some(include_bytes!("generated_schemas_vghes-3.4.json.zst")),
61 "ghes-3.3" => Some(include_bytes!("generated_schemas_vghes-3.3.json.zst")),
62 "ghes-3.2" => Some(include_bytes!("generated_schemas_vghes-3.2.json.zst")),
63 "ghes-3.1" => Some(include_bytes!("generated_schemas_vghes-3.1.json.zst")),
64 "ghes-3.0" => Some(include_bytes!("generated_schemas_vghes-3.0.json.zst")),
65 "ghes-2.22" => Some(include_bytes!("generated_schemas_vghes-2.22.json.zst")),
66 "ghes-2.21" => Some(include_bytes!("generated_schemas_vghes-2.21.json.zst")),
67 "ghes-2.20" => Some(include_bytes!("generated_schemas_vghes-2.20.json.zst")),
68 "ghes-2.19" => Some(include_bytes!("generated_schemas_vghes-2.19.json.zst")),
69 "ghes-2.18" => Some(include_bytes!("generated_schemas_vghes-2.18.json.zst")),
70 _ => None,
71 }
72}
73fn schemas_by_operation(api_version: &str) -> &'static HashMap<String, OperationSchemas> {
76 static SCHEMAS: OnceLock<Mutex<HashMap<String, &'static HashMap<String, OperationSchemas>>>> =
77 OnceLock::new();
78 static EMPTY: OnceLock<HashMap<String, OperationSchemas>> = OnceLock::new();
79 let empty = EMPTY.get_or_init(HashMap::new);
80
81 let cache = SCHEMAS.get_or_init(|| Mutex::new(HashMap::new()));
82 let mut cache = cache.lock().unwrap();
83 if let Some(schemas) = cache.get(api_version) {
84 return schemas;
85 }
86
87 let Some(compressed) = schemas_zst_for(api_version) else {
88 return empty;
89 };
90 let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(compressed)
91 .ok()
92 .and_then(|json| serde_json::from_slice(&json).ok())
93 .unwrap_or_default();
94 let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
95 cache.insert(api_version.to_string(), leaked);
96 leaked
97}
98
99fn validate_against(
100 cache: &Mutex<HashMap<String, jsonschema::Validator>>,
101 operation_id: &str,
102 schema: &Value,
103 data: &Value,
104) -> Result<(), Vec<String>> {
105 let mut cache = cache.lock().unwrap();
106 let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
107 jsonschema::validator_for(schema).unwrap_or_else(|_| {
108 jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
109 })
110 });
111
112 if validator.is_valid(data) {
113 return Ok(());
114 }
115 let errors = validator
116 .iter_errors(data)
117 .map(|error| error.to_string())
118 .collect::<Vec<_>>();
119 Err(errors)
120}
121
122pub fn validate_input(
125 api_version: &str,
126 operation_id: &str,
127 data: &Value,
128) -> Result<(), McpifyError> {
129 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
130 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
131
132 let empty = Value::Object(Default::default());
133 let schema = schemas_by_operation(api_version)
134 .get(operation_id)
135 .map(|schemas| &schemas.input_schema)
136 .unwrap_or(&empty);
137
138 let cache_key = format!("{api_version} {operation_id}");
139 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
140 message: format!("invalid input for '{operation_id}'"),
141 details: Some(serde_json::json!(errors)),
142 })
143}
144
145pub fn validate_output(
150 api_version: &str,
151 operation_id: &str,
152 data: &Value,
153) -> Result<(), McpifyError> {
154 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
155 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
156
157 let empty = Value::Object(Default::default());
158 let schema = schemas_by_operation(api_version)
159 .get(operation_id)
160 .map(|schemas| &schemas.output_schema)
161 .unwrap_or(&empty);
162
163 let cache_key = format!("{api_version} {operation_id}");
164 validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
165 message: format!("unexpected response shape for '{operation_id}'"),
166 details: Some(serde_json::json!(errors)),
167 })
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
180 fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
181 assert!(
182 validate_input(
183 "gh-2026-03-10",
184 "__unknown_operation__",
185 &serde_json::json!({"anything": true})
186 )
187 .is_ok()
188 );
189 assert!(
190 validate_output(
191 "gh-2026-03-10",
192 "__unknown_operation__",
193 &serde_json::json!(42)
194 )
195 .is_ok()
196 );
197 }
198
199 #[test]
200 fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
201 assert!(
202 validate_input(
203 "__unknown_version__",
204 "__unknown_operation__",
205 &serde_json::json!({"anything": true})
206 )
207 .is_ok()
208 );
209 }
210
211 #[test]
212 fn validate_against_reports_every_schema_violation() {
213 static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
214 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
215 let schema = serde_json::json!({
216 "type": "object",
217 "required": ["name"],
218 "properties": { "name": { "type": "string" } },
219 });
220
221 let ok = validate_against(
222 cache,
223 "test_op_ok",
224 &schema,
225 &serde_json::json!({"name": "widget"}),
226 );
227 assert!(ok.is_ok());
228
229 let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
230 assert!(err.is_err());
231 }
232}