Skip to main content

github_mcp/validation/
validator.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2//
3// jsonschema-based input/output validation. Schemas themselves are NOT
4// embedded directly in this file's logic — they're loaded from a bundled
5// zstd asset. Concatenating the per-version JSON before compression lets
6// zstd reuse GitHub's shared schema vocabulary and keeps the published
7// crate below crates.io's upload limit without dropping API versions.
8//
9// The asset is zstd-compressed: every operation's schema embeds a full
10// copy of the spec's `$defs` library (a deliberate generator tradeoff —
11// see `mcpify`'s `openapi::schema_resolve` — simpler than cross-schema
12// `$ref` resolution, at the cost of file size), and a long-distance-
13// matching compressor collapses that duplication (measured ~190 MB down
14// to tens of KB on a real spec) without requiring any change to the JSON
15// Schema shape itself. The bundle is decompressed transiently when a
16// version is first requested; only that version remains in the cache.
17
18use 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
36// Byte ranges in the decompressed concatenation, in bundle build order.
37// mcpify:versions:begin
38fn 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}
48// mcpify:versions:end
49
50fn 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
100/// Returns `Err` if `data` doesn't satisfy `operation_id`'s input schema,
101/// for the given `api_version`.
102pub 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
123/// Returns `Err` if `data` doesn't satisfy `operation_id`'s output schema
124/// — surfaces upstream API drift as a structured error rather than
125/// silently returning a mismatched response (architecture.md's `call`
126/// pipeline). Validates against the given `api_version`'s schema.
127pub 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    // These tests deliberately use an operationId that generated_schemas.json
153    // never declares — they exercise the "no schema found" fallback (an
154    // always-valid `{}` schema), rather than any spec-dependent content
155    // this project's own generation happened to produce.
156
157    #[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        // "not-a-real-type" isn't a recognized JSON Schema `type` value, so
229        // `jsonschema::validator_for` fails to compile it — exercising the
230        // always-valid-empty-schema fallback rather than a real validator.
231        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}