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}