Skip to main content

agentic_tools_core/
schema.rs

1//! Schema engine for runtime transforms.
2
3use schemars::Schema;
4use serde_json::Value as Json;
5use std::collections::HashMap;
6use std::collections::HashSet;
7
8/// Field-level constraint to apply to a schema.
9#[derive(Clone, Debug)]
10pub enum FieldConstraint {
11    /// Restrict field to specific enum values.
12    Enum(Vec<Json>),
13
14    /// Apply numeric range constraints.
15    Range {
16        minimum: Option<Json>,
17        maximum: Option<Json>,
18    },
19
20    /// Apply string pattern constraint.
21    Pattern(String),
22
23    /// Apply a JSON merge-patch to the field schema.
24    MergePatch(Json),
25}
26
27/// Trait for custom schema transforms.
28pub trait SchemaTransform: Send + Sync {
29    /// Apply the transform to a tool's schema.
30    fn apply(&self, tool: &str, schema: &mut Json);
31}
32
33/// Engine for applying runtime transforms to tool schemas.
34///
35/// Schemars derive generates base schemas at compile time.
36/// `SchemaEngine` applies transforms at runtime for provider flexibility.
37///
38/// # Clone behavior
39/// When cloned, `custom_transforms` are **not** carried over (they are not `Clone`).
40/// Only `per_tool` constraints and `global_strict` settings are cloned.
41#[derive(Default)]
42pub struct SchemaEngine {
43    per_tool: HashMap<String, Vec<(Vec<String>, FieldConstraint)>>,
44    global_strict: bool,
45    custom_transforms: Vec<Box<dyn SchemaTransform>>,
46}
47
48impl Clone for SchemaEngine {
49    fn clone(&self) -> Self {
50        // Custom transforms cannot be cloned, so we only clone the config
51        Self {
52            per_tool: self.per_tool.clone(),
53            global_strict: self.global_strict,
54            custom_transforms: Vec::new(), // Transforms are not cloned
55        }
56    }
57}
58
59impl std::fmt::Debug for SchemaEngine {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("SchemaEngine")
62            .field("per_tool", &self.per_tool)
63            .field("global_strict", &self.global_strict)
64            .field(
65                "custom_transforms",
66                &format!("[{} transforms]", self.custom_transforms.len()),
67            )
68            .finish()
69    }
70}
71
72impl SchemaEngine {
73    /// Create a new schema engine.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Enable strict mode (additionalProperties=false) globally.
79    #[must_use]
80    pub fn with_strict(mut self, strict: bool) -> Self {
81        self.global_strict = strict;
82        self
83    }
84
85    /// Get global strict mode setting.
86    pub fn is_strict(&self) -> bool {
87        self.global_strict
88    }
89
90    /// Add a field constraint for a specific tool.
91    ///
92    /// The `json_path` is a list of property names to traverse to reach the field.
93    /// For example, `["properties", "count"]` would target the "count" property.
94    pub fn constrain_field(&mut self, tool: &str, json_path: Vec<String>, c: FieldConstraint) {
95        self.per_tool
96            .entry(tool.to_string())
97            .or_default()
98            .push((json_path, c));
99    }
100
101    /// Add a custom transform.
102    pub fn add_transform<T: SchemaTransform + 'static>(&mut self, transform: T) {
103        self.custom_transforms.push(Box::new(transform));
104    }
105
106    /// Transform a tool's schema applying all constraints and transforms.
107    #[expect(
108        clippy::needless_pass_by_value,
109        reason = "this public API intentionally consumes and returns Schema at the transformation boundary"
110    )]
111    pub fn transform(&self, tool: &str, schema: Schema) -> Schema {
112        let mut v = match serde_json::to_value(&schema) {
113            Ok(value) => value,
114            Err(error) => panic!("serialize schema: {error}"),
115        };
116
117        // Apply global strict mode
118        if self.global_strict
119            && let Some(obj) = v.as_object_mut()
120        {
121            obj.insert("additionalProperties".to_string(), Json::Bool(false));
122        }
123
124        // Apply per-tool constraints
125        if let Some(entries) = self.per_tool.get(tool) {
126            for (path, constraint) in entries {
127                Self::apply_constraint(&mut v, path, constraint);
128            }
129        }
130
131        // Apply custom transforms
132        for transform in &self.custom_transforms {
133            transform.apply(tool, &mut v);
134        }
135
136        // try_from only rejects non-object/non-bool JSON values.  Since we start
137        // from a valid Schema (always an object) and built-in transforms only mutate
138        // sub-nodes, failure here means a custom SchemaTransform replaced the root
139        // type — a programming error that must surface immediately.
140        match Schema::try_from(v) {
141            Ok(schema) => schema,
142            Err(error) => panic!("schema transform must produce a valid schema: {error}"),
143        }
144    }
145
146    fn apply_constraint(root: &mut Json, path: &[String], constraint: &FieldConstraint) {
147        let Some(node) = Self::find_node_mut(root, path) else {
148            return;
149        };
150        let Some(obj) = node.as_object_mut() else {
151            return;
152        };
153        match constraint {
154            FieldConstraint::Enum(vals) => {
155                obj.insert("enum".into(), Json::Array(vals.clone()));
156            }
157            FieldConstraint::Range { minimum, maximum } => {
158                if let Some(m) = minimum {
159                    obj.insert("minimum".into(), m.clone());
160                }
161                if let Some(m) = maximum {
162                    obj.insert("maximum".into(), m.clone());
163                }
164            }
165            FieldConstraint::Pattern(p) => {
166                obj.insert("pattern".into(), Json::String(p.clone()));
167            }
168            FieldConstraint::MergePatch(merge_patch) => {
169                json_patch::merge(node, merge_patch);
170            }
171        }
172    }
173
174    fn find_node_mut<'a>(root: &'a mut Json, path: &[String]) -> Option<&'a mut Json> {
175        let mut cur = root;
176        for seg in path {
177            cur = cur.as_object_mut()?.get_mut(seg)?;
178        }
179        Some(cur)
180    }
181}
182
183const OPTIONAL_PROPERTY_GUIDANCE: &str = "Optional; omit or use null.";
184
185#[derive(Clone, Default)]
186struct NullFirstOptional;
187
188impl schemars::transform::Transform for NullFirstOptional {
189    fn transform(&mut self, schema: &mut Schema) {
190        let mut value = match serde_json::to_value(&*schema) {
191            Ok(value) => value,
192            Err(error) => panic!("serialize schema: {error}"),
193        };
194        normalize_optional_properties(&mut value);
195        *schema = match Schema::try_from(value) {
196            Ok(schema) => schema,
197            Err(error) => panic!("NullFirstOptional must preserve schema validity: {error}"),
198        };
199    }
200}
201
202fn normalize_optional_properties(node: &mut Json) {
203    let Some(obj) = node.as_object_mut() else {
204        return;
205    };
206
207    recurse_object_entries(obj, "$defs");
208    recurse_object_entries(obj, "definitions");
209
210    let required = required_property_names(obj.get("required"));
211    if let Some(properties) = obj.get_mut("properties").and_then(Json::as_object_mut) {
212        for (property_name, property_schema) in properties {
213            if !required.contains(property_name.as_str()) {
214                normalize_known_nullable_shapes(property_schema);
215                if explicitly_allows_null(property_schema) {
216                    annotate_optional_property(property_schema);
217                }
218            }
219            normalize_optional_properties(property_schema);
220        }
221    }
222
223    recurse_object_entries(obj, "dependentSchemas");
224    recurse_object_entries(obj, "patternProperties");
225    recurse_schema_entry(obj, "additionalProperties");
226    recurse_schema_entry(obj, "propertyNames");
227    recurse_schema_entry(obj, "unevaluatedProperties");
228    recurse_schema_entry(obj, "items");
229    recurse_schema_entry(obj, "unevaluatedItems");
230    recurse_schema_entry(obj, "contains");
231    recurse_schema_array_entry(obj, "prefixItems");
232    recurse_schema_array_entry(obj, "allOf");
233    recurse_schema_array_entry(obj, "anyOf");
234    recurse_schema_array_entry(obj, "oneOf");
235    recurse_schema_entry(obj, "if");
236    recurse_schema_entry(obj, "then");
237    recurse_schema_entry(obj, "else");
238    recurse_schema_entry(obj, "not");
239}
240
241fn recurse_object_entries(obj: &mut serde_json::Map<String, Json>, key: &str) {
242    let Some(entries) = obj.get_mut(key).and_then(Json::as_object_mut) else {
243        return;
244    };
245
246    for value in entries.values_mut() {
247        normalize_optional_properties(value);
248    }
249}
250
251fn recurse_schema_entry(obj: &mut serde_json::Map<String, Json>, key: &str) {
252    let Some(value) = obj.get_mut(key) else {
253        return;
254    };
255
256    normalize_optional_properties(value);
257}
258
259fn recurse_schema_array_entry(obj: &mut serde_json::Map<String, Json>, key: &str) {
260    let Some(values) = obj.get_mut(key).and_then(Json::as_array_mut) else {
261        return;
262    };
263
264    for value in values {
265        normalize_optional_properties(value);
266    }
267}
268
269fn required_property_names(required: Option<&Json>) -> HashSet<String> {
270    required
271        .and_then(Json::as_array)
272        .into_iter()
273        .flatten()
274        .filter_map(Json::as_str)
275        .map(str::to_owned)
276        .collect()
277}
278
279fn normalize_known_nullable_shapes(node: &mut Json) {
280    move_null_to_front_in_type_array(node);
281    move_null_to_front_in_enum_values(node);
282    move_null_to_front_in_any_of(node);
283}
284
285fn explicitly_allows_null(node: &Json) -> bool {
286    type_array_contains_null(node)
287        || enum_values_contain_null(node)
288        || any_of_contains_explicit_null_branch(node)
289}
290
291fn type_array_contains_null(node: &Json) -> bool {
292    node.as_object()
293        .and_then(|obj| obj.get("type"))
294        .and_then(Json::as_array)
295        .is_some_and(|type_values| {
296            type_values
297                .iter()
298                .any(|value| value == &Json::String("null".into()))
299        })
300}
301
302fn enum_values_contain_null(node: &Json) -> bool {
303    node.as_object()
304        .and_then(|obj| obj.get("enum"))
305        .and_then(Json::as_array)
306        .is_some_and(|enum_values| enum_values.iter().any(Json::is_null))
307}
308
309fn any_of_contains_explicit_null_branch(node: &Json) -> bool {
310    node.as_object()
311        .and_then(|obj| obj.get("anyOf"))
312        .and_then(Json::as_array)
313        .is_some_and(|any_of| any_of.iter().any(is_explicit_null_branch))
314}
315
316fn move_null_to_front_in_type_array(node: &mut Json) {
317    let Some(obj) = node.as_object_mut() else {
318        return;
319    };
320
321    let Some(type_values) = obj.get_mut("type").and_then(Json::as_array_mut) else {
322        return;
323    };
324
325    move_values_to_front(type_values, |value| value == &Json::String("null".into()));
326}
327
328fn move_null_to_front_in_enum_values(node: &mut Json) {
329    let Some(obj) = node.as_object_mut() else {
330        return;
331    };
332
333    let Some(enum_values) = obj.get_mut("enum").and_then(Json::as_array_mut) else {
334        return;
335    };
336
337    move_values_to_front(enum_values, Json::is_null);
338}
339
340fn move_null_to_front_in_any_of(node: &mut Json) {
341    let Some(obj) = node.as_object_mut() else {
342        return;
343    };
344
345    let Some(any_of) = obj.get_mut("anyOf").and_then(Json::as_array_mut) else {
346        return;
347    };
348
349    move_values_to_front(any_of, is_explicit_null_branch);
350}
351
352fn annotate_optional_property(node: &mut Json) {
353    let Some(obj) = node.as_object_mut() else {
354        return;
355    };
356
357    match obj.get_mut("description") {
358        Some(Json::String(description)) => {
359            if !description.contains(OPTIONAL_PROPERTY_GUIDANCE) {
360                description.push_str("\n\n");
361                description.push_str(OPTIONAL_PROPERTY_GUIDANCE);
362            }
363        }
364        Some(_) => {
365            // Preserve non-string descriptions as-is; appending guidance only works for strings.
366        }
367        None => {
368            obj.insert(
369                "description".to_string(),
370                Json::String(OPTIONAL_PROPERTY_GUIDANCE.to_string()),
371            );
372        }
373    }
374}
375
376fn move_values_to_front<F>(values: &mut Vec<Json>, predicate: F)
377where
378    F: Fn(&Json) -> bool,
379{
380    let mut matching = Vec::new();
381    let mut non_matching = Vec::new();
382
383    for value in values.drain(..) {
384        if predicate(&value) {
385            matching.push(value);
386        } else {
387            non_matching.push(value);
388        }
389    }
390
391    if matching.is_empty() {
392        *values = non_matching;
393        return;
394    }
395
396    matching.extend(non_matching);
397    *values = matching;
398}
399
400fn is_explicit_null_branch(node: &Json) -> bool {
401    matches!(
402        node,
403        Json::Object(obj) if obj.get("type") == Some(&Json::String("null".into()))
404    )
405}
406
407// ============================================================================
408// Centralized Draft 2020-12 Generator for MCP + Registry
409// ============================================================================
410
411/// Centralized schema generation using Draft 2020-12.
412///
413/// This module provides cached schema generation for MCP:
414/// - JSON Schema Draft 2020-12 (MCP protocol requirement)
415/// - `Option<T>` object properties remain nullable and are normalized to place
416///   `null` first while preserving inner item/value nullability
417/// - Thread-local caching keyed by `TypeId` for performance
418pub mod mcp_schema {
419    use super::NullFirstOptional;
420    use schemars::JsonSchema;
421    use schemars::Schema;
422    use schemars::generate::SchemaSettings;
423    use schemars::transform::RestrictFormats;
424    use std::any::TypeId;
425    use std::cell::RefCell;
426    use std::collections::HashMap;
427    use std::sync::Arc;
428
429    thread_local! {
430        static CACHE_FOR_TYPE: RefCell<HashMap<TypeId, Arc<Schema>>> = RefCell::new(HashMap::new());
431        static CACHE_FOR_OUTPUT: RefCell<HashMap<TypeId, Result<Arc<Schema>, String>>> = RefCell::new(HashMap::new());
432    }
433
434    fn settings() -> SchemaSettings {
435        SchemaSettings::draft2020_12()
436            .with_transform(RestrictFormats::default())
437            .with_transform(NullFirstOptional)
438    }
439
440    /// Generate a cached schema for type T using Draft 2020-12.
441    pub fn cached_schema_for<T: JsonSchema + 'static>() -> Arc<Schema> {
442        CACHE_FOR_TYPE.with(|cache| {
443            let mut cache = cache.borrow_mut();
444            if let Some(x) = cache.get(&TypeId::of::<T>()) {
445                return Arc::clone(x);
446            }
447            let generator = settings().into_generator();
448            let root = generator.into_root_schema_for::<T>();
449            let arc = Arc::new(root);
450            cache.insert(TypeId::of::<T>(), Arc::clone(&arc));
451            arc
452        })
453    }
454
455    /// Generate a cached output schema for type T, validating root type is "object".
456    /// Returns Err if the root type is not "object" (per MCP spec requirement).
457    pub fn cached_output_schema_for<T: JsonSchema + 'static>() -> Result<Arc<Schema>, String> {
458        CACHE_FOR_OUTPUT.with(|cache| {
459            let mut cache = cache.borrow_mut();
460            if let Some(r) = cache.get(&TypeId::of::<T>()) {
461                return r.clone();
462            }
463            let root = cached_schema_for::<T>();
464            let json = match serde_json::to_value(root.as_ref()) {
465                Ok(value) => value,
466                Err(error) => panic!("serialize output schema: {error}"),
467            };
468            let result = match json.get("type") {
469                Some(serde_json::Value::String(t)) if t == "object" => Ok(root),
470                Some(serde_json::Value::String(t)) => Err(format!(
471                    "MCP requires output_schema root type 'object', found '{t}'"
472                )),
473                None => {
474                    // Schema might use $ref or other patterns without explicit type
475                    // Accept if it has properties (likely an object schema)
476                    if json.get("properties").is_some() {
477                        Ok(root)
478                    } else {
479                        Err(
480                            "Schema missing 'type' — output_schema must have root type 'object'"
481                                .to_string(),
482                        )
483                    }
484                }
485                Some(other) => Err(format!(
486                    "Unexpected 'type' format: {other:?} — expected string 'object'"
487                )),
488            };
489            cache.insert(TypeId::of::<T>(), result.clone());
490            result
491        })
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use serde::Serialize;
499
500    #[derive(schemars::JsonSchema, Serialize)]
501    struct TestInput {
502        count: i32,
503        name: String,
504    }
505
506    #[test]
507    fn test_strict_mode() {
508        let engine = SchemaEngine::new().with_strict(true);
509        let schema = schemars::schema_for!(TestInput);
510        let transformed = engine.transform("test", schema);
511
512        let json = serde_json::to_value(&transformed).unwrap();
513        assert_eq!(json.get("additionalProperties"), Some(&Json::Bool(false)));
514    }
515
516    #[test]
517    fn test_is_strict_getter() {
518        let e = SchemaEngine::new();
519        assert!(!e.is_strict());
520        let e2 = SchemaEngine::new().with_strict(true);
521        assert!(e2.is_strict());
522    }
523
524    #[test]
525    fn test_enum_constraint() {
526        let mut engine = SchemaEngine::new();
527
528        // Use a simple schema object for testing
529        let test_schema: Json = serde_json::json!({
530            "type": "object",
531            "properties": {
532                "name": {
533                    "type": "string"
534                }
535            }
536        });
537
538        engine.constrain_field(
539            "test",
540            vec!["properties".into(), "name".into()],
541            FieldConstraint::Enum(vec![Json::String("a".into()), Json::String("b".into())]),
542        );
543
544        let schema: Schema = Schema::try_from(test_schema).unwrap();
545        let transformed = engine.transform("test", schema);
546
547        let json = serde_json::to_value(&transformed).unwrap();
548        let name_schema = &json["properties"]["name"];
549        assert!(name_schema.get("enum").is_some());
550    }
551
552    #[test]
553    fn test_range_constraint() {
554        // Test that range constraints are applied to the correct schema path
555        let mut engine = SchemaEngine::new();
556        engine.constrain_field(
557            "test",
558            vec!["properties".into(), "count".into()],
559            FieldConstraint::Range {
560                minimum: Some(Json::Number(0.into())),
561                maximum: Some(Json::Number(100.into())),
562            },
563        );
564
565        // Use schemars to generate a real schema
566        let schema = schemars::schema_for!(TestInput);
567
568        // The transform function modifies the schema
569        let transformed = engine.transform("test", schema);
570
571        // Verify the range constraints were applied
572        let json = serde_json::to_value(&transformed).unwrap();
573        let count_schema = &json["properties"]["count"];
574
575        // Verify range was applied (compare as f64 since schemars may use floats)
576        let min = count_schema
577            .get("minimum")
578            .and_then(serde_json::Value::as_f64);
579        let max = count_schema
580            .get("maximum")
581            .and_then(serde_json::Value::as_f64);
582
583        assert_eq!(min, Some(0.0), "minimum constraint should be applied");
584        assert_eq!(max, Some(100.0), "maximum constraint should be applied");
585    }
586
587    // ========================================================================
588    // mcp_schema module tests
589    // ========================================================================
590
591    mod mcp_schema_tests {
592        use super::Json;
593        use super::NullFirstOptional;
594        use super::OPTIONAL_PROPERTY_GUIDANCE;
595        use super::Schema;
596        use super::mcp_schema;
597        use schemars::transform::Transform;
598        use serde::Serialize;
599
600        fn property<'a>(schema: &'a Json, name: &str) -> &'a Json {
601            &schema["properties"][name]
602        }
603
604        fn required_names(schema: &Json) -> Vec<&str> {
605            schema["required"]
606                .as_array()
607                .into_iter()
608                .flatten()
609                .filter_map(Json::as_str)
610                .collect()
611        }
612
613        fn assert_optional_guidance(schema: &Json, name: &str) {
614            assert_eq!(
615                property(schema, name).get("description"),
616                Some(&Json::String(OPTIONAL_PROPERTY_GUIDANCE.to_string()))
617            );
618        }
619
620        #[derive(schemars::JsonSchema, Serialize)]
621        struct WithOption {
622            a: Option<String>,
623        }
624
625        #[test]
626        fn test_option_string_is_optional_nullable_with_null_first() {
627            let root = mcp_schema::cached_schema_for::<WithOption>();
628            let v = serde_json::to_value(root.as_ref()).unwrap();
629            let a = property(&v, "a");
630
631            assert_eq!(a.get("type"), Some(&serde_json::json!(["null", "string"])));
632            assert!(a.get("nullable").is_none());
633            assert!(required_names(&v).is_empty());
634            assert_optional_guidance(&v, "a");
635        }
636
637        #[derive(schemars::JsonSchema, Serialize)]
638        struct OutputObj {
639            x: i32,
640        }
641
642        #[test]
643        fn test_output_schema_validation_object() {
644            let ok = mcp_schema::cached_output_schema_for::<OutputObj>();
645            assert!(
646                ok.is_ok(),
647                "Object types should pass output schema validation"
648            );
649        }
650
651        #[test]
652        fn test_output_schema_validation_non_object() {
653            // String is not an object type
654            let bad = mcp_schema::cached_output_schema_for::<String>();
655            assert!(
656                bad.is_err(),
657                "Non-object types should fail output schema validation"
658            );
659        }
660
661        #[test]
662        fn test_draft_2020_12_uses_defs() {
663            let root = mcp_schema::cached_schema_for::<WithOption>();
664            let v = serde_json::to_value(root.as_ref()).unwrap();
665            // Draft 2020-12 should use $defs, not definitions
666            // Note: simple types may not have $defs, so we just verify
667            // the schema is valid and contains expected structure
668            assert!(v.is_object(), "Schema should be an object");
669            assert!(
670                v.get("$schema")
671                    .and_then(|s| s.as_str())
672                    .is_some_and(|s| s.contains("2020-12")),
673                "Schema should reference Draft 2020-12"
674            );
675        }
676
677        #[test]
678        fn test_caching_returns_same_arc() {
679            let first = mcp_schema::cached_schema_for::<OutputObj>();
680            let second = mcp_schema::cached_schema_for::<OutputObj>();
681            assert!(
682                std::sync::Arc::ptr_eq(&first, &second),
683                "Cached schemas should return the same Arc"
684            );
685        }
686
687        // ====================================================================
688        // RestrictFormats transform and Option<Enum> tests
689        // ====================================================================
690
691        #[expect(dead_code)]
692        #[derive(schemars::JsonSchema, Serialize)]
693        enum TestEnum {
694            A,
695            B,
696        }
697
698        #[derive(schemars::JsonSchema, Serialize)]
699        struct HasOptEnum {
700            e: Option<TestEnum>,
701        }
702
703        #[test]
704        fn test_option_enum_keeps_any_of_with_null_first() {
705            let root = mcp_schema::cached_schema_for::<HasOptEnum>();
706            let v = serde_json::to_value(root.as_ref()).unwrap();
707            let e = property(&v, "e");
708            let any_of = e["anyOf"].as_array().expect("Option enum should use anyOf");
709
710            assert_eq!(any_of.len(), 2);
711            assert_eq!(any_of[0], serde_json::json!({ "type": "null" }));
712            assert!(any_of[1].get("$ref").is_some());
713            assert_optional_guidance(&v, "e");
714        }
715
716        #[derive(schemars::JsonSchema, Serialize)]
717        struct Unsigneds {
718            a: u32,
719            b: u64,
720        }
721
722        #[test]
723        fn test_strip_uint_formats() {
724            let root = mcp_schema::cached_schema_for::<Unsigneds>();
725            let v = serde_json::to_value(root.as_ref()).unwrap();
726            let pa = &v["properties"]["a"];
727            let pb = &v["properties"]["b"];
728
729            assert!(
730                pa.get("format").is_none(),
731                "u32 should not include non-standard 'format'"
732            );
733            assert!(
734                pb.get("format").is_none(),
735                "u64 should not include non-standard 'format'"
736            );
737            assert_eq!(
738                pa.get("minimum").and_then(serde_json::Value::as_u64),
739                Some(0),
740                "u32 minimum must be preserved"
741            );
742            assert_eq!(
743                pb.get("minimum").and_then(serde_json::Value::as_u64),
744                Some(0),
745                "u64 minimum must be preserved"
746            );
747        }
748
749        #[derive(schemars::JsonSchema, Serialize)]
750        struct HasOptString {
751            s: Option<String>,
752        }
753
754        #[test]
755        fn test_option_string_uses_null_first_without_nullable_keyword() {
756            let root = mcp_schema::cached_schema_for::<HasOptString>();
757            let v = serde_json::to_value(root.as_ref()).unwrap();
758            let s = property(&v, "s");
759
760            assert_eq!(s.get("type"), Some(&serde_json::json!(["null", "string"])));
761            assert!(
762                s.get("nullable").is_none(),
763                "Option<String> should not have nullable keyword"
764            );
765            assert_optional_guidance(&v, "s");
766        }
767
768        #[derive(schemars::JsonSchema, Serialize)]
769        struct NestedInner {
770            leaf: Option<String>,
771        }
772
773        #[derive(schemars::JsonSchema, Serialize)]
774        struct NestedOuter {
775            nested: Option<NestedInner>,
776        }
777
778        #[test]
779        fn test_nested_optional_properties_are_normalized_recursively() {
780            let root = mcp_schema::cached_schema_for::<NestedOuter>();
781            let v = serde_json::to_value(root.as_ref()).unwrap();
782            let nested = property(&v, "nested");
783            let nested_any_of = nested["anyOf"]
784                .as_array()
785                .expect("Nested option should keep anyOf branches");
786
787            assert_eq!(nested_any_of[0], serde_json::json!({ "type": "null" }));
788            assert!(nested_any_of[1].get("$ref").is_some());
789            assert_optional_guidance(&v, "nested");
790
791            let defs = v["$defs"]
792                .as_object()
793                .expect("Nested type should use $defs");
794            let inner = defs
795                .values()
796                .find(|schema| schema["properties"].get("leaf").is_some())
797                .expect("NestedInner schema should exist in $defs");
798
799            assert_eq!(
800                inner["properties"]["leaf"]["type"],
801                serde_json::json!(["null", "string"])
802            );
803            assert_eq!(
804                inner["properties"]["leaf"]["description"],
805                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
806            );
807        }
808
809        #[derive(schemars::JsonSchema, Serialize)]
810        struct HasOptVec {
811            values: Option<Vec<String>>,
812        }
813
814        #[test]
815        fn test_option_vec_property_keeps_outer_nullability_with_null_first() {
816            let root = mcp_schema::cached_schema_for::<HasOptVec>();
817            let v = serde_json::to_value(root.as_ref()).unwrap();
818            let values = property(&v, "values");
819
820            assert_eq!(
821                values.get("type"),
822                Some(&serde_json::json!(["null", "array"]))
823            );
824            assert_eq!(values["items"]["type"], serde_json::json!("string"));
825            assert_optional_guidance(&v, "values");
826        }
827
828        #[derive(schemars::JsonSchema, Serialize)]
829        struct HasNestedOptionalItems {
830            values: Option<Vec<Option<String>>>,
831        }
832
833        #[test]
834        fn test_inner_nullability_is_preserved() {
835            let root = mcp_schema::cached_schema_for::<HasNestedOptionalItems>();
836            let v = serde_json::to_value(root.as_ref()).unwrap();
837            let values = property(&v, "values");
838            let item_type = values["items"]["type"]
839                .as_array()
840                .expect("Inner Option<String> should remain nullable");
841
842            assert_eq!(
843                values.get("type"),
844                Some(&serde_json::json!(["null", "array"]))
845            );
846            assert!(item_type.contains(&serde_json::json!("string")));
847            assert!(item_type.contains(&serde_json::json!("null")));
848            assert_optional_guidance(&v, "values");
849        }
850
851        #[test]
852        fn test_required_fields_remain_unchanged() {
853            let mut schema = Schema::try_from(serde_json::json!({
854                "type": "object",
855                "properties": {
856                    "required_field": { "type": ["string", "null"] },
857                    "optional_field": { "type": ["string", "null"] }
858                },
859                "required": ["required_field"]
860            }))
861            .unwrap();
862
863            NullFirstOptional.transform(&mut schema);
864
865            let v = serde_json::to_value(&schema).unwrap();
866            let required_type = v["properties"]["required_field"]["type"]
867                .as_array()
868                .expect("Required field should keep nullable type array");
869
870            assert!(required_type.contains(&serde_json::json!("string")));
871            assert!(required_type.contains(&serde_json::json!("null")));
872            assert_eq!(
873                v["properties"]["optional_field"]["type"],
874                serde_json::json!(["null", "string"])
875            );
876            assert_eq!(
877                v["properties"]["optional_field"]["description"],
878                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
879            );
880            assert!(
881                v["properties"]["required_field"]
882                    .get("description")
883                    .is_none()
884            );
885        }
886
887        #[test]
888        fn test_manual_any_of_null_branch_moves_to_front() {
889            let mut schema = Schema::try_from(serde_json::json!({
890                "type": "object",
891                "properties": {
892                    "optional_field": {
893                        "anyOf": [
894                            { "type": "string" },
895                            { "type": "integer" },
896                            { "type": "null" }
897                        ]
898                    }
899                }
900            }))
901            .unwrap();
902
903            NullFirstOptional.transform(&mut schema);
904
905            let v = serde_json::to_value(&schema).unwrap();
906            assert_eq!(
907                v["properties"]["optional_field"]["anyOf"],
908                serde_json::json!([
909                    { "type": "null" },
910                    { "type": "string" },
911                    { "type": "integer" }
912                ])
913            );
914            assert_eq!(
915                v["properties"]["optional_field"]["description"],
916                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
917            );
918        }
919
920        #[test]
921        fn test_manual_enum_null_moves_to_front() {
922            let mut schema = Schema::try_from(serde_json::json!({
923                "type": "object",
924                "properties": {
925                    "optional_field": {
926                        "enum": ["alpha", null, "beta"]
927                    }
928                }
929            }))
930            .unwrap();
931
932            NullFirstOptional.transform(&mut schema);
933
934            let v = serde_json::to_value(&schema).unwrap();
935            assert_eq!(
936                v["properties"]["optional_field"]["enum"],
937                serde_json::json!([null, "alpha", "beta"])
938            );
939            assert_eq!(
940                v["properties"]["optional_field"]["description"],
941                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
942            );
943        }
944
945        #[test]
946        fn test_existing_description_appends_guidance_once() {
947            let mut schema = Schema::try_from(serde_json::json!({
948                "type": "object",
949                "properties": {
950                    "optional_field": {
951                        "description": "Existing description.",
952                        "type": ["string", "null"]
953                    }
954                }
955            }))
956            .unwrap();
957
958            NullFirstOptional.transform(&mut schema);
959            NullFirstOptional.transform(&mut schema);
960
961            let v = serde_json::to_value(&schema).unwrap();
962            assert_eq!(
963                v["properties"]["optional_field"]["description"],
964                serde_json::json!("Existing description.\n\nOptional; omit or use null.")
965            );
966            assert_eq!(
967                v["properties"]["optional_field"]["type"],
968                serde_json::json!(["null", "string"])
969            );
970        }
971
972        #[test]
973        fn test_non_nullable_optional_property_does_not_get_null_guidance() {
974            let mut schema = Schema::try_from(serde_json::json!({
975                "type": "object",
976                "properties": {
977                    "optional_field": {
978                        "type": "string"
979                    }
980                }
981            }))
982            .unwrap();
983
984            NullFirstOptional.transform(&mut schema);
985
986            let v = serde_json::to_value(&schema).unwrap();
987            assert!(
988                v["properties"]["optional_field"]
989                    .get("description")
990                    .is_none()
991            );
992        }
993
994        #[test]
995        fn test_dependent_schemas_are_normalized_recursively() {
996            let mut schema = Schema::try_from(serde_json::json!({
997                "type": "object",
998                "properties": {
999                    "trigger": { "type": "boolean" }
1000                },
1001                "dependentSchemas": {
1002                    "trigger": {
1003                        "type": "object",
1004                        "properties": {
1005                            "nested_optional": {
1006                                "type": ["string", "null"]
1007                            }
1008                        }
1009                    }
1010                }
1011            }))
1012            .unwrap();
1013
1014            NullFirstOptional.transform(&mut schema);
1015
1016            let v = serde_json::to_value(&schema).unwrap();
1017            assert_eq!(
1018                v["dependentSchemas"]["trigger"]["properties"]["nested_optional"]["type"],
1019                serde_json::json!(["null", "string"])
1020            );
1021            assert_eq!(
1022                v["dependentSchemas"]["trigger"]["properties"]["nested_optional"]["description"],
1023                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
1024            );
1025        }
1026
1027        #[test]
1028        fn test_unevaluated_items_are_normalized_recursively() {
1029            let mut schema = Schema::try_from(serde_json::json!({
1030                "type": "array",
1031                "unevaluatedItems": {
1032                    "type": "object",
1033                    "properties": {
1034                        "nested_optional": {
1035                            "type": ["string", "null"]
1036                        }
1037                    }
1038                }
1039            }))
1040            .unwrap();
1041
1042            NullFirstOptional.transform(&mut schema);
1043
1044            let v = serde_json::to_value(&schema).unwrap();
1045            assert_eq!(
1046                v["unevaluatedItems"]["properties"]["nested_optional"]["type"],
1047                serde_json::json!(["null", "string"])
1048            );
1049            assert_eq!(
1050                v["unevaluatedItems"]["properties"]["nested_optional"]["description"],
1051                serde_json::json!(OPTIONAL_PROPERTY_GUIDANCE)
1052            );
1053        }
1054    }
1055}