Skip to main content

turul_mcp_builders/
schemars_helpers.rs

1//! Schemars helpers for auto-generating tool schemas
2//!
3//! This module provides utilities for converting schemars-generated JSON Schemas
4//! into MCP ToolSchema format.
5//!
6//! # Example
7//!
8//! ```rust
9//! use turul_mcp_builders::ToolSchemaExt;
10//! use turul_mcp_protocol::ToolSchema;
11//! use schemars::{JsonSchema, schema_for};
12//! use serde::Serialize;
13//!
14//! #[derive(Serialize, JsonSchema)]
15//! struct CalculatorOutput {
16//!     result: f64,
17//!     operation: String,
18//! }
19//!
20//! let json_schema = schema_for!(CalculatorOutput);
21//! let tool_schema = ToolSchema::from_schemars(json_schema)
22//!     .expect("Valid schema");
23//! ```
24
25use serde_json::Value;
26use std::collections::HashMap;
27use turul_mcp_protocol::ToolSchema;
28use turul_mcp_protocol::schema::JsonSchema;
29
30/// Convert a serde_json::Value from schemars to MCP's JsonSchema enum
31///
32/// This is a "lossy but safe" converter that:
33/// - Handles basic types: string, number, integer, boolean, object, array
34/// - Recursively converts nested properties and array items
35/// - Returns generic Object for complex patterns (anyOf, oneOf, etc.)
36/// - **Never panics** - always returns a valid JsonSchema
37pub fn convert_value_to_json_schema(value: &Value) -> JsonSchema {
38    convert_value_to_json_schema_with_defs(value, &HashMap::new())
39}
40
41/// Convert a serde_json::Value from schemars to MCP's JsonSchema enum with $ref resolution
42///
43/// This version accepts a definitions map to resolve $ref references for nested types.
44/// Use this when converting a schemars RootSchema that includes definitions.
45///
46/// # Arguments
47///
48/// * `value` - The JSON schema value to convert
49/// * `definitions` - Map of type names to their schema definitions for $ref resolution
50///
51/// # Returns
52///
53/// A converted JsonSchema that:
54/// - Handles basic types: string, number, integer, boolean, object, array
55/// - Recursively converts nested properties and array items
56/// - Resolves $ref references to definitions for nested types
57/// - Returns generic Object for unresolvable patterns (anyOf, oneOf, etc.)
58/// - **Never panics** - always returns a valid JsonSchema
59pub fn convert_value_to_json_schema_with_defs(
60    value: &Value,
61    definitions: &HashMap<String, Value>,
62) -> JsonSchema {
63    // Handle boolean schemas (rare, but valid in JSON Schema)
64    if let Some(b) = value.as_bool() {
65        // true = accept anything, false = accept nothing
66        // Both represented as generic objects
67        return JsonSchema::Object {
68            description: None,
69            properties: None,
70            required: None,
71            additional_properties: Some(b),
72        };
73    }
74
75    // Must be an object schema
76    let obj = match value.as_object() {
77        Some(o) => o,
78        None => {
79            // Not an object or boolean - return generic object
80            return JsonSchema::Object {
81                description: None,
82                properties: None,
83                required: None,
84                additional_properties: None,
85            };
86        }
87    };
88
89    // Handle $ref - resolve from definitions
90    if let Some(ref_path) = obj.get("$ref").and_then(|v| v.as_str()) {
91        // Extract definition name from "#/definitions/TypeName" or "#/$defs/TypeName"
92        let def_name = ref_path
93            .strip_prefix("#/definitions/")
94            .or_else(|| ref_path.strip_prefix("#/$defs/"));
95
96        if let Some(name) = def_name
97            && let Some(def_schema) = definitions.get(name)
98        {
99            // Recursively convert the referenced definition
100            return convert_value_to_json_schema_with_defs(def_schema, definitions);
101        }
102        // Couldn't resolve reference - fall back to generic object
103        return JsonSchema::Object {
104            description: obj
105                .get("description")
106                .and_then(|v| v.as_str())
107                .map(String::from),
108            properties: None,
109            required: None,
110            additional_properties: None,
111        };
112    }
113
114    // Handle anyOf - common for Option<T> which generates anyOf: [T, null]
115    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
116        // Look for the non-null schema in the anyOf array
117        for schema in any_of {
118            // Skip null schemas
119            if let Some(obj) = schema.as_object() {
120                if let Some(t) = obj.get("type")
121                    && t.as_str() == Some("null")
122                {
123                    continue; // Skip null type
124                }
125                // Found non-null schema - convert it
126                return convert_value_to_json_schema_with_defs(schema, definitions);
127            }
128        }
129        // All schemas were null or couldn't parse - fall back to generic object
130        return JsonSchema::Object {
131            description: obj
132                .get("description")
133                .and_then(|v| v.as_str())
134                .map(String::from),
135            properties: None,
136            required: None,
137            additional_properties: None,
138        };
139    }
140
141    // Get the type field - can be string or array of strings
142    let schema_type = obj
143        .get("type")
144        .and_then(|v| {
145            if let Some(s) = v.as_str() {
146                // Single type as string
147                Some(s.to_string())
148            } else if let Some(arr) = v.as_array() {
149                // Array of types (e.g., ["string", "null"] for Option<String>)
150                // Find the non-null type
151                for type_val in arr {
152                    if let Some(t) = type_val.as_str()
153                        && t != "null"
154                    {
155                        return Some(t.to_string());
156                    }
157                }
158                None
159            } else {
160                None
161            }
162        })
163        .or_else(|| {
164            // If no type but has properties, assume object
165            if obj.contains_key("properties") {
166                Some("object".to_string())
167            } else {
168                None
169            }
170        });
171
172    let schema_type = schema_type.as_deref();
173    // Note: Unknown schema types fall back to generic object
174
175    // Convert based on type
176    match schema_type {
177        Some("string") => JsonSchema::String {
178            description: obj
179                .get("description")
180                .and_then(|v| v.as_str())
181                .map(String::from),
182            pattern: obj
183                .get("pattern")
184                .and_then(|v| v.as_str())
185                .map(String::from),
186            min_length: obj.get("minLength").and_then(|v| v.as_u64()),
187            max_length: obj.get("maxLength").and_then(|v| v.as_u64()),
188            enum_values: obj.get("enum").and_then(|v| {
189                v.as_array().and_then(|arr| {
190                    arr.iter()
191                        .map(|v| v.as_str().map(String::from))
192                        .collect::<Option<Vec<_>>>()
193                })
194            }),
195        },
196
197        Some("number") => JsonSchema::Number {
198            description: obj
199                .get("description")
200                .and_then(|v| v.as_str())
201                .map(String::from),
202            minimum: obj.get("minimum").and_then(|v| v.as_f64()),
203            maximum: obj.get("maximum").and_then(|v| v.as_f64()),
204        },
205
206        Some("integer") => JsonSchema::Integer {
207            description: obj
208                .get("description")
209                .and_then(|v| v.as_str())
210                .map(String::from),
211            minimum: obj.get("minimum").and_then(|v| v.as_i64()),
212            maximum: obj.get("maximum").and_then(|v| v.as_i64()),
213        },
214
215        Some("boolean") => JsonSchema::Boolean {
216            description: obj
217                .get("description")
218                .and_then(|v| v.as_str())
219                .map(String::from),
220        },
221
222        Some("array") => {
223            // Recursively convert array items
224            let items = obj
225                .get("items")
226                .map(|v| Box::new(convert_value_to_json_schema_with_defs(v, definitions)));
227
228            JsonSchema::Array {
229                description: obj
230                    .get("description")
231                    .and_then(|v| v.as_str())
232                    .map(String::from),
233                items,
234                min_items: obj.get("minItems").and_then(|v| v.as_u64()),
235                max_items: obj.get("maxItems").and_then(|v| v.as_u64()),
236            }
237        }
238
239        Some("object") => {
240            // Recursively convert properties
241            let properties = obj
242                .get("properties")
243                .and_then(|v| v.as_object())
244                .map(|props| {
245                    props
246                        .iter()
247                        .map(|(k, v)| {
248                            (
249                                k.clone(),
250                                convert_value_to_json_schema_with_defs(v, definitions),
251                            )
252                        })
253                        .collect::<HashMap<_, _>>()
254                });
255
256            // Get required fields
257            let required = obj.get("required").and_then(|v| v.as_array()).map(|arr| {
258                arr.iter()
259                    .filter_map(|v| v.as_str().map(String::from))
260                    .collect()
261            });
262
263            JsonSchema::Object {
264                description: obj
265                    .get("description")
266                    .and_then(|v| v.as_str())
267                    .map(String::from),
268                properties,
269                required,
270                additional_properties: obj.get("additionalProperties").and_then(|v| v.as_bool()),
271            }
272        }
273
274        _ => {
275            // Unknown type, $ref, anyOf, oneOf, allOf, etc.
276            // Return generic object (lossy but safe)
277            JsonSchema::Object {
278                description: obj
279                    .get("description")
280                    .and_then(|v| v.as_str())
281                    .map(String::from),
282                properties: None,
283                required: None,
284                additional_properties: None,
285            }
286        }
287    }
288}
289
290/// Extension trait for ToolSchema to support schemars conversion
291///
292/// This trait is automatically implemented for `ToolSchema`, providing the
293/// `from_schemars()` method for converting schemars schemas to MCP format.
294pub trait ToolSchemaExt {
295    /// Convert a schemars JSON Schema to MCP ToolSchema
296    ///
297    /// This enables auto-generating tool output schemas from Rust types using the
298    /// `schemars` crate's `JsonSchema` derive macro.
299    ///
300    /// # Arguments
301    ///
302    /// * `schema` - A schemars Schema generated via `schema_for!()`
303    ///
304    /// # Returns
305    ///
306    /// * `Ok(ToolSchema)` - Successfully converted schema
307    /// * `Err(String)` - Conversion error message
308    ///
309    /// # Example
310    ///
311    /// ```rust
312    /// use turul_mcp_builders::ToolSchemaExt;
313    /// use turul_mcp_protocol::ToolSchema;
314    /// use schemars::{JsonSchema, schema_for};
315    /// use serde::Serialize;
316    /// use std::sync::OnceLock;
317    ///
318    /// #[derive(Serialize, JsonSchema)]
319    /// struct Output {
320    ///     result: f64,
321    ///     timestamp: String,
322    /// }
323    ///
324    /// // In your HasOutputSchema implementation:
325    /// fn get_output_schema() -> &'static ToolSchema {
326    ///     static SCHEMA: OnceLock<ToolSchema> = OnceLock::new();
327    ///     SCHEMA.get_or_init(|| {
328    ///         let json_schema = schema_for!(Output);
329    ///         ToolSchema::from_schemars(json_schema)
330    ///             .expect("Valid schema")
331    ///     })
332    /// }
333    /// ```
334    fn from_schemars(schema: schemars::Schema) -> Result<Self, String>
335    where
336        Self: Sized;
337}
338
339impl ToolSchemaExt for ToolSchema {
340    fn from_schemars(schema: schemars::Schema) -> Result<Self, String> {
341        let json_value = serde_json::to_value(schema)
342            .map_err(|e| format!("Failed to serialize schemars schema: {}", e))?;
343
344        let obj = json_value
345            .as_object()
346            .ok_or_else(|| "Schema is not an object".to_string())?;
347
348        // Validate root is an object schema (ToolSchema requires type: "object")
349        let is_object = obj.get("type").is_some_and(|v| {
350            v.as_str() == Some("object")
351                || v.as_array()
352                    .is_some_and(|arr| arr.iter().any(|t| t.as_str() == Some("object")))
353        }) || obj.contains_key("properties");
354
355        if !is_object {
356            return Err("ToolSchema requires an object schema (type: \"object\")".to_string());
357        }
358
359        // Extract definitions for $ref resolution — merge both $defs and definitions
360        let mut definitions: HashMap<String, Value> = HashMap::new();
361        for key in ["$defs", "definitions"] {
362            if let Some(defs) = obj.get(key).and_then(|v| v.as_object()) {
363                definitions.extend(defs.iter().map(|(k, v)| (k.clone(), v.clone())));
364            }
365        }
366
367        // Convert each property using the centralized converter
368        let properties = obj
369            .get("properties")
370            .and_then(|v| v.as_object())
371            .map(|props| {
372                props
373                    .iter()
374                    .map(|(k, v)| {
375                        (
376                            k.clone(),
377                            convert_value_to_json_schema_with_defs(v, &definitions),
378                        )
379                    })
380                    .collect()
381            });
382
383        let required = obj.get("required").and_then(|v| v.as_array()).map(|arr| {
384            arr.iter()
385                .filter_map(|v| v.as_str().map(String::from))
386                .collect()
387        });
388
389        // Preserve remaining top-level fields (description, title, additionalProperties, etc.)
390        let reserved = [
391            "type",
392            "properties",
393            "required",
394            "$defs",
395            "definitions",
396            "$schema",
397        ];
398        let additional: HashMap<String, Value> = obj
399            .iter()
400            .filter(|(k, _)| !reserved.contains(&k.as_str()))
401            .map(|(k, v)| (k.clone(), v.clone()))
402            .collect();
403
404        Ok(ToolSchema {
405            schema_type: "object".to_string(),
406            properties,
407            required,
408            additional,
409        })
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use schemars::{JsonSchema, schema_for};
417    use serde::{Deserialize, Serialize};
418
419    #[derive(Serialize, JsonSchema)]
420    struct TestOutput {
421        value: i32,
422        message: String,
423    }
424
425    #[test]
426    fn test_from_schemars_basic() {
427        let json_schema = schema_for!(TestOutput);
428        let result = ToolSchema::from_schemars(json_schema);
429
430        assert!(result.is_ok(), "Schema conversion should succeed");
431        let tool_schema = result.unwrap();
432        assert_eq!(tool_schema.schema_type, "object");
433    }
434
435    #[test]
436    fn test_from_schemars_with_optional_field() {
437        #[derive(Serialize, Deserialize, JsonSchema)]
438        struct OutputWithOptional {
439            required_field: String,
440            #[serde(skip_serializing_if = "Option::is_none")]
441            optional_field: Option<i32>,
442        }
443
444        let json_schema = schema_for!(OutputWithOptional);
445        let result = ToolSchema::from_schemars(json_schema);
446
447        assert!(
448            result.is_ok(),
449            "Schema with optional fields should convert successfully"
450        );
451        let schema = result.unwrap();
452        assert_eq!(schema.schema_type, "object");
453        assert!(schema.properties.is_some());
454        let props = schema.properties.as_ref().unwrap();
455        assert!(props.contains_key("required_field"));
456        assert!(props.contains_key("optional_field"));
457    }
458
459    #[test]
460    fn test_from_schemars_anyof_null() {
461        #[derive(Serialize, Deserialize, JsonSchema)]
462        struct Inner {
463            x: i32,
464        }
465
466        #[derive(Serialize, Deserialize, JsonSchema)]
467        struct WithOptionalNested {
468            name: String,
469            inner: Option<Inner>,
470        }
471
472        let json_schema = schema_for!(WithOptionalNested);
473        let result = ToolSchema::from_schemars(json_schema);
474
475        assert!(
476            result.is_ok(),
477            "Schema with anyOf/null optional nested struct should convert: {:?}",
478            result.err()
479        );
480        let schema = result.unwrap();
481        assert_eq!(schema.schema_type, "object");
482        let props = schema.properties.as_ref().unwrap();
483        assert!(props.contains_key("name"));
484        assert!(props.contains_key("inner"));
485    }
486
487    #[test]
488    fn test_from_schemars_with_nested_ref() {
489        #[derive(Serialize, Deserialize, JsonSchema)]
490        struct Nested {
491            value: f64,
492        }
493
494        #[derive(Serialize, Deserialize, JsonSchema)]
495        struct WithNested {
496            label: String,
497            nested: Nested,
498        }
499
500        let json_schema = schema_for!(WithNested);
501        let result = ToolSchema::from_schemars(json_schema);
502
503        assert!(
504            result.is_ok(),
505            "Schema with $ref nested struct should convert: {:?}",
506            result.err()
507        );
508        let schema = result.unwrap();
509        assert_eq!(schema.schema_type, "object");
510        let props = schema.properties.as_ref().unwrap();
511        assert!(props.contains_key("label"));
512        assert!(props.contains_key("nested"));
513    }
514
515    #[test]
516    fn test_from_schemars_with_legacy_definitions() {
517        // Construct a schema using "definitions" (not "$defs") to test backward compat
518        let schema_json = serde_json::json!({
519            "type": "object",
520            "properties": {
521                "item": { "$ref": "#/definitions/Item" }
522            },
523            "required": ["item"],
524            "definitions": {
525                "Item": {
526                    "type": "object",
527                    "properties": {
528                        "id": { "type": "integer" }
529                    },
530                    "required": ["id"]
531                }
532            }
533        });
534
535        let schema: schemars::Schema =
536            serde_json::from_value(schema_json).expect("valid schemars schema");
537        let result = ToolSchema::from_schemars(schema);
538
539        assert!(
540            result.is_ok(),
541            "Schema with legacy definitions should convert: {:?}",
542            result.err()
543        );
544        let tool_schema = result.unwrap();
545        assert_eq!(tool_schema.schema_type, "object");
546        let props = tool_schema.properties.as_ref().unwrap();
547        assert!(props.contains_key("item"));
548    }
549
550    #[test]
551    fn test_from_schemars_rejects_non_object() {
552        let json_schema = schema_for!(String);
553        let result = ToolSchema::from_schemars(json_schema);
554
555        assert!(result.is_err(), "Non-object root schema should be rejected");
556        assert!(
557            result
558                .unwrap_err()
559                .contains("ToolSchema requires an object schema")
560        );
561    }
562}