1use std::collections::{BTreeMap, BTreeSet};
18
19use serde_json::{Value, json};
20
21use crate::compat::{Change, Direction, SchemaDiff, Severity, compare_keys, sort};
22use crate::dialect::{Document, Info, Operation, unknown_method};
23use crate::schema::route_sig::RouteShape;
24
25const COMPONENTS_PREFIX: &str = "#/components/schemas/";
27const DEFS_PREFIX: &str = "#/$defs/";
28
29pub fn document(_info: &Info, ops: &[Operation]) -> Document {
35 let mut doc = Document::default();
36 let mut tools: BTreeMap<String, Value> = BTreeMap::new();
37
38 for op in ops {
39 if !op.method.eq_ignore_ascii_case("TOOL") {
40 doc.conflicts.push(unknown_method(op, "MCP", "TOOL"));
41 continue;
42 }
43 if tools.contains_key(&op.operation_id) {
44 doc.conflicts.push(format!(
45 "tool `{}` is declared by more than one route",
46 op.operation_id
47 ));
48 continue;
49 }
50 tools.insert(op.operation_id.clone(), tool_object(op, &mut doc));
51 }
52
53 doc.value = json!({ "tools": tools.into_values().collect::<Vec<_>>() });
56 doc
57}
58
59fn tool_object(op: &Operation, doc: &mut Document) -> Value {
60 let mut tool = serde_json::Map::new();
61 tool.insert("name".into(), json!(op.operation_id));
62 if let Some(s) = &op.summary {
63 tool.insert("description".into(), json!(s));
64 }
65 tool.insert("inputSchema".into(), input_schema(op, doc));
66 if let Some(out) = output_schema(op, doc) {
67 tool.insert("outputSchema".into(), out);
68 }
69 Value::Object(tool)
70}
71
72fn input_schema(op: &Operation, doc: &mut Document) -> Value {
75 let shape = &op.shape;
76 let mut properties = serde_json::Map::new();
77 let mut required: Vec<String> = Vec::new();
78
79 if let Some(body) = &shape.request {
80 let root = inline_root(&body.schema, &shape.components);
81 if let Some(props) = root["properties"].as_object() {
82 for (name, schema) in props {
83 properties.insert(name.clone(), schema.clone());
84 }
85 required.extend(
86 root["required"]
87 .as_array()
88 .into_iter()
89 .flatten()
90 .filter_map(|v| v.as_str().map(String::from)),
91 );
92 } else if !root.as_object().is_none_or(serde_json::Map::is_empty) {
93 doc.notes.push(format!(
96 "tool `{}`: the request body is not an object, so it cannot \
97 become named tool arguments; inputSchema is left open",
98 op.operation_id
99 ));
100 }
101 }
102
103 for p in &shape.parameters {
104 if properties.contains_key(&p.name) {
105 doc.conflicts.push(format!(
106 "tool `{}`: parameter `{}` collides with a request body field \
107 of the same name",
108 op.operation_id, p.name
109 ));
110 continue;
111 }
112 properties.insert(p.name.clone(), rewrite_refs(&p.schema));
113 if p.required {
114 required.push(p.name.clone());
115 }
116 }
117
118 let mut schema = json!({ "type": "object", "properties": properties });
119 if !required.is_empty() {
120 required.sort();
121 required.dedup();
122 schema["required"] = json!(required);
123 }
124 attach_defs(&mut schema, &shape.components);
125 schema
126}
127
128fn output_schema(op: &Operation, doc: &mut Document) -> Option<Value> {
130 let (_, response) = success_response(&op.shape)?;
131 if response.schema.is_null() {
132 return None;
133 }
134 let root = inline_root(&response.schema, &op.shape.components);
135 if root["type"] != "object" {
136 doc.notes.push(format!(
137 "tool `{}`: the success response is not an object, which MCP \
138 cannot describe as structured output; outputSchema is omitted \
139 and the result is unstructured",
140 op.operation_id
141 ));
142 return None;
143 }
144 let mut schema = root;
145 attach_defs(&mut schema, &op.shape.components);
146 Some(schema)
147}
148
149fn success_response(shape: &RouteShape) -> Option<(&String, &crate::schema::route_sig::Response)> {
151 shape
152 .responses
153 .iter()
154 .filter(|(code, _)| code.starts_with('2'))
155 .min_by_key(|(code, _)| code.parse::<u16>().unwrap_or(u16::MAX))
156}
157
158fn inline_root(schema: &Value, components: &BTreeMap<String, Value>) -> Value {
162 let resolved = match component_name(schema) {
163 Some(name) => components.get(&name).cloned().unwrap_or_else(|| json!({})),
164 None => schema.clone(),
165 };
166 rewrite_refs(&resolved)
167}
168
169fn attach_defs(schema: &mut Value, components: &BTreeMap<String, Value>) {
173 let mut wanted: BTreeSet<String> = BTreeSet::new();
174 let mut frontier: Vec<String> = referenced(schema).into_iter().collect();
175 while let Some(name) = frontier.pop() {
176 if !wanted.insert(name.clone()) {
177 continue;
178 }
179 if let Some(def) = components.get(&name) {
180 frontier.extend(referenced(&rewrite_refs(def)));
181 }
182 }
183 if wanted.is_empty() {
184 return;
185 }
186 let defs: serde_json::Map<String, Value> = wanted
187 .into_iter()
188 .map(|name| {
189 let body = components
190 .get(&name)
191 .map(rewrite_refs)
192 .unwrap_or_else(|| json!({}));
193 (name, body)
194 })
195 .collect();
196 schema["$defs"] = Value::Object(defs);
197}
198
199fn component_name(schema: &Value) -> Option<String> {
201 let r = schema.get("$ref")?.as_str()?;
202 r.strip_prefix(COMPONENTS_PREFIX)
203 .or_else(|| r.strip_prefix(DEFS_PREFIX))
204 .map(String::from)
205}
206
207fn rewrite_refs(schema: &Value) -> Value {
210 match schema {
211 Value::Object(o) => Value::Object(
212 o.iter()
213 .map(|(k, v)| {
214 if k == "$ref"
215 && let Some(name) =
216 v.as_str().and_then(|r| r.strip_prefix(COMPONENTS_PREFIX))
217 {
218 return (k.clone(), json!(format!("{DEFS_PREFIX}{name}")));
219 }
220 (k.clone(), rewrite_refs(v))
221 })
222 .collect(),
223 ),
224 Value::Array(a) => Value::Array(a.iter().map(rewrite_refs).collect()),
225 other => other.clone(),
226 }
227}
228
229fn referenced(schema: &Value) -> BTreeSet<String> {
231 let mut out = BTreeSet::new();
232 collect_refs(schema, &mut out);
233 out
234}
235
236fn collect_refs(schema: &Value, out: &mut BTreeSet<String>) {
237 match schema {
238 Value::Object(o) => {
239 for (k, v) in o {
240 if k == "$defs" {
242 continue;
243 }
244 if k == "$ref" {
245 if let Some(name) = v.as_str().and_then(|r| r.strip_prefix(DEFS_PREFIX)) {
246 out.insert(name.to_string());
247 }
248 continue;
249 }
250 collect_refs(v, out);
251 }
252 }
253 Value::Array(a) => a.iter().for_each(|v| collect_refs(v, out)),
254 _ => {}
255 }
256}
257
258pub fn diff(old: &Value, new: &Value) -> Vec<Change> {
264 let mut changes = Vec::new();
265 let (old_tools, new_tools) = (by_name(old), by_name(new));
266
267 compare_keys(
268 &mut changes,
269 &old_tools,
270 &new_tools,
271 "tool",
272 |ch, at, o, n| {
273 let (oi, ni) = (&o["inputSchema"], &n["inputSchema"]);
274 SchemaDiff::new(oi, ni).compare(ch, &format!("{at} input"), oi, ni, Direction::Request);
275
276 match (o.get("outputSchema"), n.get("outputSchema")) {
277 (Some(oo), Some(no)) => {
278 SchemaDiff::new(oo, no).compare(
279 ch,
280 &format!("{at} output"),
281 oo,
282 no,
283 Direction::Response,
284 );
285 }
286 (Some(_), None) => ch.push(Change::new(
288 Severity::Breaking,
289 format!("{at} output"),
290 "structured output removed",
291 )),
292 (None, Some(_)) => ch.push(Change::new(
293 Severity::Additive,
294 format!("{at} output"),
295 "structured output added",
296 )),
297 (None, None) => {}
298 }
299 },
300 );
301
302 sort(&mut changes);
303 changes
304}
305
306fn by_name(doc: &Value) -> BTreeMap<String, Value> {
307 doc["tools"]
308 .as_array()
309 .map(|tools| {
310 tools
311 .iter()
312 .filter_map(|t| t["name"].as_str().map(|n| (n.to_string(), t.clone())))
313 .collect()
314 })
315 .unwrap_or_default()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::compat::is_compatible;
322 use crate::schema::route_sig::{Parameter, RequestBody, Response};
323
324 fn info() -> Info {
325 Info::default()
326 }
327
328 fn tool(name: &str, shape: RouteShape) -> Operation {
329 Operation {
330 operation_id: name.into(),
331 method: "TOOL".into(),
332 path: name.into(),
333 summary: Some(format!("Does {name}.")),
334 shape,
335 }
336 }
337
338 fn object(props: Value, required: &[&str]) -> Value {
339 json!({ "type": "object", "properties": props, "required": required })
340 }
341
342 #[test]
343 fn a_tool_carries_its_name_and_doc_comment() {
344 let d = document(&info(), &[tool("get_item", RouteShape::default())]);
345 assert_eq!(d.value["tools"][0]["name"], "get_item");
346 assert_eq!(d.value["tools"][0]["description"], "Does get_item.");
347 assert_eq!(d.value["tools"][0]["inputSchema"]["type"], "object");
348 assert!(d.conflicts.is_empty());
349 }
350
351 #[test]
352 fn tools_emit_in_name_order_whatever_order_the_routes_registered() {
353 let d = document(
354 &info(),
355 &[
356 tool("zeta", RouteShape::default()),
357 tool("alpha", RouteShape::default()),
358 ],
359 );
360 let names: Vec<&str> = d.value["tools"]
361 .as_array()
362 .expect("tools")
363 .iter()
364 .filter_map(|t| t["name"].as_str())
365 .collect();
366 assert_eq!(names, vec!["alpha", "zeta"]);
367 }
368
369 #[test]
370 fn the_request_body_becomes_the_tools_arguments() {
371 let mut shape = RouteShape::default();
372 shape.components.insert(
373 "NewItem".into(),
374 object(
375 json!({ "name": { "type": "string" }, "qty": { "type": "integer" } }),
376 &["name"],
377 ),
378 );
379 shape.request = Some(RequestBody {
380 content_type: "application/json".into(),
381 schema: json!({ "$ref": "#/components/schemas/NewItem" }),
382 required: true,
383 });
384 let d = document(&info(), &[tool("create_item", shape)]);
385 let input = &d.value["tools"][0]["inputSchema"];
386 assert_eq!(input["type"], "object", "root is inlined, not a $ref");
387 assert_eq!(input["properties"]["name"]["type"], "string");
388 assert_eq!(input["required"][0], "name");
389 }
390
391 #[test]
392 fn parameters_join_the_argument_object() {
393 let mut shape = RouteShape::default();
394 shape.parameters.push(Parameter {
395 name: "id".into(),
396 location: "path".into(),
397 required: true,
398 schema: json!({ "type": "integer" }),
399 });
400 shape.parameters.push(Parameter {
401 name: "verbose".into(),
402 location: "query".into(),
403 required: false,
404 schema: json!({ "type": "boolean" }),
405 });
406 let d = document(&info(), &[tool("get_item", shape)]);
407 let input = &d.value["tools"][0]["inputSchema"];
408 assert_eq!(input["properties"]["id"]["type"], "integer");
409 assert_eq!(input["required"], json!(["id"]), "only the required one");
410 }
411
412 #[test]
413 fn a_parameter_colliding_with_a_body_field_is_a_conflict() {
414 let mut shape = RouteShape {
415 request: Some(RequestBody {
416 content_type: "application/json".into(),
417 schema: object(json!({ "id": { "type": "string" } }), &[]),
418 required: true,
419 }),
420 ..RouteShape::default()
421 };
422 shape.parameters.push(Parameter {
423 name: "id".into(),
424 location: "path".into(),
425 required: true,
426 schema: json!({ "type": "integer" }),
427 });
428 let d = document(&info(), &[tool("get_item", shape)]);
429 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
430 assert!(d.conflicts[0].contains("collides"), "{:?}", d.conflicts);
431 }
432
433 #[test]
434 fn referenced_components_travel_with_the_schema_as_defs() {
435 let mut shape = RouteShape::default();
436 shape.components.insert(
437 "Item".into(),
438 object(
439 json!({ "tag": { "$ref": "#/components/schemas/Tag" } }),
440 &[],
441 ),
442 );
443 shape
444 .components
445 .insert("Tag".into(), json!({ "type": "string" }));
446 shape.components.insert(
447 "Unrelated".into(),
448 json!({ "type": "object", "properties": {} }),
449 );
450 shape.responses.insert(
451 "200".into(),
452 Response::json(json!({ "$ref": "#/components/schemas/Item" })),
453 );
454 let d = document(&info(), &[tool("get_item", shape)]);
455 let out = &d.value["tools"][0]["outputSchema"];
456 assert_eq!(
457 out["properties"]["tag"]["$ref"], "#/$defs/Tag",
458 "nested refs are repointed at $defs"
459 );
460 let defs = out["$defs"].as_object().expect("$defs");
461 assert!(defs.contains_key("Tag"));
462 assert!(
463 !defs.contains_key("Unrelated"),
464 "only reachable definitions travel: {defs:?}"
465 );
466 }
467
468 #[test]
469 fn a_parameter_typed_by_a_component_becomes_a_self_contained_def() {
470 let mut shape = RouteShape::default();
475 shape.components.insert(
476 "AnalysisType".into(),
477 json!({ "type": "string", "enum": ["a", "b"] }),
478 );
479 shape.parameters.push(Parameter {
480 name: "analysis_type".into(),
481 location: "query".into(),
482 required: true,
483 schema: json!({ "$ref": "#/components/schemas/AnalysisType" }),
484 });
485 let d = document(&info(), &[tool("get_analysis", shape)]);
486 let input = &d.value["tools"][0]["inputSchema"];
487 assert_eq!(
488 input["properties"]["analysis_type"]["$ref"], "#/$defs/AnalysisType",
489 "a parameter's $ref must be repointed at $defs like a body field's"
490 );
491 let defs = input["$defs"].as_object().expect("$defs");
492 assert!(
493 defs.contains_key("AnalysisType"),
494 "the referenced component must travel with the schema: {defs:?}"
495 );
496 }
497
498 #[test]
499 fn a_non_object_response_omits_structured_output_and_says_why() {
500 let mut shape = RouteShape::default();
501 shape.responses.insert(
502 "200".into(),
503 Response::json(json!({ "type": "array", "items": { "type": "string" } })),
504 );
505 let d = document(&info(), &[tool("list_tags", shape)]);
506 assert!(d.value["tools"][0].get("outputSchema").is_none());
507 assert_eq!(d.notes.len(), 1, "got {:?}", d.notes);
508 assert!(d.notes[0].contains("unstructured"), "{:?}", d.notes);
509 }
510
511 #[test]
512 fn a_no_content_handler_has_no_output_schema() {
513 let mut shape = RouteShape::default();
514 shape.responses.insert("204".into(), Response::empty());
515 let d = document(&info(), &[tool("delete_item", shape)]);
516 assert!(d.value["tools"][0].get("outputSchema").is_none());
517 assert!(d.notes.is_empty(), "no content is not a shortfall");
518 }
519
520 #[test]
521 fn a_non_tool_method_is_a_conflict_not_a_guess() {
522 let mut op = tool("get_item", RouteShape::default());
523 op.method = "GET".into();
524 let d = document(&info(), &[op]);
525 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
526 assert!(d.value["tools"].as_array().is_some_and(Vec::is_empty));
527 }
528
529 fn manifest(input: Value, output: Option<Value>) -> Value {
530 let mut t = json!({ "name": "create_item", "inputSchema": input });
531 if let Some(o) = output {
532 t["outputSchema"] = o;
533 }
534 json!({ "tools": [t] })
535 }
536
537 #[test]
538 fn a_newly_required_argument_breaks_callers() {
539 let old = manifest(object(json!({ "a": { "type": "string" } }), &[]), None);
540 let new = manifest(
541 object(
542 json!({ "a": { "type": "string" }, "b": { "type": "string" } }),
543 &["b"],
544 ),
545 None,
546 );
547 let changes = diff(&old, &new);
548 assert!(!is_compatible(&changes), "got {changes:?}");
549 assert_eq!(changes[0].severity, Severity::Breaking);
550 }
551
552 #[test]
553 fn an_optional_new_argument_is_additive() {
554 let old = manifest(object(json!({ "a": { "type": "string" } }), &[]), None);
555 let new = manifest(
556 object(
557 json!({ "a": { "type": "string" }, "b": { "type": "string" } }),
558 &[],
559 ),
560 None,
561 );
562 assert!(is_compatible(&diff(&old, &new)));
563 }
564
565 #[test]
566 fn withdrawing_structured_output_breaks_readers() {
567 let input = object(json!({}), &[]);
568 let old = manifest(
569 input.clone(),
570 Some(object(json!({ "id": { "type": "string" } }), &["id"])),
571 );
572 let new = manifest(input, None);
573 let changes = diff(&old, &new);
574 assert!(!is_compatible(&changes), "got {changes:?}");
575 assert!(changes[0].detail.contains("structured output removed"));
576 }
577
578 #[test]
579 fn an_output_field_that_stops_being_guaranteed_breaks_readers() {
580 let input = object(json!({}), &[]);
581 let old = manifest(
582 input.clone(),
583 Some(object(json!({ "id": { "type": "string" } }), &["id"])),
584 );
585 let new = manifest(
586 input,
587 Some(object(json!({ "id": { "type": "string" } }), &[])),
588 );
589 let changes = diff(&old, &new);
590 assert!(!is_compatible(&changes), "got {changes:?}");
591 assert!(changes[0].detail.contains("no longer guaranteed"));
592 }
593
594 #[test]
595 fn defs_resolve_per_tool_when_diffing() {
596 let schema = json!({ "$ref": "#/$defs/Item",
597 "$defs": { "Item": { "type": "object",
598 "properties": { "id": { "type": "string" } } } } });
599 let changed = json!({ "$ref": "#/$defs/Item",
600 "$defs": { "Item": { "type": "object",
601 "properties": { "id": { "type": "integer" } } } } });
602 let changes = diff(&manifest(schema, None), &manifest(changed, None));
603 assert_eq!(changes.len(), 1, "got {changes:?}");
604 assert!(changes[0].detail.contains("string -> integer"));
605 }
606
607 #[test]
608 fn a_removed_tool_breaks_callers_and_a_new_one_does_not() {
609 let old = json!({ "tools": [ { "name": "a", "inputSchema": {} },
610 { "name": "b", "inputSchema": {} } ] });
611 let new = json!({ "tools": [ { "name": "a", "inputSchema": {} },
612 { "name": "c", "inputSchema": {} } ] });
613 let changes = diff(&old, &new);
614 assert_eq!(changes.len(), 2, "got {changes:?}");
615 assert_eq!(changes[0].severity, Severity::Breaking);
616 assert_eq!(changes[0].at, "b");
617 assert_eq!(changes[1].severity, Severity::Additive);
618 }
619}