Skip to main content

endpoint_libs/model/
json_schema.rs

1//! Conversion of the endpoint [`Type`] model into JSON Schema, as required by
2//! MCP (Model Context Protocol) tool definitions (`inputSchema` / `outputSchema`).
3//!
4//! The conversion is a pure function over [`Type`]; `StructRef`/`EnumRef`/
5//! `StructTable` references are resolved through a caller-provided
6//! [`TypeRegistry`] and emitted as `$ref` entries under `$defs`.
7
8use std::collections::BTreeMap;
9
10use convert_case::{Case, Casing};
11use eyre::{Result, bail};
12use serde_json::{Value, json};
13
14use crate::model::endpoint::EndpointSchema;
15use crate::model::types::{Field, Type};
16
17/// Name → [`Type`] map used to resolve `StructRef`, `EnumRef` and
18/// `StructTable` while converting to JSON Schema.
19///
20/// Built by the caller (a server at startup, or `endpoint-gen`) from the known
21/// struct and enum definitions.
22#[derive(Debug, Default, Clone)]
23pub struct TypeRegistry {
24    structs: BTreeMap<String, Type>,
25    enums: BTreeMap<String, Type>,
26}
27
28impl TypeRegistry {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    /// Indexes `Struct` and `Enum` definitions found in `ty`, recursing into
34    /// struct fields and container types so nested definitions are captured.
35    pub fn add(&mut self, ty: &Type) {
36        match ty {
37            Type::Struct { name, fields } => {
38                self.structs.insert(name.clone(), ty.clone());
39                for field in fields {
40                    self.add(&field.ty);
41                }
42            }
43            Type::Enum { name, .. } => {
44                self.enums.insert(name.clone(), ty.clone());
45            }
46            Type::Vec(inner) | Type::Optional(inner) => self.add(inner),
47            _ => {}
48        }
49    }
50
51    /// Indexes every type in `tys`. See [`TypeRegistry::add`].
52    pub fn add_all<'a>(&mut self, tys: impl IntoIterator<Item = &'a Type>) {
53        for ty in tys {
54            self.add(ty);
55        }
56    }
57
58    /// Indexes all types referenced by an endpoint's parameters, returns and
59    /// stream response.
60    pub fn add_endpoint(&mut self, schema: &EndpointSchema) {
61        self.add_all(schema.parameters.iter().map(|f| &f.ty));
62        self.add_all(schema.returns.iter().map(|f| &f.ty));
63        if let Some(stream) = &schema.stream_response {
64            self.add(stream);
65        }
66    }
67
68    pub fn get_struct(&self, name: &str) -> Option<&Type> {
69        self.structs.get(name)
70    }
71
72    pub fn get_enum(&self, name: &str) -> Option<&Type> {
73        self.enums.get(name)
74    }
75}
76
77/// Definition name used for `$defs`/`$ref`. Enums keep their optional `Enum`
78/// prefix (mirrors `prefixed_name` handling in generated Rust code).
79fn enum_def_name(name: &str, prefixed_name: bool) -> String {
80    if prefixed_name {
81        format!("Enum{}", name.to_case(Case::Pascal))
82    } else {
83        name.to_case(Case::Pascal)
84    }
85}
86
87fn struct_def_name(name: &str) -> String {
88    name.to_case(Case::Pascal)
89}
90
91fn ref_to(def_name: &str) -> Value {
92    json!({ "$ref": format!("#/$defs/{def_name}") })
93}
94
95/// Builds an object schema over `fields`, in camelCase (matching the
96/// `#[serde(rename_all = "camelCase")]` on generated structs). Fields whose
97/// type is `Optional` are excluded from `required`.
98fn fields_to_object_schema(
99    fields: &[Field],
100    registry: &TypeRegistry,
101    defs: &mut BTreeMap<String, Value>,
102) -> Result<Value> {
103    let mut properties = serde_json::Map::new();
104    let mut required = Vec::new();
105
106    for field in fields {
107        let key = field.name.to_case(Case::Camel);
108        let mut schema = match &field.ty {
109            // Unwrap Optional at the field level: the field is simply not required.
110            Type::Optional(inner) => inner.to_json_schema(registry, defs)?,
111            other => {
112                required.push(Value::String(key.clone()));
113                other.to_json_schema(registry, defs)?
114            }
115        };
116        if !field.description.is_empty()
117            && let Value::Object(map) = &mut schema
118        {
119            map.entry("description")
120                .or_insert_with(|| Value::String(field.description.clone()));
121        }
122        properties.insert(key, schema);
123    }
124
125    let mut obj = serde_json::Map::new();
126    obj.insert("type".into(), "object".into());
127    obj.insert("properties".into(), Value::Object(properties));
128    obj.insert("required".into(), Value::Array(required));
129    obj.insert("additionalProperties".into(), Value::Bool(false));
130    Ok(Value::Object(obj))
131}
132
133fn enum_to_schema(name: &str, variants: &[crate::model::types::EnumVariant]) -> Value {
134    let one_of: Vec<Value> = variants
135        .iter()
136        .map(|v| {
137            let mut entry = serde_json::Map::new();
138            entry.insert("const".into(), json!(v.value));
139            entry.insert("title".into(), Value::String(v.name.clone()));
140            if !v.description.is_empty() {
141                entry.insert("description".into(), Value::String(v.description.clone()));
142            }
143            Value::Object(entry)
144        })
145        .collect();
146    json!({
147        "type": "integer",
148        "title": name,
149        "oneOf": one_of,
150    })
151}
152
153impl Type {
154    /// Converts this type to a JSON Schema fragment.
155    ///
156    /// `Struct`/`Enum` definitions (inline or by reference) are recorded in
157    /// `defs` and referenced via `$ref` so shared definitions are emitted once.
158    /// Returns an error for `StructRef`/`EnumRef`/`StructTable` names that are
159    /// not present in `registry` — callers should fail at startup rather than
160    /// emit a schema with dangling references.
161    pub fn to_json_schema(
162        &self,
163        registry: &TypeRegistry,
164        defs: &mut BTreeMap<String, Value>,
165    ) -> Result<Value> {
166        Ok(match self {
167            Type::UInt32 => json!({ "type": "integer", "minimum": 0, "maximum": u32::MAX }),
168            Type::Int32 => json!({ "type": "integer", "minimum": i32::MIN, "maximum": i32::MAX }),
169            Type::Int64 => json!({ "type": "integer" }),
170            Type::TimeStampMs => {
171                json!({ "type": "integer", "description": "Unix timestamp in milliseconds" })
172            }
173            Type::Float64 => json!({ "type": "number" }),
174            Type::Boolean => json!({ "type": "boolean" }),
175            Type::String => json!({ "type": "string" }),
176            Type::Bytea => json!({ "type": "string", "contentEncoding": "base64" }),
177            Type::UUID => json!({ "type": "string", "format": "uuid" }),
178            Type::NanoId { len } => json!({
179                "type": "string",
180                "minLength": len,
181                "maxLength": len,
182                "pattern": "^[0-9A-Za-z]+$",
183            }),
184            // Both IPv4 and IPv6 are accepted on the wire, so no single JSON
185            // Schema `format` applies.
186            Type::IpAddr => json!({ "type": "string", "description": "IPv4 or IPv6 address" }),
187            Type::Object => json!({ "type": "object" }),
188            Type::Unit => json!({ "type": "null" }),
189            Type::BlockchainDecimal => {
190                json!({ "type": "string", "description": "Decimal number as string" })
191            }
192            Type::BlockchainAddress => {
193                json!({ "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" })
194            }
195            Type::BlockchainTransactionHash => {
196                json!({ "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" })
197            }
198            Type::Vec(inner) => {
199                json!({ "type": "array", "items": inner.to_json_schema(registry, defs)? })
200            }
201            Type::Optional(inner) => {
202                // Standalone Optional (not unwrapped by a parent object):
203                // nullable variant of the inner schema.
204                let inner = inner.to_json_schema(registry, defs)?;
205                json!({ "anyOf": [inner, { "type": "null" }] })
206            }
207            Type::Struct { name, fields } => {
208                let def_name = struct_def_name(name);
209                if !defs.contains_key(&def_name) {
210                    // Reserve the slot first so recursive references terminate.
211                    defs.insert(def_name.clone(), Value::Null);
212                    let schema = fields_to_object_schema(fields, registry, defs)?;
213                    defs.insert(def_name.clone(), schema);
214                }
215                ref_to(&def_name)
216            }
217            Type::StructRef(name) => {
218                let Some(ty) = registry.get_struct(name).cloned() else {
219                    bail!("unresolved StructRef: {name} (not present in TypeRegistry)");
220                };
221                ty.to_json_schema(registry, defs)?
222            }
223            Type::StructTable { struct_ref } => {
224                let Some(ty) = registry.get_struct(struct_ref).cloned() else {
225                    bail!("unresolved StructTable ref: {struct_ref} (not present in TypeRegistry)");
226                };
227                let items = ty.to_json_schema(registry, defs)?;
228                json!({ "type": "array", "items": items })
229            }
230            Type::Enum { name, variants } => {
231                let def_name = enum_def_name(name, false);
232                defs.entry(def_name.clone())
233                    .or_insert_with(|| enum_to_schema(&def_name, variants));
234                ref_to(&def_name)
235            }
236            Type::EnumRef {
237                name,
238                prefixed_name,
239            } => {
240                let Some(Type::Enum { variants, .. }) = registry.get_enum(name) else {
241                    bail!("unresolved EnumRef: {name} (not present in TypeRegistry)");
242                };
243                let def_name = enum_def_name(name, *prefixed_name);
244                if !defs.contains_key(&def_name) {
245                    let schema = enum_to_schema(&def_name, variants);
246                    defs.insert(def_name.clone(), schema);
247                }
248                ref_to(&def_name)
249            }
250        })
251    }
252}
253
254/// Attaches accumulated `$defs` to a root schema object, if any.
255fn attach_defs(mut root: Value, defs: BTreeMap<String, Value>) -> Value {
256    if !defs.is_empty()
257        && let Value::Object(map) = &mut root
258    {
259        map.insert("$defs".into(), json!(defs));
260    }
261    root
262}
263
264impl EndpointSchema {
265    /// MCP tool name for this endpoint: the endpoint name in snake_case
266    /// (e.g. `UserListSymbols` → `user_list_symbols`).
267    pub fn tool_name(&self) -> String {
268        self.name.to_case(Case::Snake)
269    }
270
271    /// MCP `inputSchema`: an object schema over `parameters`. Non-`Optional`
272    /// parameters are listed in `required`.
273    pub fn to_mcp_input_schema(&self, registry: &TypeRegistry) -> Result<Value> {
274        let mut defs = BTreeMap::new();
275        let root = fields_to_object_schema(&self.parameters, registry, &mut defs)?;
276        Ok(attach_defs(root, defs))
277    }
278
279    /// MCP `outputSchema`: an object schema over `returns`.
280    pub fn to_mcp_output_schema(&self, registry: &TypeRegistry) -> Result<Value> {
281        let mut defs = BTreeMap::new();
282        let root = fields_to_object_schema(&self.returns, registry, &mut defs)?;
283        Ok(attach_defs(root, defs))
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::model::types::EnumVariant;
291
292    fn schema_of(ty: &Type) -> Value {
293        let registry = TypeRegistry::new();
294        let mut defs = BTreeMap::new();
295        let schema = ty.to_json_schema(&registry, &mut defs).unwrap();
296        attach_defs(schema, defs)
297    }
298
299    #[test]
300    fn primitives() {
301        assert_eq!(
302            schema_of(&Type::UInt32),
303            json!({ "type": "integer", "minimum": 0, "maximum": u32::MAX })
304        );
305        assert_eq!(
306            schema_of(&Type::Int32),
307            json!({ "type": "integer", "minimum": i32::MIN, "maximum": i32::MAX })
308        );
309        assert_eq!(schema_of(&Type::Int64), json!({ "type": "integer" }));
310        assert_eq!(schema_of(&Type::Float64), json!({ "type": "number" }));
311        assert_eq!(schema_of(&Type::Boolean), json!({ "type": "boolean" }));
312        assert_eq!(schema_of(&Type::String), json!({ "type": "string" }));
313        assert_eq!(schema_of(&Type::Object), json!({ "type": "object" }));
314        assert_eq!(schema_of(&Type::Unit), json!({ "type": "null" }));
315    }
316
317    #[test]
318    fn string_formats() {
319        assert_eq!(
320            schema_of(&Type::Bytea),
321            json!({ "type": "string", "contentEncoding": "base64" })
322        );
323        assert_eq!(
324            schema_of(&Type::UUID),
325            json!({ "type": "string", "format": "uuid" })
326        );
327        assert_eq!(
328            schema_of(&Type::NanoId { len: 21 }),
329            json!({
330                "type": "string",
331                "minLength": 21,
332                "maxLength": 21,
333                "pattern": "^[0-9A-Za-z]+$",
334            })
335        );
336        assert_eq!(
337            schema_of(&Type::BlockchainAddress),
338            json!({ "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" })
339        );
340        assert_eq!(
341            schema_of(&Type::BlockchainTransactionHash),
342            json!({ "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" })
343        );
344    }
345
346    #[test]
347    fn containers() {
348        assert_eq!(
349            schema_of(&Type::vec(Type::String)),
350            json!({ "type": "array", "items": { "type": "string" } })
351        );
352        assert_eq!(
353            schema_of(&Type::optional(Type::Int64)),
354            json!({ "anyOf": [{ "type": "integer" }, { "type": "null" }] })
355        );
356    }
357
358    #[test]
359    fn inline_struct_goes_to_defs() {
360        let ty = Type::struct_(
361            "UserInfo",
362            vec![
363                Field::new("user_id", Type::Int64),
364                Field::new("email", Type::optional(Type::String)),
365            ],
366        );
367        let schema = schema_of(&ty);
368        assert_eq!(schema["$ref"], json!("#/$defs/UserInfo"));
369        let def = &schema["$defs"]["UserInfo"];
370        assert_eq!(def["type"], json!("object"));
371        assert_eq!(def["properties"]["userId"]["type"], json!("integer"));
372        assert_eq!(def["properties"]["email"]["type"], json!("string"));
373        // Optional field is not required.
374        assert_eq!(def["required"], json!(["userId"]));
375        assert_eq!(def["additionalProperties"], json!(false));
376    }
377
378    #[test]
379    fn enum_schema() {
380        let ty = Type::enum_(
381            "role",
382            vec![
383                EnumVariant::new_with_description("Admin", "administrator", 1),
384                EnumVariant::new("User", 2),
385            ],
386        );
387        let schema = schema_of(&ty);
388        assert_eq!(schema["$ref"], json!("#/$defs/Role"));
389        let def = &schema["$defs"]["Role"];
390        assert_eq!(def["type"], json!("integer"));
391        assert_eq!(def["oneOf"][0]["const"], json!(1));
392        assert_eq!(def["oneOf"][0]["title"], json!("Admin"));
393        assert_eq!(def["oneOf"][0]["description"], json!("administrator"));
394        assert_eq!(def["oneOf"][1], json!({ "const": 2, "title": "User" }));
395    }
396
397    #[test]
398    fn struct_ref_resolves_through_registry() {
399        let user_info = Type::struct_("UserInfo", vec![Field::new("user_id", Type::Int64)]);
400        let mut registry = TypeRegistry::new();
401        registry.add(&user_info);
402
403        let mut defs = BTreeMap::new();
404        let schema = Type::struct_ref("UserInfo")
405            .to_json_schema(&registry, &mut defs)
406            .unwrap();
407        assert_eq!(schema, json!({ "$ref": "#/$defs/UserInfo" }));
408        assert!(defs.contains_key("UserInfo"));
409    }
410
411    #[test]
412    fn struct_table_resolves_to_array() {
413        let row = Type::struct_("Row", vec![Field::new("id", Type::Int64)]);
414        let mut registry = TypeRegistry::new();
415        registry.add(&row);
416
417        let mut defs = BTreeMap::new();
418        let schema = Type::struct_table("Row")
419            .to_json_schema(&registry, &mut defs)
420            .unwrap();
421        assert_eq!(
422            schema,
423            json!({ "type": "array", "items": { "$ref": "#/$defs/Row" } })
424        );
425    }
426
427    #[test]
428    fn unresolved_refs_error() {
429        let registry = TypeRegistry::new();
430        let mut defs = BTreeMap::new();
431        assert!(
432            Type::struct_ref("Missing")
433                .to_json_schema(&registry, &mut defs)
434                .is_err()
435        );
436        assert!(
437            Type::enum_ref("Missing", true)
438                .to_json_schema(&registry, &mut defs)
439                .is_err()
440        );
441        assert!(
442            Type::struct_table("Missing")
443                .to_json_schema(&registry, &mut defs)
444                .is_err()
445        );
446    }
447
448    #[test]
449    fn enum_ref_prefixed_naming() {
450        let role = Type::enum_("role", vec![EnumVariant::new("Admin", 1)]);
451        let mut registry = TypeRegistry::new();
452        registry.add(&role);
453
454        let mut defs = BTreeMap::new();
455        let schema = Type::enum_ref("role", true)
456            .to_json_schema(&registry, &mut defs)
457            .unwrap();
458        assert_eq!(schema, json!({ "$ref": "#/$defs/EnumRole" }));
459        assert!(defs.contains_key("EnumRole"));
460    }
461
462    #[test]
463    fn shared_defs_emitted_once() {
464        let user_info = Type::struct_("UserInfo", vec![Field::new("user_id", Type::Int64)]);
465        let outer = Type::struct_(
466            "Outer",
467            vec![
468                Field::new("a", user_info.clone()),
469                Field::new("b", user_info),
470            ],
471        );
472        let schema = schema_of(&outer);
473        let def = &schema["$defs"]["Outer"];
474        assert_eq!(
475            def["properties"]["a"],
476            json!({ "$ref": "#/$defs/UserInfo" })
477        );
478        assert_eq!(
479            def["properties"]["b"],
480            json!({ "$ref": "#/$defs/UserInfo" })
481        );
482        assert!(schema["$defs"]["UserInfo"].is_object());
483    }
484
485    #[test]
486    fn tool_name_is_snake_case() {
487        let schema = EndpointSchema::new("UserListSymbols", 10020, vec![], vec![]);
488        assert_eq!(schema.tool_name(), "user_list_symbols");
489    }
490
491    #[test]
492    fn endpoint_input_schema_golden() {
493        let endpoint = EndpointSchema::new(
494            "UserGetProfile",
495            10010,
496            vec![
497                Field::new("user_id", Type::Int64),
498                Field::new_with_description(
499                    "nickname",
500                    "Optional display name",
501                    Type::optional(Type::String),
502                ),
503            ],
504            vec![Field::new("profile", Type::struct_ref("UserProfile"))],
505        );
506
507        let profile = Type::struct_(
508            "UserProfile",
509            vec![
510                Field::new("user_id", Type::Int64),
511                Field::new("role", Type::enum_ref("role", true)),
512            ],
513        );
514        let role = Type::enum_("role", vec![EnumVariant::new("Admin", 1)]);
515        let mut registry = TypeRegistry::new();
516        registry.add(&profile);
517        registry.add(&role);
518
519        let input = endpoint.to_mcp_input_schema(&registry).unwrap();
520        assert_eq!(
521            input,
522            json!({
523                "type": "object",
524                "properties": {
525                    "userId": { "type": "integer" },
526                    "nickname": { "type": "string", "description": "Optional display name" },
527                },
528                "required": ["userId"],
529                "additionalProperties": false,
530            })
531        );
532
533        let output = endpoint.to_mcp_output_schema(&registry).unwrap();
534        assert_eq!(
535            output["properties"]["profile"],
536            json!({ "$ref": "#/$defs/UserProfile" })
537        );
538        assert_eq!(output["required"], json!(["profile"]));
539        assert_eq!(
540            output["$defs"]["UserProfile"]["properties"]["role"],
541            json!({ "$ref": "#/$defs/EnumRole" })
542        );
543        assert!(output["$defs"]["EnumRole"].is_object());
544    }
545}