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 turul_mcp_protocol::ToolSchema;
26use turul_mcp_protocol::schema::JsonSchema;
27use serde_json::Value;
28use std::collections::HashMap;
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.strip_prefix("#/definitions/")
93            .or_else(|| ref_path.strip_prefix("#/$defs/"));
94
95        if let Some(name) = def_name
96            && let Some(def_schema) = definitions.get(name)
97        {
98            // Recursively convert the referenced definition
99            return convert_value_to_json_schema_with_defs(def_schema, definitions);
100        }
101        // Couldn't resolve reference - fall back to generic object
102        return JsonSchema::Object {
103            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
104            properties: None,
105            required: None,
106            additional_properties: None,
107        };
108    }
109
110    // Handle anyOf - common for Option<T> which generates anyOf: [T, null]
111    if let Some(any_of) = obj.get("anyOf").and_then(|v| v.as_array()) {
112        // Look for the non-null schema in the anyOf array
113        for schema in any_of {
114            // Skip null schemas
115            if let Some(obj) = schema.as_object() {
116                if let Some(t) = obj.get("type")
117                    && t.as_str() == Some("null")
118                {
119                    continue; // Skip null type
120                }
121                // Found non-null schema - convert it
122                return convert_value_to_json_schema_with_defs(schema, definitions);
123            }
124        }
125        // All schemas were null or couldn't parse - fall back to generic object
126        return JsonSchema::Object {
127            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
128            properties: None,
129            required: None,
130            additional_properties: None,
131        };
132    }
133
134    // Get the type field - can be string or array of strings
135    let schema_type = obj.get("type")
136        .and_then(|v| {
137            if let Some(s) = v.as_str() {
138                // Single type as string
139                Some(s.to_string())
140            } else if let Some(arr) = v.as_array() {
141                // Array of types (e.g., ["string", "null"] for Option<String>)
142                // Find the non-null type
143                for type_val in arr {
144                    if let Some(t) = type_val.as_str()
145                        && t != "null"
146                    {
147                        return Some(t.to_string());
148                    }
149                }
150                None
151            } else {
152                None
153            }
154        })
155        .or_else(|| {
156            // If no type but has properties, assume object
157            if obj.contains_key("properties") {
158                Some("object".to_string())
159            } else {
160                None
161            }
162        });
163
164    let schema_type = schema_type.as_deref();
165    // Note: Unknown schema types fall back to generic object
166
167    // Convert based on type
168    match schema_type {
169        Some("string") => JsonSchema::String {
170            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
171            pattern: obj.get("pattern").and_then(|v| v.as_str()).map(String::from),
172            min_length: obj.get("minLength").and_then(|v| v.as_u64()),
173            max_length: obj.get("maxLength").and_then(|v| v.as_u64()),
174            enum_values: obj.get("enum").and_then(|v| {
175                v.as_array().and_then(|arr| {
176                    arr.iter()
177                        .map(|v| v.as_str().map(String::from))
178                        .collect::<Option<Vec<_>>>()
179                })
180            }),
181        },
182
183        Some("number") => JsonSchema::Number {
184            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
185            minimum: obj.get("minimum").and_then(|v| v.as_f64()),
186            maximum: obj.get("maximum").and_then(|v| v.as_f64()),
187        },
188
189        Some("integer") => JsonSchema::Integer {
190            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
191            minimum: obj.get("minimum").and_then(|v| v.as_i64()),
192            maximum: obj.get("maximum").and_then(|v| v.as_i64()),
193        },
194
195        Some("boolean") => JsonSchema::Boolean {
196            description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
197        },
198
199        Some("array") => {
200            // Recursively convert array items
201            let items = obj.get("items")
202                .map(|v| Box::new(convert_value_to_json_schema_with_defs(v, definitions)));
203
204            JsonSchema::Array {
205                description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
206                items,
207                min_items: obj.get("minItems").and_then(|v| v.as_u64()),
208                max_items: obj.get("maxItems").and_then(|v| v.as_u64()),
209            }
210        },
211
212        Some("object") => {
213            // Recursively convert properties
214            let properties = obj.get("properties")
215                .and_then(|v| v.as_object())
216                .map(|props| {
217                    props.iter()
218                        .map(|(k, v)| {
219                            (k.clone(), convert_value_to_json_schema_with_defs(v, definitions))
220                        })
221                        .collect::<HashMap<_, _>>()
222                });
223
224            // Get required fields
225            let required = obj.get("required")
226                .and_then(|v| v.as_array())
227                .map(|arr| {
228                    arr.iter()
229                        .filter_map(|v| v.as_str().map(String::from))
230                        .collect()
231                });
232
233            JsonSchema::Object {
234                description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
235                properties,
236                required,
237                additional_properties: obj.get("additionalProperties").and_then(|v| v.as_bool()),
238            }
239        },
240
241        _ => {
242            // Unknown type, $ref, anyOf, oneOf, allOf, etc.
243            // Return generic object (lossy but safe)
244            JsonSchema::Object {
245                description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
246                properties: None,
247                required: None,
248                additional_properties: None,
249            }
250        }
251    }
252}
253
254/// Extension trait for ToolSchema to support schemars conversion
255///
256/// This trait is automatically implemented for `ToolSchema`, providing the
257/// `from_schemars()` method for converting schemars schemas to MCP format.
258pub trait ToolSchemaExt {
259    /// Convert a schemars JSON Schema to MCP ToolSchema
260    ///
261    /// This enables auto-generating tool output schemas from Rust types using the
262    /// `schemars` crate's `JsonSchema` derive macro.
263    ///
264    /// # Arguments
265    ///
266    /// * `schema` - A schemars Schema generated via `schema_for!()`
267    ///
268    /// # Returns
269    ///
270    /// * `Ok(ToolSchema)` - Successfully converted schema
271    /// * `Err(String)` - Conversion error message
272    ///
273    /// # Example
274    ///
275    /// ```rust
276    /// use turul_mcp_builders::ToolSchemaExt;
277    /// use turul_mcp_protocol::ToolSchema;
278    /// use schemars::{JsonSchema, schema_for};
279    /// use serde::Serialize;
280    /// use std::sync::OnceLock;
281    ///
282    /// #[derive(Serialize, JsonSchema)]
283    /// struct Output {
284    ///     result: f64,
285    ///     timestamp: String,
286    /// }
287    ///
288    /// // In your HasOutputSchema implementation:
289    /// fn get_output_schema() -> &'static ToolSchema {
290    ///     static SCHEMA: OnceLock<ToolSchema> = OnceLock::new();
291    ///     SCHEMA.get_or_init(|| {
292    ///         let json_schema = schema_for!(Output);
293    ///         ToolSchema::from_schemars(json_schema)
294    ///             .expect("Valid schema")
295    ///     })
296    /// }
297    /// ```
298    fn from_schemars(schema: schemars::Schema) -> Result<Self, String>
299    where
300        Self: Sized;
301}
302
303impl ToolSchemaExt for ToolSchema {
304    fn from_schemars(schema: schemars::Schema) -> Result<Self, String> {
305        // Convert schemars Schema to serde_json::Value
306        let json_value = serde_json::to_value(schema)
307            .map_err(|e| format!("Failed to serialize schemars schema: {}", e))?;
308
309        // Deserialize into ToolSchema
310        serde_json::from_value(json_value)
311            .map_err(|e| format!("Failed to deserialize ToolSchema: {}", e))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use schemars::{JsonSchema, schema_for};
319    use serde::Serialize;
320
321    #[derive(Serialize, JsonSchema)]
322    struct TestOutput {
323        value: i32,
324        message: String,
325    }
326
327    #[test]
328    fn test_from_schemars_basic() {
329        let json_schema = schema_for!(TestOutput);
330        let result = ToolSchema::from_schemars(json_schema);
331
332        assert!(result.is_ok(), "Schema conversion should succeed");
333        let tool_schema = result.unwrap();
334        assert_eq!(tool_schema.schema_type, "object");
335    }
336
337    #[test]
338    fn test_from_schemars_with_optional_field() {
339        #[derive(Serialize, JsonSchema)]
340        struct OutputWithOptional {
341            required_field: String,
342            #[serde(skip_serializing_if = "Option::is_none")]
343            optional_field: Option<i32>,
344        }
345
346        let json_schema = schema_for!(OutputWithOptional);
347        let result = ToolSchema::from_schemars(json_schema);
348
349        // Note: schemars may generate complex schemas with anyOf/oneOf for Option fields
350        // This is expected behavior - the test just verifies the conversion doesn't panic
351        match result {
352            Ok(schema) => {
353                assert_eq!(schema.schema_type, "object", "Should convert to object schema");
354            },
355            Err(e) => {
356                // This is acceptable - complex schemas with anyOf/oneOf may not convert
357                // Users should use simpler schema patterns for tool outputs
358                eprintln!("Schema conversion failed (expected for complex optional patterns): {}", e);
359            }
360        }
361    }
362}