Skip to main content

soothfast_spec/
mcp.rs

1//! MCP tool manifests generated from `#[route(method = "TOOL")]` handlers.
2//!
3//! A tool is a name, a description and an `inputSchema` — which is exactly
4//! what the extractor already produces, so this dialect is mostly plumbing.
5//! The one structural difference from OpenAPI is where shared schemas live:
6//! each tool's schema is a self-contained JSON Schema document, so components
7//! are inlined as `$defs` under the schema that references them rather than
8//! collected at the document root.
9//!
10//! Both schemas are rooted at an object because MCP clients build a form (or
11//! a tool-call argument object) from them. A response whose root is not an
12//! object cannot be described as structured output, so `outputSchema` is
13//! omitted and the shortfall reported — an omitted output schema means
14//! "unstructured", which is true, where a wrapped one would be a contract
15//! nobody serves.
16
17use 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
25/// Where the extractor puts shared schemas, and where MCP expects them.
26const COMPONENTS_PREFIX: &str = "#/components/schemas/";
27const DEFS_PREFIX: &str = "#/$defs/";
28
29/// Assemble an MCP tool manifest.
30///
31/// `info` is unused: a tool manifest has no document-level metadata to carry,
32/// and inventing a wrapper object for it would break every client that reads
33/// the standard `{ "tools": [...] }` shape.
34pub 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    // Tools emit as an array (the shape clients read) but are built in a map,
54    // so the manifest is ordered by name rather than by link order.
55    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
72/// The object a caller fills in: the request body's own fields, plus any
73/// parameters the handler also takes.
74fn 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            // A body that is not an object (an array, a bare string) has no
94            // field names to offer as tool arguments.
95            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
128/// The structured result a caller can rely on, when there is one.
129fn 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
149/// The success response: the lowest 2xx status the handler declares.
150fn 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
158/// Resolve a root `$ref` into the component it names, so the schema starts at
159/// an object rather than a pointer. Nested refs are left alone: they become
160/// `$defs` entries.
161fn 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
169/// Attach every component the schema still references, transitively, under
170/// `$defs`, and nothing else — an unreferenced definition in a tool manifest
171/// is noise a client has to skip past.
172fn 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
199/// The component a `$ref` names, if it is one of ours.
200fn 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
207/// Repoint every `$ref` at `$defs`, where a self-contained JSON Schema
208/// document keeps its definitions.
209fn 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
229/// Every `$defs` name a schema refers to, one level deep.
230fn 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                // A `$defs` block already holds definitions, not references.
241                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
258/// Compare two generated tool manifests, oldest first.
259///
260/// A tool's arguments flow inward and its result flows outward, so the two
261/// schemas are compared in opposite directions — the same asymmetry OpenAPI
262/// has between a request body and a response.
263pub 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                // Structured output a client already parses cannot be withdrawn.
287                (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        // Regression: a request-body field's $ref was rewritten to `$defs`
471        // via `inline_root`, but a parameter's own $ref went in unrewritten
472        // — the tool's inputSchema pointed at `#/components/schemas/...`,
473        // which does not exist anywhere in a standalone tool manifest.
474        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}