Skip to main content

rust_web_server/schema/
mod.rs

1//! JSON Schema for Rust types, for MCP tool `inputSchema`/`outputSchema`.
2//!
3//! [`JsonSchema::json_schema`] returns a type's schema as JSON text. It is
4//! implemented for the primitives, `String`, `Option`, `Vec`, sets, maps
5//! keyed by strings and smart pointers; `#[derive(JsonSchema)]` (`macros`
6//! feature) implements it for a struct with named fields or an enum of unit
7//! variants, following the type's `#[serde(...)]` renames so the schema
8//! describes what `serde_json` actually reads and writes.
9//!
10//! The schemas use the subset of JSON Schema 2020-12 MCP clients rely on:
11//! `type`, `properties`, `required`, `items`, `additionalProperties`, `enum`,
12//! `anyOf` (for `Option`), `minimum`, `description`.
13
14use std::cell::Cell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16use std::rc::Rc;
17use std::sync::Arc;
18
19use crate::mcp::json_escape;
20
21/// A type with a JSON Schema.
22pub trait JsonSchema {
23    /// The schema, as a JSON object.
24    fn json_schema() -> String;
25
26    /// `true` when a struct field of this type may be left out (`Option`).
27    fn is_optional() -> bool {
28        false
29    }
30}
31
32/// Nesting deeper than this yields `{}` (any value): a type that contains
33/// itself (`struct Node { children: Vec<Node> }`) would otherwise recurse
34/// forever.
35pub const MAX_DEPTH: u32 = 32;
36
37thread_local! {
38    static DEPTH: Cell<u32> = const { Cell::new(0) };
39}
40
41/// `T`'s schema, or `{}` past [`MAX_DEPTH`] levels of nesting. Used for every
42/// field and element type, so recursive types end in "any value".
43pub fn nested<T: JsonSchema + ?Sized>() -> String {
44    let depth = DEPTH.with(|d| d.get());
45    if depth >= MAX_DEPTH {
46        return "{}".to_string();
47    }
48    DEPTH.with(|d| d.set(depth + 1));
49    let schema = T::json_schema();
50    DEPTH.with(|d| d.set(depth));
51    schema
52}
53
54/// `schema` with `"description"` added (`schema` must be a JSON object).
55pub fn with_description(schema: &str, description: &str) -> String {
56    if description.is_empty() {
57        return schema.to_string();
58    }
59    let rest = schema.trim_start().strip_prefix('{').unwrap_or("}").trim_start();
60    let separator = if rest.starts_with('}') { "" } else { "," };
61    format!(r#"{{"description":"{}"{separator}{rest}"#, json_escape(description))
62}
63
64/// Builds an object schema property by property — what `#[derive(JsonSchema)]`
65/// expands to.
66pub struct ObjectSchema {
67    description: String,
68    properties: Vec<String>,
69    required: Vec<String>,
70}
71
72impl ObjectSchema {
73    /// An object with `description` (may be empty).
74    pub fn new(description: &str) -> Self {
75        ObjectSchema { description: description.to_string(), properties: Vec::new(), required: Vec::new() }
76    }
77
78    /// Add property `name` with `schema`; `required` lists it in `required`.
79    pub fn property(&mut self, name: &str, schema: &str, description: &str, required: bool) {
80        self.properties.push(format!(r#""{}":{}"#, json_escape(name), with_description(schema, description)));
81        if required {
82            self.required.push(format!(r#""{}""#, json_escape(name)));
83        }
84    }
85
86    /// The finished schema.
87    pub fn finish(self) -> String {
88        let required = if self.required.is_empty() {
89            String::new()
90        } else {
91            format!(r#","required":[{}]"#, self.required.join(","))
92        };
93        let schema = format!(r#"{{"type":"object","properties":{{{}}}{required}}}"#, self.properties.join(","));
94        with_description(&schema, &self.description)
95    }
96}
97
98/// A string schema allowing only `values` — a unit-variant enum.
99pub fn string_enum(description: &str, values: &[&str]) -> String {
100    let values: Vec<String> = values.iter().map(|v| format!(r#""{}""#, json_escape(v))).collect();
101    with_description(&format!(r#"{{"type":"string","enum":[{}]}}"#, values.join(",")), description)
102}
103
104/// `true` when `schema` describes a JSON object — what MCP requires of an
105/// `outputSchema`.
106pub fn is_object_schema(schema: &str) -> bool {
107    crate::mcp::json_rpc::object_members(schema)
108        .and_then(|members| members.into_iter().find(|(k, _)| k == "type"))
109        .is_some_and(|(_, v)| v == r#""object""#)
110}
111
112macro_rules! fixed_schema {
113    ($schema:expr => $($t:ty),*) => {
114        $(impl JsonSchema for $t {
115            fn json_schema() -> String { $schema.to_string() }
116        })*
117    };
118}
119
120fixed_schema!(r#"{"type":"boolean"}"# => bool);
121fixed_schema!(r#"{"type":"integer"}"# => i8, i16, i32, i64, i128, isize);
122fixed_schema!(r#"{"type":"integer","minimum":0}"# => u8, u16, u32, u64, u128, usize);
123fixed_schema!(r#"{"type":"number"}"# => f32, f64);
124fixed_schema!(r#"{"type":"string"}"# => String, str, char);
125fixed_schema!(r#"{"type":"null"}"# => ());
126
127impl<T: JsonSchema> JsonSchema for Option<T> {
128    fn json_schema() -> String {
129        format!(r#"{{"anyOf":[{},{{"type":"null"}}]}}"#, nested::<T>())
130    }
131    fn is_optional() -> bool {
132        true
133    }
134}
135
136macro_rules! array_schema {
137    ($($t:ident),*) => {
138        $(impl<T: JsonSchema> JsonSchema for $t<T> {
139            fn json_schema() -> String { format!(r#"{{"type":"array","items":{}}}"#, nested::<T>()) }
140        })*
141    };
142}
143
144array_schema!(Vec, VecDeque, BTreeSet);
145
146impl<T: JsonSchema, S> JsonSchema for HashSet<T, S> {
147    fn json_schema() -> String {
148        format!(r#"{{"type":"array","items":{}}}"#, nested::<T>())
149    }
150}
151
152impl<T: JsonSchema> JsonSchema for [T] {
153    fn json_schema() -> String {
154        format!(r#"{{"type":"array","items":{}}}"#, nested::<T>())
155    }
156}
157
158impl<T: JsonSchema, const N: usize> JsonSchema for [T; N] {
159    fn json_schema() -> String {
160        format!(r#"{{"type":"array","items":{},"minItems":{N},"maxItems":{N}}}"#, nested::<T>())
161    }
162}
163
164impl<T: JsonSchema, S> JsonSchema for HashMap<String, T, S> {
165    fn json_schema() -> String {
166        format!(r#"{{"type":"object","additionalProperties":{}}}"#, nested::<T>())
167    }
168}
169
170impl<T: JsonSchema> JsonSchema for BTreeMap<String, T> {
171    fn json_schema() -> String {
172        format!(r#"{{"type":"object","additionalProperties":{}}}"#, nested::<T>())
173    }
174}
175
176macro_rules! pointer_schema {
177    ($($t:ident),*) => {
178        $(impl<T: JsonSchema + ?Sized> JsonSchema for $t<T> {
179            fn json_schema() -> String { T::json_schema() }
180            fn is_optional() -> bool { T::is_optional() }
181        })*
182    };
183}
184
185pointer_schema!(Box, Arc, Rc);
186
187impl<T: JsonSchema + ?Sized> JsonSchema for &T {
188    fn json_schema() -> String {
189        T::json_schema()
190    }
191    fn is_optional() -> bool {
192        T::is_optional()
193    }
194}
195
196/// Any JSON value.
197#[cfg(feature = "serde")]
198impl JsonSchema for serde_json::Value {
199    fn json_schema() -> String {
200        "{}".to_string()
201    }
202}
203
204#[cfg(test)]
205mod tests;