turul_mcp_schema_validation/
lib.rs1pub const MAX_SCHEMA_BYTES: usize = 256 * 1024;
26
27pub const MAX_REF_DEPTH: usize = 32;
35
36pub const MAX_COMPOSITION_DEPTH: usize = 32;
40
41const DIALECT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema";
42
43#[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
64pub 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
108fn 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
166struct 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 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 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}