Skip to main content

component_shape_mcp/
input_schema.rs

1use super::*;
2
3pub fn schema_for_input(input: McpInput) -> McpSchema {
4    match input.input_shape() {
5        McpInputShape::Unsupported => McpSchema::impossible(),
6        McpInputShape::Scalar(kind) => schema_for_primitive(kind),
7        McpInputShape::List(kind) => array_schema(schema_for_primitive(kind)),
8        McpInputShape::Set(kind) => unique_array_schema(schema_for_primitive(kind)),
9        McpInputShape::Range(kind) => range_schema(schema_for_range_bound(kind)),
10        McpInputShape::Object => McpSchema::object(),
11    }
12}
13
14/// Build a compact JSON descriptor for component-shape MCP input metadata.
15///
16/// Descriptor resources use this alongside full JSON Schema so clients can
17/// display the intended component/control shape without reverse-engineering
18/// schema details.
19pub fn mcp_input_descriptor_value(input: McpInput) -> Value {
20    match input.input_shape() {
21        McpInputShape::Unsupported => json!({
22            "supported": false,
23            "shape": "unsupported",
24        }),
25        McpInputShape::Scalar(kind) => json!({
26            "supported": true,
27            "shape": "scalar",
28            "primitive": kind.as_str(),
29        }),
30        McpInputShape::List(kind) => json!({
31            "supported": true,
32            "shape": "list",
33            "items": kind.as_str(),
34        }),
35        McpInputShape::Set(kind) => json!({
36            "supported": true,
37            "shape": "set",
38            "items": kind.as_str(),
39        }),
40        McpInputShape::Range(kind) => json!({
41            "supported": true,
42            "shape": "range",
43            "bound": kind.as_str(),
44        }),
45        McpInputShape::Object => json!({
46            "supported": true,
47            "shape": "object",
48        }),
49    }
50}
51
52/// Attach generated validation metadata and supported JSON Schema hints.
53pub fn apply_validation_schema_metadata(
54    object: &mut Map<String, Value>,
55    extension_key: &str,
56    rules: &[McpValidationRule],
57) {
58    if !rules.is_empty() {
59        object.insert(
60            extension_key.to_string(),
61            Value::Array(rules.iter().map(|rule| rule.to_value()).collect()),
62        );
63    }
64    apply_validation_schema_hints(object, rules);
65}
66
67/// Reflect supported literal validation parameters into a JSON Schema object.
68pub fn apply_validation_schema_hints(object: &mut Map<String, Value>, rules: &[McpValidationRule]) {
69    for rule in rules {
70        match rule.validator() {
71            "LenValidation" => apply_len_validation_schema_hint(*rule, object),
72            "RangeValidation" => apply_range_validation_schema_hint(*rule, object),
73            "NonEmptyValidation" => apply_non_empty_validation_schema_hint(object),
74            _ => {},
75        }
76    }
77}
78
79fn apply_len_validation_schema_hint(rule: McpValidationRule, object: &mut Map<String, Value>) {
80    let Some(schema_type) = primary_schema_type(object) else {
81        return;
82    };
83    let (min_keyword, max_keyword) = match schema_type {
84        "string" => ("minLength", "maxLength"),
85        "array" => ("minItems", "maxItems"),
86        _ => return,
87    };
88    if let Some(min) = rule
89        .params()
90        .iter()
91        .find(|param| param.name() == "min")
92        .and_then(|param| param.literal_value())
93        .and_then(literal_to_u64)
94    {
95        object.insert(min_keyword.to_string(), Value::Number(min.into()));
96    }
97    if let Some(max) = rule
98        .params()
99        .iter()
100        .find(|param| param.name() == "max")
101        .and_then(|param| param.literal_value())
102        .and_then(literal_to_u64)
103    {
104        object.insert(max_keyword.to_string(), Value::Number(max.into()));
105    }
106}
107
108fn apply_range_validation_schema_hint(rule: McpValidationRule, object: &mut Map<String, Value>) {
109    if !matches!(primary_schema_type(object), Some("integer" | "number")) {
110        return;
111    }
112    let exclusive_min = rule
113        .params()
114        .iter()
115        .find(|param| param.name() == "exclusive_min")
116        .and_then(|param| param.literal_value())
117        .and_then(literal_to_bool)
118        .unwrap_or(false);
119    let exclusive_max = rule
120        .params()
121        .iter()
122        .find(|param| param.name() == "exclusive_max")
123        .and_then(|param| param.literal_value())
124        .and_then(literal_to_bool)
125        .unwrap_or(false);
126    if let Some(min) = rule
127        .params()
128        .iter()
129        .find(|param| param.name() == "min")
130        .and_then(|param| param.literal_value())
131        .and_then(literal_to_number_value)
132    {
133        let keyword = if exclusive_min {
134            "exclusiveMinimum"
135        } else {
136            "minimum"
137        };
138        object.insert(keyword.to_string(), min);
139    }
140    if let Some(max) = rule
141        .params()
142        .iter()
143        .find(|param| param.name() == "max")
144        .and_then(|param| param.literal_value())
145        .and_then(literal_to_number_value)
146    {
147        let keyword = if exclusive_max {
148            "exclusiveMaximum"
149        } else {
150            "maximum"
151        };
152        object.insert(keyword.to_string(), max);
153    }
154}
155
156fn apply_non_empty_validation_schema_hint(object: &mut Map<String, Value>) {
157    match primary_schema_type(object) {
158        Some("string") => {
159            object
160                .entry("minLength")
161                .or_insert(Value::Number(1_u64.into()));
162        },
163        Some("array") => {
164            object
165                .entry("minItems")
166                .or_insert(Value::Number(1_u64.into()));
167        },
168        _ => {},
169    }
170}
171
172fn primary_schema_type(object: &Map<String, Value>) -> Option<&str> {
173    object.get("type").and_then(Value::as_str).or_else(|| {
174        object
175            .get("anyOf")
176            .and_then(Value::as_array)
177            .and_then(|schemas| schemas.iter().find_map(schema_type_from_value))
178    })
179}
180
181fn schema_type_from_value(value: &Value) -> Option<&str> {
182    match value {
183        Value::Object(object) => object.get("type").and_then(Value::as_str),
184        _ => None,
185    }
186}
187
188fn literal_to_u64(literal: &str) -> Option<u64> {
189    literal.parse::<u64>().ok()
190}
191
192fn literal_to_bool(literal: &str) -> Option<bool> {
193    match literal {
194        "true" => Some(true),
195        "false" => Some(false),
196        _ => None,
197    }
198}
199
200fn literal_to_number_value(literal: &str) -> Option<Value> {
201    if let Ok(value) = literal.parse::<i64>() {
202        return Some(Value::Number(value.into()));
203    }
204    if let Ok(value) = literal.parse::<u64>() {
205        return Some(Value::Number(value.into()));
206    }
207    literal
208        .parse::<f64>()
209        .ok()
210        .and_then(serde_json::Number::from_f64)
211        .map(Value::Number)
212}
213
214pub fn range_schema(bound_schema: McpSchema) -> McpSchema {
215    McpSchema::object()
216        .with_properties(McpSchemaProperties::from([
217            ("min".to_string(), nullable_schema(bound_schema.clone())),
218            ("max".to_string(), nullable_schema(bound_schema)),
219        ]))
220        .with_additional_properties(false)
221}
222
223pub fn schema_for_primitive(kind: McpPrimitiveKind) -> McpSchema {
224    match kind {
225        McpPrimitiveKind::Any => McpSchema::any(),
226        McpPrimitiveKind::Boolean => McpSchema::boolean(),
227        McpPrimitiveKind::Integer => McpSchema::integer(),
228        McpPrimitiveKind::Number => McpSchema::number(),
229        McpPrimitiveKind::Decimal => McpSchema::any_of([McpSchema::number(), McpSchema::string()]),
230        McpPrimitiveKind::String => McpSchema::string(),
231        McpPrimitiveKind::Date => McpSchema::string().with_format(McpStringFormat::Date),
232        McpPrimitiveKind::DateTime => McpSchema::string().with_format(McpStringFormat::DateTime),
233    }
234}
235
236pub fn schema_for_range_bound(kind: McpRangeBoundKind) -> McpSchema {
237    schema_for_primitive(kind.primitive_kind())
238}
239
240pub fn nullable_schema(schema: McpSchema) -> McpSchema {
241    McpSchema::any_of([schema, McpSchema::null()])
242}
243
244pub fn schema_allows_null(schema: &McpSchema) -> bool {
245    value_schema_allows_null(schema.as_value())
246}
247
248pub(crate) fn value_schema_allows_null(schema: &Value) -> bool {
249    match schema {
250        Value::Bool(value) => *value,
251        Value::Object(object) if object.is_empty() => true,
252        Value::Object(object) => {
253            object.get("type").is_some_and(type_allows_null)
254                || object.get("const").is_some_and(Value::is_null)
255                || object
256                    .get("enum")
257                    .and_then(Value::as_array)
258                    .is_some_and(|values| values.iter().any(Value::is_null))
259                || object
260                    .get("anyOf")
261                    .and_then(Value::as_array)
262                    .is_some_and(|schemas| schemas.iter().any(value_schema_allows_null))
263                || object
264                    .get("oneOf")
265                    .and_then(Value::as_array)
266                    .is_some_and(|schemas| schemas.iter().any(value_schema_allows_null))
267                || object
268                    .get("allOf")
269                    .and_then(Value::as_array)
270                    .is_some_and(|schemas| schemas.iter().all(value_schema_allows_null))
271        },
272        _ => false,
273    }
274}
275
276fn type_allows_null(value: &Value) -> bool {
277    match value {
278        Value::String(value) => value == "null",
279        Value::Array(values) => values
280            .iter()
281            .any(|value| matches!(value, Value::String(value) if value == "null")),
282        _ => false,
283    }
284}