Skip to main content

sqlserver_mcp_catalog/validation/
validator.rs

1// SQL Server 2025 - master/msdb/sandbox combined catalog 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        "2025" => Some(include_bytes!("generated_schemas.json.zst")),
42        "2022" => Some(include_bytes!("generated_schemas_v2022.json.zst")),
43        "2019" => Some(include_bytes!("generated_schemas_v2019.json.zst")),
44        "2017" => Some(include_bytes!("generated_schemas_v2017.json.zst")),
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(compressed) = schemas_zst_for(api_version) else {
63        return empty;
64    };
65    let parsed: HashMap<String, OperationSchemas> = zstd::decode_all(compressed)
66        .ok()
67        .and_then(|json| serde_json::from_slice(&json).ok())
68        .unwrap_or_default();
69    let leaked: &'static HashMap<String, OperationSchemas> = Box::leak(Box::new(parsed));
70    cache.insert(api_version.to_string(), leaked);
71    leaked
72}
73
74/// Returns the fully-`$defs`-resolved input/output JSON Schema for
75/// `operation_id` under `api_version`, if known — the same asset
76/// `validate_input`/`validate_output` validate against, exposed directly so
77/// `services::api_client` can read declared parameter/column names and
78/// `x-sql-type` values off it to build the actual T-SQL call, rather than
79/// re-deriving that from `EndpointRecord.input_schema`'s unresolved
80/// `$ref`s (see `data::store::ENDPOINT_COLUMNS` — that column stores the
81/// spec's `requestBody` as written, `$ref` and all).
82pub fn resolved_schemas_for(
83    api_version: &str,
84    operation_id: &str,
85) -> Option<(&'static Value, &'static Value)> {
86    schemas_by_operation(api_version)
87        .get(operation_id)
88        .map(|schemas| (&schemas.input_schema, &schemas.output_schema))
89}
90
91fn validate_against(
92    cache: &Mutex<HashMap<String, jsonschema::Validator>>,
93    operation_id: &str,
94    schema: &Value,
95    data: &Value,
96) -> Result<(), Vec<String>> {
97    let mut cache = cache.lock().unwrap();
98    let validator = cache.entry(operation_id.to_string()).or_insert_with(|| {
99        jsonschema::validator_for(schema).unwrap_or_else(|_| {
100            jsonschema::validator_for(&Value::Object(Default::default())).unwrap()
101        })
102    });
103
104    if validator.is_valid(data) {
105        return Ok(());
106    }
107    let errors = validator
108        .iter_errors(data)
109        .map(|error| error.to_string())
110        .collect::<Vec<_>>();
111    Err(errors)
112}
113
114/// Returns `Err` if `data` doesn't satisfy `operation_id`'s input schema,
115/// for the given `api_version`.
116pub fn validate_input(
117    api_version: &str,
118    operation_id: &str,
119    data: &Value,
120) -> Result<(), McpifyError> {
121    static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
122    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
123
124    let empty = Value::Object(Default::default());
125    let schema = schemas_by_operation(api_version)
126        .get(operation_id)
127        .map(|schemas| &schemas.input_schema)
128        .unwrap_or(&empty);
129
130    let cache_key = format!("{api_version} {operation_id}");
131    validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
132        message: format!("invalid input for '{operation_id}'"),
133        details: Some(serde_json::json!(errors)),
134    })
135}
136
137/// Returns `Err` if `data` doesn't satisfy `operation_id`'s output schema
138/// — surfaces upstream API drift as a structured error rather than
139/// silently returning a mismatched response (architecture.md's `call`
140/// pipeline). Validates against the given `api_version`'s schema.
141pub fn validate_output(
142    api_version: &str,
143    operation_id: &str,
144    data: &Value,
145) -> Result<(), McpifyError> {
146    static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
147    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
148
149    let empty = Value::Object(Default::default());
150    let schema = schemas_by_operation(api_version)
151        .get(operation_id)
152        .map(|schemas| &schemas.output_schema)
153        .unwrap_or(&empty);
154
155    let cache_key = format!("{api_version} {operation_id}");
156    validate_against(cache, &cache_key, schema, data).map_err(|errors| McpifyError::Validation {
157        message: format!("unexpected response shape for '{operation_id}'"),
158        details: Some(serde_json::json!(errors)),
159    })
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    // These tests deliberately use an operationId that generated_schemas.json
167    // never declares — they exercise the "no schema found" fallback (an
168    // always-valid `{}` schema), rather than any spec-dependent content
169    // this project's own generation happened to produce.
170
171    #[test]
172    fn an_unknown_operation_id_falls_back_to_an_always_valid_schema() {
173        assert!(
174            validate_input(
175                "2025",
176                "__unknown_operation__",
177                &serde_json::json!({"anything": true})
178            )
179            .is_ok()
180        );
181        assert!(validate_output("2025", "__unknown_operation__", &serde_json::json!(42)).is_ok());
182    }
183
184    #[test]
185    fn an_unknown_api_version_also_falls_back_to_an_always_valid_schema() {
186        assert!(
187            validate_input(
188                "__unknown_version__",
189                "__unknown_operation__",
190                &serde_json::json!({"anything": true})
191            )
192            .is_ok()
193        );
194    }
195
196    #[test]
197    fn validate_against_reports_every_schema_violation() {
198        static CACHE: OnceLock<Mutex<HashMap<String, jsonschema::Validator>>> = OnceLock::new();
199        let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
200        let schema = serde_json::json!({
201            "type": "object",
202            "required": ["name"],
203            "properties": { "name": { "type": "string" } },
204        });
205
206        let ok = validate_against(
207            cache,
208            "test_op_ok",
209            &schema,
210            &serde_json::json!({"name": "widget"}),
211        );
212        assert!(ok.is_ok());
213
214        let err = validate_against(cache, "test_op_missing", &schema, &serde_json::json!({}));
215        assert!(err.is_err());
216    }
217}