1use schemars::Schema;
2use serde_json::{Map, Value, json};
3use thiserror::Error;
4
5#[derive(Clone, Debug, PartialEq)]
6pub struct StrictToolParametersSchema {
7 document: Value,
8}
9
10#[derive(Debug, Error)]
11pub enum ToolSchemaError {
12 #[error("failed to serialize tool schema")]
13 SerializeSchema(#[from] serde_json::Error),
14 #[error("tool schema must be a JSON object")]
15 RootMustBeObject,
16 #[error("tool schema node at {path} must be a JSON object")]
17 NodeMustBeObject { path: String },
18 #[error("tool schema map at {path} must be a JSON object")]
19 NodeMapMustBeObject { path: String },
20 #[error("tool schema required must be an array at {path}")]
21 RequiredMustBeArray { path: String },
22 #[error(
23 "strict tool schemas do not support patternProperties at {path}; define explicit properties instead"
24 )]
25 PatternPropertiesUnsupported { path: String },
26 #[error(
27 "strict tool schemas do not support propertyNames at {path}; define explicit properties instead"
28 )]
29 PropertyNamesUnsupported { path: String },
30 #[error(
31 "strict tool schemas do not support open object schemas at {path}; define explicit properties instead"
32 )]
33 OpenObjectUnsupported { path: String },
34 #[error(
35 "strict tool schemas do not support schema-valued additionalProperties at {path}; define explicit properties instead"
36 )]
37 SchemaValuedAdditionalPropertiesUnsupported { path: String },
38 #[error("strict tool schemas do not support {kind}-valued additionalProperties at {path}")]
39 InvalidAdditionalProperties { path: String, kind: &'static str },
40 #[error("strict tool schemas do not support $ref siblings {keywords} at {path}")]
41 UnsupportedRefSiblingKeywords { path: String, keywords: String },
42}
43
44impl StrictToolParametersSchema {
45 pub(super) fn try_from_raw(schema: Option<&Schema>) -> Result<Self, ToolSchemaError> {
46 let raw = match schema {
47 Some(schema) => serde_json::to_value(schema)?,
48 None => json!({}),
49 };
50
51 let root = raw.as_object().ok_or(ToolSchemaError::RootMustBeObject)?;
52
53 Ok(Self {
54 document: Value::Object(parse_schema_object(root, &SchemaPath::root(), true)?),
55 })
56 }
57
58 pub fn into_json(self) -> Value {
59 self.document
60 }
61
62 pub fn as_json(&self) -> &Value {
63 &self.document
64 }
65}
66
67fn parse_schema_value(value: &Value, path: &SchemaPath) -> Result<Value, ToolSchemaError> {
68 let object = value
69 .as_object()
70 .ok_or_else(|| ToolSchemaError::NodeMustBeObject {
71 path: path.to_string(),
72 })?;
73
74 Ok(Value::Object(parse_schema_object(object, path, false)?))
75}
76
77fn parse_schema_object(
78 schema: &Map<String, Value>,
79 path: &SchemaPath,
80 force_object: bool,
81) -> Result<Map<String, Value>, ToolSchemaError> {
82 let schema = normalize_schema_object(schema, path)?;
83
84 if force_object || schema_is_object(&schema) {
85 parse_object_schema(&schema, path)
86 } else {
87 parse_non_object_schema(&schema, path)
88 }
89}
90
91fn normalize_schema_object(
92 schema: &Map<String, Value>,
93 path: &SchemaPath,
94) -> Result<Map<String, Value>, ToolSchemaError> {
95 let mut normalized = schema.clone();
96 rewrite_nullable_type_union(&mut normalized);
97 rewrite_nullable_one_of(&mut normalized);
98 strip_ref_annotation_siblings(&mut normalized, path)?;
99 Ok(normalized)
100}
101
102fn rewrite_nullable_type_union(schema: &mut Map<String, Value>) {
103 let Some(entries) = schema.get("type").and_then(Value::as_array) else {
104 return;
105 };
106
107 let Some(non_null_type) = nullable_type_union(entries).map(str::to_owned) else {
108 return;
109 };
110
111 let mut non_null_branch = schema.clone();
112 non_null_branch.insert("type".to_string(), Value::String(non_null_type));
113 let annotations = extract_schema_annotations(schema);
114
115 for key in schema_annotation_keys() {
116 non_null_branch.remove(*key);
117 }
118
119 schema.clear();
120 schema.extend(annotations);
121 schema.insert(
122 "anyOf".to_string(),
123 Value::Array(vec![
124 Value::Object(non_null_branch),
125 json!({ "type": "null" }),
126 ]),
127 );
128}
129
130fn rewrite_nullable_one_of(schema: &mut Map<String, Value>) {
131 let Some(entries) = schema.get("oneOf").and_then(Value::as_array).cloned() else {
132 return;
133 };
134
135 if is_nullable_union(&entries) {
136 schema.remove("oneOf");
137 schema.insert("anyOf".to_string(), Value::Array(entries));
138 }
139}
140
141fn is_nullable_union(entries: &[Value]) -> bool {
142 entries.len() == 2 && entries.iter().any(is_null_schema)
143}
144
145fn nullable_type_union(entries: &[Value]) -> Option<&str> {
146 if entries.len() != 2 {
147 return None;
148 }
149
150 let mut non_null = None;
151
152 for entry in entries {
153 let kind = entry.as_str()?;
154 if kind == "null" {
155 continue;
156 }
157
158 if non_null.is_some() {
159 return None;
160 }
161
162 non_null = Some(kind);
163 }
164
165 non_null
166}
167
168fn is_null_schema(value: &Value) -> bool {
169 matches!(
170 value,
171 Value::Object(object) if matches!(object.get("type"), Some(Value::String(kind)) if kind == "null")
172 )
173}
174
175fn extract_schema_annotations(schema: &Map<String, Value>) -> Map<String, Value> {
176 schema_annotation_keys()
177 .iter()
178 .filter_map(|key| {
179 schema
180 .get(*key)
181 .cloned()
182 .map(|value| ((*key).to_string(), value))
183 })
184 .collect()
185}
186
187fn schema_annotation_keys() -> &'static [&'static str] {
188 &[
189 "description",
190 "title",
191 "default",
192 "examples",
193 "deprecated",
194 "readOnly",
195 "writeOnly",
196 ]
197}
198
199fn strip_ref_annotation_siblings(
200 schema: &mut Map<String, Value>,
201 path: &SchemaPath,
202) -> Result<(), ToolSchemaError> {
203 const SAFE_REF_ANNOTATIONS: &[&str] = &[
204 "description",
205 "title",
206 "default",
207 "examples",
208 "deprecated",
209 "readOnly",
210 "writeOnly",
211 ];
212
213 if !schema.contains_key("$ref") {
214 return Ok(());
215 }
216
217 let mut unsupported = Vec::new();
218 let sibling_keys = schema
219 .keys()
220 .filter(|key| key.as_str() != "$ref")
221 .cloned()
222 .collect::<Vec<_>>();
223
224 for key in sibling_keys {
225 if SAFE_REF_ANNOTATIONS.contains(&key.as_str()) {
226 schema.remove(&key);
227 } else {
228 unsupported.push(key);
229 }
230 }
231
232 if unsupported.is_empty() {
233 Ok(())
234 } else {
235 Err(ToolSchemaError::UnsupportedRefSiblingKeywords {
236 path: path.to_string(),
237 keywords: unsupported.join(", "),
238 })
239 }
240}
241
242fn parse_object_schema(
243 schema: &Map<String, Value>,
244 path: &SchemaPath,
245) -> Result<Map<String, Value>, ToolSchemaError> {
246 if schema.get("patternProperties").is_some() {
247 return Err(ToolSchemaError::PatternPropertiesUnsupported {
248 path: path.to_string(),
249 });
250 }
251
252 if schema.get("propertyNames").is_some() {
253 return Err(ToolSchemaError::PropertyNamesUnsupported {
254 path: path.to_string(),
255 });
256 }
257
258 match schema.get("additionalProperties") {
259 Some(Value::Bool(true)) => {
260 return Err(ToolSchemaError::OpenObjectUnsupported {
261 path: path.to_string(),
262 });
263 }
264 Some(Value::Object(_)) => {
265 return Err(
266 ToolSchemaError::SchemaValuedAdditionalPropertiesUnsupported {
267 path: path.to_string(),
268 },
269 );
270 }
271 Some(Value::Array(_)) => {
272 return Err(ToolSchemaError::InvalidAdditionalProperties {
273 path: path.to_string(),
274 kind: "array",
275 });
276 }
277 Some(Value::Null) => {
278 return Err(ToolSchemaError::InvalidAdditionalProperties {
279 path: path.to_string(),
280 kind: "null",
281 });
282 }
283 Some(Value::String(_) | Value::Number(_)) => {
284 return Err(ToolSchemaError::InvalidAdditionalProperties {
285 path: path.to_string(),
286 kind: "scalar",
287 });
288 }
289 Some(Value::Bool(false)) | None => {}
290 }
291
292 let mut parsed = schema.clone();
293 parsed.insert("type".to_string(), Value::String("object".to_string()));
294 parsed.insert("additionalProperties".to_string(), Value::Bool(false));
295 parsed.insert(
296 "properties".to_string(),
297 Value::Object(parse_schema_map(
298 schema.get("properties"),
299 &path.with_key("properties"),
300 )?),
301 );
302
303 if let Some(required) = schema.get("required")
304 && !required.is_array()
305 {
306 return Err(ToolSchemaError::RequiredMustBeArray {
307 path: path.with_key("required").to_string(),
308 });
309 }
310
311 recurse_schema_children(schema, &mut parsed, path)?;
312 Ok(parsed)
313}
314
315fn parse_non_object_schema(
316 schema: &Map<String, Value>,
317 path: &SchemaPath,
318) -> Result<Map<String, Value>, ToolSchemaError> {
319 let mut parsed = schema.clone();
320 recurse_schema_children(schema, &mut parsed, path)?;
321 Ok(parsed)
322}
323
324fn recurse_schema_children(
325 source: &Map<String, Value>,
326 target: &mut Map<String, Value>,
327 path: &SchemaPath,
328) -> Result<(), ToolSchemaError> {
329 for key in ["items", "contains", "if", "then", "else", "not"] {
330 if let Some(schema) = source.get(key) {
331 target.insert(
332 key.to_string(),
333 parse_schema_value(schema, &path.with_key(key))?,
334 );
335 }
336 }
337
338 for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
339 if let Some(entries) = source.get(key).and_then(Value::as_array) {
340 target.insert(
341 key.to_string(),
342 Value::Array(
343 entries
344 .iter()
345 .enumerate()
346 .map(|(index, schema)| {
347 parse_schema_value(schema, &path.with_index(key, index))
348 })
349 .collect::<Result<Vec<_>, _>>()?,
350 ),
351 );
352 }
353 }
354
355 for key in ["properties", "$defs", "definitions", "dependentSchemas"] {
356 if let Some(entries) = source.get(key) {
357 target.insert(
358 key.to_string(),
359 Value::Object(parse_schema_map(Some(entries), &path.with_key(key))?),
360 );
361 }
362 }
363
364 Ok(())
365}
366
367fn parse_schema_map(
368 value: Option<&Value>,
369 path: &SchemaPath,
370) -> Result<Map<String, Value>, ToolSchemaError> {
371 let Some(value) = value else {
372 return Ok(Map::new());
373 };
374
375 let entries = value
376 .as_object()
377 .ok_or_else(|| ToolSchemaError::NodeMapMustBeObject {
378 path: path.to_string(),
379 })?;
380
381 let mut parsed = Map::new();
382 for (key, schema) in entries {
383 parsed.insert(
384 key.clone(),
385 parse_schema_value(schema, &path.with_key(key))?,
386 );
387 }
388
389 Ok(parsed)
390}
391
392fn schema_is_object(schema: &Map<String, Value>) -> bool {
393 type_includes_object(schema.get("type"))
394 || schema.contains_key("properties")
395 || schema.contains_key("additionalProperties")
396 || schema.contains_key("patternProperties")
397 || schema.contains_key("propertyNames")
398}
399
400fn type_includes_object(value: Option<&Value>) -> bool {
401 match value {
402 Some(Value::String(kind)) => kind == "object",
403 Some(Value::Array(kinds)) => kinds
404 .iter()
405 .filter_map(Value::as_str)
406 .any(|kind| kind == "object"),
407 _ => false,
408 }
409}
410
411#[derive(Clone, Debug)]
412pub(super) struct SchemaPath(Vec<String>);
413
414impl SchemaPath {
415 fn root() -> Self {
416 Self(vec!["$".to_string()])
417 }
418
419 fn with_key(&self, key: impl Into<String>) -> Self {
420 let mut path = self.0.clone();
421 path.push(key.into());
422 Self(path)
423 }
424
425 fn with_index(&self, key: impl Into<String>, index: usize) -> Self {
426 let mut path = self.0.clone();
427 path.push(key.into());
428 path.push(index.to_string());
429 Self(path)
430 }
431}
432
433impl std::fmt::Display for SchemaPath {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 write!(f, "{}", self.0.join("."))
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
444 #[serde(deny_unknown_fields)]
445 struct NestedCommentArgs {
446 request: NestedCommentRequest,
447 }
448
449 #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
450 #[serde(deny_unknown_fields)]
451 struct NestedCommentRequest {
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 body: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 text: Option<String>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 page_id: Option<String>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 block_id: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 discussion_id: Option<String>,
462 }
463
464 #[derive(serde::Serialize, serde::Deserialize)]
465 #[serde(transparent)]
466 struct FreeformObject(serde_json::Map<String, Value>);
467
468 impl schemars::JsonSchema for FreeformObject {
469 fn schema_name() -> std::borrow::Cow<'static, str> {
470 "FreeformObject".into()
471 }
472
473 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> Schema {
474 serde_json::from_value(json!({
475 "type": "object",
476 "additionalProperties": true
477 }))
478 .expect("freeform object schema should serialize")
479 }
480 }
481
482 #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
483 #[serde(deny_unknown_fields)]
484 struct CreateViewArgs {
485 request: CreateViewRequest,
486 }
487
488 #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
489 #[serde(deny_unknown_fields)]
490 struct CreateViewRequest {
491 body: FreeformObject,
492 }
493
494 #[test]
495 fn strict_tool_schema_rejects_nested_freeform_object_wrappers() {
496 let error =
497 StrictToolParametersSchema::try_from_raw(Some(&schemars::schema_for!(CreateViewArgs)))
498 .expect_err("freeform object should be rejected in strict mode");
499
500 let message = error.to_string();
501 assert!(message.contains("strict tool schemas do not support open object schemas"));
502 assert!(message.contains("FreeformObject"));
503 }
504
505 #[test]
506 fn strict_tool_schema_rewrites_nullable_type_unions_to_any_of() {
507 let schema: Schema = serde_json::from_value(json!({
508 "type": "object",
509 "properties": {
510 "body": {
511 "type": ["string", "null"]
512 }
513 },
514 "required": ["body"]
515 }))
516 .expect("schema should deserialize");
517
518 let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
519 .unwrap()
520 .into_json();
521
522 let body = &rendered["properties"]["body"];
523 assert!(body.get("type").is_none());
524 assert!(body.get("oneOf").is_none());
525 assert_eq!(
526 body["anyOf"],
527 Value::Array(vec![json!({ "type": "string" }), json!({ "type": "null" })])
528 );
529 }
530
531 #[test]
532 fn strict_tool_schema_rewrites_nullable_one_of_to_any_of() {
533 let schema: Schema = serde_json::from_value(json!({
534 "type": "object",
535 "properties": {
536 "body": {
537 "oneOf": [
538 { "type": "string" },
539 { "type": "null" }
540 ]
541 }
542 },
543 "required": ["body"]
544 }))
545 .expect("schema should deserialize");
546
547 let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
548 .unwrap()
549 .into_json();
550
551 let body = &rendered["properties"]["body"];
552 assert!(body.get("type").is_none());
553 assert!(body.get("oneOf").is_none());
554 assert_eq!(
555 body["anyOf"],
556 Value::Array(vec![json!({ "type": "string" }), json!({ "type": "null" })])
557 );
558 }
559
560 #[test]
561 fn strict_tool_schema_strips_ref_annotation_siblings() {
562 let schema: Schema = serde_json::from_value(json!({
563 "type": "object",
564 "properties": {
565 "request": {
566 "$ref": "#/$defs/NestedCommentRequest",
567 "description": "A nested payload"
568 }
569 },
570 "required": ["request"],
571 "$defs": {
572 "NestedCommentRequest": {
573 "type": "object",
574 "properties": {
575 "body": { "type": "string" }
576 },
577 "required": ["body"]
578 }
579 }
580 }))
581 .expect("schema should deserialize");
582
583 let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
584 .unwrap()
585 .into_json();
586
587 assert_eq!(
588 rendered["properties"]["request"],
589 json!({ "$ref": "#/$defs/NestedCommentRequest" })
590 );
591 }
592
593 #[test]
594 fn strict_tool_schema_preserves_nullable_numeric_constraints_on_the_non_null_branch() {
595 let schema: Schema = serde_json::from_value(json!({
596 "$schema": "https://json-schema.org/draft/2020-12/schema",
597 "type": "object",
598 "properties": {
599 "page_size": {
600 "type": ["integer", "null"],
601 "format": "uint",
602 "minimum": 0
603 }
604 },
605 "required": ["page_size"]
606 }))
607 .expect("schema should deserialize");
608
609 let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
610 .unwrap()
611 .into_json();
612
613 assert_eq!(
614 rendered.get("$schema"),
615 Some(&json!("https://json-schema.org/draft/2020-12/schema"))
616 );
617 let page_size = &rendered["properties"]["page_size"];
618 assert!(page_size.get("format").is_none());
619 assert!(page_size.get("minimum").is_none());
620 assert_eq!(
621 page_size["anyOf"],
622 Value::Array(vec![
623 json!({ "type": "integer", "format": "uint", "minimum": 0 }),
624 json!({ "type": "null" })
625 ])
626 );
627 }
628
629 #[test]
630 fn strict_tool_schema_moves_nullable_array_constraints_into_the_array_branch() {
631 let schema: Schema = serde_json::from_value(json!({
632 "type": "object",
633 "properties": {
634 "children": {
635 "type": ["array", "null"],
636 "items": { "type": "string" }
637 }
638 },
639 "required": ["children"]
640 }))
641 .expect("schema should deserialize");
642
643 let rendered = StrictToolParametersSchema::try_from_raw(Some(&schema))
644 .unwrap()
645 .into_json();
646
647 let children = &rendered["properties"]["children"];
648 assert!(children.get("items").is_none());
649 assert_eq!(
650 children["anyOf"],
651 Value::Array(vec![
652 json!({ "type": "array", "items": { "type": "string" } }),
653 json!({ "type": "null" })
654 ])
655 );
656 }
657
658 #[test]
659 fn strict_tool_schema_preserves_optional_nested_fields_before_provider_shaping() {
660 let schema = StrictToolParametersSchema::try_from_raw(Some(&schemars::schema_for!(
661 NestedCommentArgs
662 )))
663 .unwrap();
664
665 let rendered = schema.into_json();
666 let nested_ref = rendered["properties"]["request"]["$ref"]
667 .as_str()
668 .expect("nested request should be referenced");
669 let nested_name = nested_ref
670 .rsplit('/')
671 .next()
672 .expect("nested request ref name");
673
674 assert_eq!(rendered["additionalProperties"], Value::Bool(false));
675 assert!(
676 rendered["$defs"][nested_name].get("required").is_none(),
677 "provider-neutral parsing should not force optional nested fields into required"
678 );
679 }
680}