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}