Skip to main content

coralstack_cmd_ipc/
schema.rs

1//! Utilities for normalizing JSON Schema values produced by the
2//! `#[command]` macro into language-agnostic JSON Schema suitable for
3//! MCP tool schemas and remote `GET /cmd.json`-style consumers.
4//!
5//! The Rust `schemars` crate emits several Rust/OpenAPI-flavored fields
6//! that are NOT part of the JSON Schema standard and that generic
7//! consumers — including the TypeScript implementation — do not expect:
8//!
9//! - `$schema` — the draft URL. Informational only; dropped.
10//! - `title` — defaults to the Rust type name (e.g. `"BinaryOpReq"`,
11//!   `"int64"`). Dropped so the wire payload is free of Rust
12//!   identifiers.
13//! - `format` on numeric types (`int64`, `int32`, `uint64`, `float`,
14//!   `double`, etc.) — these are OpenAPI extensions, not standard
15//!   JSON Schema format values. `format` is only meaningful on
16//!   `type: "string"` (e.g. `"date-time"`, `"email"`, `"uri"`,
17//!   `"uuid"`), so we strip it everywhere except on string schemas.
18//!
19//! The normalizer also adds `additionalProperties: false` to any
20//! object-typed schema that doesn't already specify one, so the
21//! advertised schema reflects the fact that every request/response
22//! type in Rust is strict by construction (extra JSON fields would
23//! fail at `serde_json::from_value` regardless).
24//!
25//! Recursion walks every nested schema — including `properties`,
26//! `items`, `definitions` / `$defs`, and `oneOf` / `anyOf` / `allOf` —
27//! so the transformation is applied uniformly.
28//!
29//! Users who hand-implement [`Command::schema`](crate::Command::schema)
30//! can call [`normalize_schema`] on their manual output to get the
31//! same shape the macro produces.
32
33use serde_json::Value;
34
35use crate::message::CommandSchema;
36
37/// Rewrites a JSON Schema value in place: strips `title` and `$schema`
38/// recursively, drops `format` on non-string schemas, and adds
39/// `additionalProperties: false` to every object schema that doesn't
40/// already declare one.
41///
42/// The transformation is idempotent — running it twice produces the
43/// same result — so it's safe for the library to apply defensively
44/// even if the caller already normalized.
45pub fn normalize_schema(mut v: Value) -> Value {
46    normalize_in_place(&mut v);
47    v
48}
49
50/// Normalizes both slots of a [`CommandSchema`]. Used internally by
51/// [`CommandRegistry::register`](crate::CommandRegistry::register) and
52/// on remote schema ingest so every schema reachable through the
53/// registry has the same, language-agnostic shape.
54pub(crate) fn normalize_command_schema(cs: CommandSchema) -> CommandSchema {
55    CommandSchema {
56        request: cs.request.map(normalize_schema),
57        response: cs.response.map(normalize_schema),
58    }
59}
60
61fn normalize_in_place(v: &mut Value) {
62    match v {
63        Value::Object(map) => {
64            // Strip metadata not present in the TS-emitted schemas.
65            map.remove("title");
66            map.remove("$schema");
67
68            // JSON Schema `format` is only standard on `type: "string"`
69            // (date-time, email, uri, uuid, …). Numeric formats such as
70            // `int64`, `int32`, `uint32`, `float`, `double` are OpenAPI
71            // extensions that MCP and generic JSON Schema consumers
72            // don't understand. Drop `format` unless this schema node's
73            // type is `"string"`.
74            let is_string = matches!(map.get("type"), Some(Value::String(t)) if t == "string");
75            if !is_string {
76                map.remove("format");
77            }
78
79            // Add additionalProperties: false on object schemas.
80            // Only when `type` is the scalar "object" (skip the rare
81            // `type: ["object", "null"]` form — keep behavior conservative).
82            if matches!(map.get("type"), Some(Value::String(t)) if t == "object")
83                && !map.contains_key("additionalProperties")
84            {
85                map.insert("additionalProperties".into(), Value::Bool(false));
86            }
87
88            // Recurse into every sub-value so nested schemas (fields,
89            // items, $defs entries, union branches) are normalized too.
90            for (_, child) in map.iter_mut() {
91                normalize_in_place(child);
92            }
93        }
94        Value::Array(arr) => {
95            for child in arr.iter_mut() {
96                normalize_in_place(child);
97            }
98        }
99        _ => {}
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    #[test]
109    fn strips_title_schema_and_numeric_format_and_adds_additional_properties_false() {
110        let input = json!({
111            "$schema": "http://json-schema.org/draft-07/schema#",
112            "title": "BinaryOpReq",
113            "type": "object",
114            "properties": {
115                "a": { "title": "int64", "type": "integer", "format": "int64" },
116                "b": { "title": "int64", "type": "integer", "format": "int64" }
117            },
118            "required": ["a", "b"]
119        });
120        let got = normalize_schema(input);
121        assert_eq!(
122            got,
123            json!({
124                "type": "object",
125                "additionalProperties": false,
126                "properties": {
127                    "a": { "type": "integer" },
128                    "b": { "type": "integer" }
129                },
130                "required": ["a", "b"]
131            })
132        );
133    }
134
135    #[test]
136    fn preserves_format_on_string_schemas() {
137        // date-time / email / uri / uuid are standard JSON Schema string formats.
138        let input = json!({
139            "type": "object",
140            "properties": {
141                "created": { "type": "string", "format": "date-time" },
142                "email":   { "type": "string", "format": "email" },
143                "id":      { "type": "string", "format": "uuid" }
144            },
145            "required": ["created", "email", "id"]
146        });
147        let got = normalize_schema(input);
148        assert_eq!(got["properties"]["created"]["format"], "date-time");
149        assert_eq!(got["properties"]["email"]["format"], "email");
150        assert_eq!(got["properties"]["id"]["format"], "uuid");
151    }
152
153    #[test]
154    fn strips_openapi_numeric_formats() {
155        // schemars emits these for the corresponding Rust integer widths.
156        // None are part of the JSON Schema standard.
157        for fmt in [
158            "int32", "int64", "uint8", "uint32", "uint64", "float", "double",
159        ] {
160            let input = json!({ "type": "integer", "format": fmt });
161            let got = normalize_schema(input);
162            assert!(
163                got.get("format").is_none(),
164                "format `{fmt}` should have been stripped on non-string schema"
165            );
166        }
167    }
168
169    #[test]
170    fn leaves_existing_additional_properties_alone() {
171        let input = json!({
172            "type": "object",
173            "additionalProperties": true,
174            "properties": { "x": { "type": "string" } }
175        });
176        let got = normalize_schema(input);
177        assert_eq!(got["additionalProperties"], Value::Bool(true));
178    }
179
180    #[test]
181    fn normalizes_non_object_root_schemas() {
182        let input = json!({
183            "$schema": "http://json-schema.org/draft-07/schema#",
184            "title": "String",
185            "type": "string"
186        });
187        let got = normalize_schema(input);
188        assert_eq!(got, json!({ "type": "string" }));
189    }
190
191    #[test]
192    fn recurses_into_definitions_and_oneof() {
193        let input = json!({
194            "title": "Outer",
195            "type": "object",
196            "properties": {
197                "choice": {
198                    "oneOf": [
199                        { "title": "A", "type": "object", "properties": { "a": { "type": "integer" } } },
200                        { "title": "B", "type": "object", "properties": { "b": { "type": "integer" } } }
201                    ]
202                }
203            },
204            "$defs": {
205                "Inner": {
206                    "title": "Inner",
207                    "type": "object",
208                    "properties": { "n": { "type": "integer" } }
209                }
210            }
211        });
212        let got = normalize_schema(input);
213        // Root object has additionalProperties: false, no title
214        assert_eq!(got["additionalProperties"], Value::Bool(false));
215        assert!(got.get("title").is_none());
216        // oneOf branches each become additionalProperties: false, no title
217        let branches = got["properties"]["choice"]["oneOf"].as_array().unwrap();
218        for b in branches {
219            assert!(b.get("title").is_none());
220            assert_eq!(b["additionalProperties"], Value::Bool(false));
221        }
222        // $defs entry normalized
223        assert!(got["$defs"]["Inner"].get("title").is_none());
224        assert_eq!(
225            got["$defs"]["Inner"]["additionalProperties"],
226            Value::Bool(false)
227        );
228    }
229}