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
5// per-version co-located generated_schemas(_v<label>).json.zst asset
6// (written directly by the Rust generator, not templated, keeping this
7// file's size independent of how many operations the source spec
8// declares) via `include_bytes!` per known version, so each is baked into
9// the compiled binary rather than read from disk at runtime. Because of
10// that, a project rebuild (`cargo build`) is required after `mcpify
11// add-version` for a new version's schemas to actually take effect here.
12//
13// The asset is zstd-compressed: every operation's schema embeds a full
14// copy of the spec's `$defs` library (a deliberate generator tradeoff —
15// see `mcpify`'s `openapi::schema_resolve` — simpler than cross-schema
16// `$ref` resolution, at the cost of file size), and a long-distance-
17// matching compressor collapses that duplication (measured ~190 MB down
18// to tens of KB on a real spec) without requiring any change to the JSON
19// Schema shape itself. Decompressed once per version, lazily, into the
20// same cache below.
21
22use 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
38// mcpify:versions:begin
39fn 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}
73// mcpify:versions:end
74
75fn 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
122/// Returns `Err` if `data` doesn't satisfy `operation_id`'s input schema,
123/// for the given `api_version`.
124pub 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
145/// Returns `Err` if `data` doesn't satisfy `operation_id`'s output schema
146/// — surfaces upstream API drift as a structured error rather than
147/// silently returning a mismatched response (architecture.md's `call`
148/// pipeline). Validates against the given `api_version`'s schema.
149pub 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    // These tests deliberately use an operationId that generated_schemas.json
175    // never declares — they exercise the "no schema found" fallback (an
176    // always-valid `{}` schema), rather than any spec-dependent content
177    // this project's own generation happened to produce.
178
179    #[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}