Skip to main content

turul_mcp_schema_validation/
lib.rs

1//! JSON Schema 2020-12 dialect validation for MCP tool `inputSchema`.
2//!
3//! 2026-07-28 (SEP-2106) opens `Tool.inputSchema` to the full JSON Schema
4//! 2020-12 vocabulary (`oneOf`/`anyOf`/`allOf`/`$ref`/`$defs`/conditionals).
5//! The spec states the MUST this crate satisfies:
6//! "Clients and servers MUST validate schemas according to their declared or
7//! default dialect and MUST handle unsupported dialects gracefully by
8//! returning an appropriate error." [`validate_tool_input_schema`]'s dialect
9//! check and 2020-12 meta-validation compile step are that MUST.
10//!
11//! Two additional checks in this crate are **framework security policy, not
12//! a JSON Schema spec requirement**: rejecting a remote `$ref` (prevents
13//! SSRF — fetching attacker-controlled schema content over the network) and
14//! enforcing size/nesting/composition-depth bounds (prevents resource-
15//! exhaustion DoS from an oversized or pathologically nested schema). Both
16//! are deliberate hardening layered on top of the spec MUST, not mandated by
17//! it.
18//!
19//! This crate is used by both trust boundaries: a server MUST NOT advertise
20//! an invalid `inputSchema` (enforced at tool registration), and a client
21//! MUST exclude a tool whose `inputSchema` is invalid from `tools/list`.
22
23/// Maximum serialized size of a tool `inputSchema`, in bytes. Framework
24/// security policy (DoS hardening), not a JSON Schema spec requirement.
25pub const MAX_SCHEMA_BYTES: usize = 256 * 1024;
26
27/// Reserved for a future resolved-`$ref`-chain bound. The current bounds walk
28/// is deliberately cycle-safe: it traverses only the literal JSON document
29/// tree and never resolves/follows a `$ref` (see [`validate_tool_input_schema`]
30/// docs) — a legitimate cyclic local `$ref` (e.g. a recursive tree-node
31/// schema) MUST pass. Because `$ref` targets are never resolved, there is no
32/// "chain" to measure without reintroducing the cycle risk this bound would
33/// otherwise guard against, so this constant is not currently exercised.
34pub const MAX_REF_DEPTH: usize = 32;
35
36/// Maximum nesting depth through composition keywords (`allOf`/`anyOf`/`oneOf`/
37/// `not`/`if`/`then`/`else`) and structural keywords (`items`/`properties`).
38/// Framework security policy (DoS hardening), not a JSON Schema spec requirement.
39pub const MAX_COMPOSITION_DEPTH: usize = 32;
40
41const DIALECT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema";
42
43/// Why a tool `inputSchema` failed validation.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum SchemaValidationError {
46    #[error("inputSchema is {bytes} bytes, exceeding the {limit}-byte limit")]
47    TooLarge { bytes: usize, limit: usize },
48
49    #[error("inputSchema {kind} nesting exceeds the limit of {limit}")]
50    TooDeep { kind: &'static str, limit: usize },
51
52    #[error(
53        "inputSchema declares unsupported dialect '{0}' (only JSON Schema 2020-12 is supported)"
54    )]
55    UnsupportedDialect(String),
56
57    #[error("inputSchema is not a well-formed JSON Schema 2020-12 document: {0}")]
58    Malformed(String),
59
60    #[error("remote $ref resolution is disabled by policy: '{0}'")]
61    RemoteRef(String),
62}
63
64/// Validate a tool `inputSchema`: size bound, `$ref`/composition
65/// bounds (checked over the literal JSON tree only — see below), remote-`$ref`
66/// rejection, dialect check, then 2020-12 meta-validation. Checks run
67/// cheapest-first so a pathological schema is rejected before the (relatively)
68/// expensive compile step.
69///
70/// **Cycle safety**: the bounds walk traverses only the literal JSON document
71/// tree (a finite structure by construction) — it does not resolve or follow
72/// `$ref` targets. A `$ref` value is inspected as a string only (to detect a
73/// remote reference); it is never dereferenced during this pass. This means a
74/// legitimate cyclic local `$ref` (e.g. `{"$ref": "#/$defs/Node"}` inside its
75/// own `$defs/Node` definition — a recursive tree-node schema) passes the
76/// bounds check; `jsonschema` resolves and validates such references safely
77/// at compile time in the step that follows.
78pub fn validate_tool_input_schema(schema: &serde_json::Value) -> Result<(), SchemaValidationError> {
79    let bytes = serde_json::to_vec(schema)
80        .map(|v| v.len())
81        .unwrap_or(usize::MAX);
82    if bytes > MAX_SCHEMA_BYTES {
83        return Err(SchemaValidationError::TooLarge {
84            bytes,
85            limit: MAX_SCHEMA_BYTES,
86        });
87    }
88
89    check_bounds(schema, 0)?;
90    check_dialect(schema)?;
91    compile_2020_12_no_remote(schema)?;
92
93    Ok(())
94}
95
96fn check_dialect(schema: &serde_json::Value) -> Result<(), SchemaValidationError> {
97    match schema.get("$schema").and_then(|v| v.as_str()) {
98        None => Ok(()),
99        Some(dialect) if dialect.trim_end_matches('#') == DIALECT_2020_12 => Ok(()),
100        Some(other) => Err(SchemaValidationError::UnsupportedDialect(other.to_string())),
101    }
102}
103
104fn is_remote_ref(r: &str) -> bool {
105    r.starts_with("http://") || r.starts_with("https://") || r.contains("://")
106}
107
108/// Literal-tree-only walk: recurses through `items`/`properties`/composition/
109/// conditional keywords, never through a resolved `$ref` target. A `$ref`
110/// value is checked for remoteness only.
111fn check_bounds(node: &serde_json::Value, comp_depth: usize) -> Result<(), SchemaValidationError> {
112    let Some(obj) = node.as_object() else {
113        return Ok(());
114    };
115
116    if let Some(r) = obj.get("$ref").and_then(|v| v.as_str())
117        && is_remote_ref(r)
118    {
119        return Err(SchemaValidationError::RemoteRef(r.to_string()));
120    }
121
122    for key in ["not", "if", "then", "else", "items"] {
123        if let Some(sub) = obj.get(key) {
124            let comp_depth = comp_depth + 1;
125            if comp_depth > MAX_COMPOSITION_DEPTH {
126                return Err(SchemaValidationError::TooDeep {
127                    kind: "composition",
128                    limit: MAX_COMPOSITION_DEPTH,
129                });
130            }
131            check_bounds(sub, comp_depth)?;
132        }
133    }
134
135    for key in ["allOf", "anyOf", "oneOf"] {
136        if let Some(subs) = obj.get(key).and_then(|v| v.as_array()) {
137            let comp_depth = comp_depth + 1;
138            if comp_depth > MAX_COMPOSITION_DEPTH {
139                return Err(SchemaValidationError::TooDeep {
140                    kind: "composition",
141                    limit: MAX_COMPOSITION_DEPTH,
142                });
143            }
144            for sub in subs {
145                check_bounds(sub, comp_depth)?;
146            }
147        }
148    }
149
150    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
151        let comp_depth = comp_depth + 1;
152        if comp_depth > MAX_COMPOSITION_DEPTH {
153            return Err(SchemaValidationError::TooDeep {
154                kind: "composition",
155                limit: MAX_COMPOSITION_DEPTH,
156            });
157        }
158        for sub in props.values() {
159            check_bounds(sub, comp_depth)?;
160        }
161    }
162
163    Ok(())
164}
165
166/// Retriever that refuses every lookup — remote `$ref` resolution is disabled
167/// by policy, in addition to the crate-feature-level guard (no `resolve-http`/
168/// `resolve-file`/`reqwest` features are enabled on the `jsonschema` dependency).
169struct BlockingRetriever;
170
171impl jsonschema::Retrieve for BlockingRetriever {
172    fn retrieve(
173        &self,
174        uri: &jsonschema::Uri<String>,
175    ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
176        Err(format!("remote $ref resolution is disabled: {uri}").into())
177    }
178}
179
180fn compile_2020_12_no_remote(schema: &serde_json::Value) -> Result<(), SchemaValidationError> {
181    jsonschema::options()
182        .with_draft(jsonschema::Draft::Draft202012)
183        .with_retriever(BlockingRetriever)
184        .build(schema)
185        .map(|_| ())
186        .map_err(|e| SchemaValidationError::Malformed(e.to_string()))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    #[test]
195    fn absent_schema_dialect_is_accepted() {
196        let schema = json!({"type": "object", "properties": {"a": {"type": "string"}}});
197        assert_eq!(validate_tool_input_schema(&schema), Ok(()));
198    }
199
200    #[test]
201    fn canonical_2020_12_dialect_accepted_with_and_without_trailing_hash() {
202        for uri in [
203            DIALECT_2020_12,
204            "https://json-schema.org/draft/2020-12/schema#",
205        ] {
206            let schema = json!({"$schema": uri, "type": "object"});
207            assert_eq!(validate_tool_input_schema(&schema), Ok(()), "uri={uri}");
208        }
209    }
210
211    #[test]
212    fn unsupported_dialect_is_rejected() {
213        let schema = json!({
214            "$schema": "http://json-schema.org/draft-07/schema#",
215            "type": "object"
216        });
217        match validate_tool_input_schema(&schema) {
218            Err(SchemaValidationError::UnsupportedDialect(d)) => {
219                assert_eq!(d, "http://json-schema.org/draft-07/schema#");
220            }
221            other => panic!("expected UnsupportedDialect, got {other:?}"),
222        }
223    }
224
225    #[test]
226    fn malformed_schema_is_rejected() {
227        let schema = json!({"type": 123});
228        match validate_tool_input_schema(&schema) {
229            Err(SchemaValidationError::Malformed(_)) => {}
230            other => panic!("expected Malformed, got {other:?}"),
231        }
232    }
233
234    #[test]
235    fn oversized_schema_names_the_limit_in_its_message() {
236        let big_description = "x".repeat(MAX_SCHEMA_BYTES + 1);
237        let schema = json!({"type": "object", "description": big_description});
238        let err = validate_tool_input_schema(&schema).unwrap_err();
239        match err {
240            SchemaValidationError::TooLarge { limit, .. } => assert_eq!(limit, MAX_SCHEMA_BYTES),
241            other => panic!("expected TooLarge, got {other:?}"),
242        }
243        let msg = err.to_string();
244        assert!(
245            msg.contains(&MAX_SCHEMA_BYTES.to_string()),
246            "message must name the exceeded limit: {msg}"
247        );
248    }
249
250    #[test]
251    fn excessive_composition_nesting_names_the_limit_in_its_message() {
252        // Build allOf nested MAX_COMPOSITION_DEPTH + 1 deep: each level wraps
253        // the next in {"allOf": [ ... ]}.
254        let mut inner = json!({"type": "string"});
255        for _ in 0..(MAX_COMPOSITION_DEPTH + 1) {
256            inner = json!({"allOf": [inner]});
257        }
258        let err = validate_tool_input_schema(&inner).unwrap_err();
259        match &err {
260            SchemaValidationError::TooDeep { kind, limit } => {
261                assert_eq!(*kind, "composition");
262                assert_eq!(*limit, MAX_COMPOSITION_DEPTH);
263            }
264            other => panic!("expected TooDeep, got {other:?}"),
265        }
266        let msg = err.to_string();
267        assert!(
268            msg.contains("composition") && msg.contains(&MAX_COMPOSITION_DEPTH.to_string()),
269            "message must name the kind and the exceeded limit: {msg}"
270        );
271    }
272
273    #[test]
274    fn local_ref_is_accepted_not_flagged_as_remote() {
275        let schema = json!({
276            "type": "object",
277            "properties": {"a": {"$ref": "#/$defs/Foo"}},
278            "$defs": {"Foo": {"type": "string"}}
279        });
280        assert_eq!(validate_tool_input_schema(&schema), Ok(()));
281    }
282
283    #[test]
284    fn recursive_local_ref_schema_is_accepted() {
285        // A tree-node schema referencing itself through `items` — a cyclic
286        // local $ref that is legal JSON Schema. The bounds walk must not
287        // follow it (cycle safety), so this must pass; jsonschema resolves
288        // the recursion safely at compile time.
289        let schema = json!({
290            "type": "object",
291            "properties": { "root": { "$ref": "#/$defs/Node" } },
292            "$defs": {
293                "Node": {
294                    "type": "object",
295                    "properties": {
296                        "value": { "type": "string" },
297                        "children": {
298                            "type": "array",
299                            "items": { "$ref": "#/$defs/Node" }
300                        }
301                    }
302                }
303            }
304        });
305        assert_eq!(validate_tool_input_schema(&schema), Ok(()));
306    }
307
308    #[test]
309    fn remote_ref_is_rejected_and_names_the_ref_and_policy_reason() {
310        let schema = json!({
311            "type": "object",
312            "properties": {"a": {"$ref": "https://evil.example/schema.json"}}
313        });
314        let err = validate_tool_input_schema(&schema).unwrap_err();
315        match &err {
316            SchemaValidationError::RemoteRef(uri) => {
317                assert_eq!(uri, "https://evil.example/schema.json");
318            }
319            other => panic!("expected RemoteRef, got {other:?}"),
320        }
321        let msg = err.to_string();
322        assert!(
323            msg.contains("https://evil.example/schema.json"),
324            "message must name the offending $ref: {msg}"
325        );
326        assert!(
327            msg.contains("disabled") && msg.contains("policy"),
328            "message must state this is a policy rejection, not a spec MUST: {msg}"
329        );
330    }
331}