Skip to main content

ferro_api_mcp/
schema.rs

1use serde_json::{json, Value};
2
3use crate::types::ApiParam;
4
5/// Adds a "description" key to a JSON Schema object.
6pub fn inject_description(schema: &mut Value, description: &str) {
7    if let Value::Object(map) = schema {
8        map.insert(
9            "description".to_string(),
10            Value::String(description.to_string()),
11        );
12    }
13}
14
15/// Converts a list of `ApiParam`s and an optional request body schema into a
16/// JSON Schema suitable for MCP tool `input_schema`.
17///
18/// Path/query/header parameters become top-level properties. Required parameters
19/// (typically path params) are listed in the "required" array. Request body
20/// content is nested under a "body" key to avoid name collisions with
21/// path/query parameters.
22pub fn build_input_schema(params: &[ApiParam], request_body_schema: Option<&Value>) -> Value {
23    let mut properties = serde_json::Map::new();
24    let mut required: Vec<Value> = Vec::new();
25
26    for param in params {
27        let mut schema = param.schema.clone();
28        if let Some(desc) = &param.description {
29            inject_description(&mut schema, desc);
30        }
31        properties.insert(param.name.clone(), schema);
32        if param.required {
33            required.push(Value::String(param.name.clone()));
34        }
35    }
36
37    if let Some(body_schema) = request_body_schema {
38        let mut body_prop = body_schema.clone();
39        inject_description(&mut body_prop, "Request body (JSON)");
40        properties.insert("body".to_string(), body_prop);
41    }
42
43    json!({
44        "type": "object",
45        "properties": properties,
46        "required": required,
47    })
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::types::ParamLocation;
54
55    #[test]
56    fn build_input_schema_path_params_only() {
57        let params = vec![ApiParam {
58            name: "id".to_string(),
59            location: ParamLocation::Path,
60            required: true,
61            schema: json!({"type": "string"}),
62            description: None,
63        }];
64
65        let schema = build_input_schema(&params, None);
66
67        assert_eq!(schema["type"], "object");
68        assert_eq!(schema["properties"]["id"]["type"], "string");
69        assert_eq!(schema["required"], json!(["id"]));
70    }
71
72    #[test]
73    fn build_input_schema_query_params() {
74        let params = vec![ApiParam {
75            name: "page".to_string(),
76            location: ParamLocation::Query,
77            required: false,
78            schema: json!({"type": "integer"}),
79            description: Some("Page number".to_string()),
80        }];
81
82        let schema = build_input_schema(&params, None);
83
84        assert_eq!(schema["properties"]["page"]["type"], "integer");
85        assert_eq!(schema["properties"]["page"]["description"], "Page number");
86        assert_eq!(schema["required"], json!([]));
87    }
88
89    #[test]
90    fn build_input_schema_with_body() {
91        let body_schema = json!({
92            "type": "object",
93            "properties": {
94                "name": {"type": "string"},
95                "email": {"type": "string"}
96            },
97            "required": ["name", "email"]
98        });
99
100        let schema = build_input_schema(&[], Some(&body_schema));
101
102        assert!(schema["properties"]["body"].is_object());
103        assert_eq!(
104            schema["properties"]["body"]["description"],
105            "Request body (JSON)"
106        );
107        assert_eq!(
108            schema["properties"]["body"]["properties"]["name"]["type"],
109            "string"
110        );
111    }
112
113    #[test]
114    fn build_input_schema_no_params() {
115        let schema = build_input_schema(&[], None);
116
117        assert_eq!(schema["type"], "object");
118        assert_eq!(schema["properties"], json!({}));
119        assert_eq!(schema["required"], json!([]));
120    }
121
122    #[test]
123    fn build_input_schema_mixed_params_and_body() {
124        let params = vec![
125            ApiParam {
126                name: "user_id".to_string(),
127                location: ParamLocation::Path,
128                required: true,
129                schema: json!({"type": "integer"}),
130                description: Some("User identifier".to_string()),
131            },
132            ApiParam {
133                name: "page".to_string(),
134                location: ParamLocation::Query,
135                required: false,
136                schema: json!({"type": "integer"}),
137                description: None,
138            },
139        ];
140
141        let body_schema = json!({
142            "type": "object",
143            "properties": {
144                "name": {"type": "string"}
145            }
146        });
147
148        let schema = build_input_schema(&params, Some(&body_schema));
149
150        // All three property types present
151        assert!(schema["properties"]["user_id"].is_object());
152        assert!(schema["properties"]["page"].is_object());
153        assert!(schema["properties"]["body"].is_object());
154
155        // Only path param is required
156        assert_eq!(schema["required"], json!(["user_id"]));
157
158        // Descriptions applied
159        assert_eq!(
160            schema["properties"]["user_id"]["description"],
161            "User identifier"
162        );
163        assert_eq!(
164            schema["properties"]["body"]["description"],
165            "Request body (JSON)"
166        );
167    }
168
169    #[test]
170    fn inject_description_adds_to_object() {
171        let mut schema = json!({"type": "string"});
172        inject_description(&mut schema, "A test field");
173        assert_eq!(schema["description"], "A test field");
174    }
175
176    #[test]
177    fn inject_description_ignores_non_object() {
178        let mut schema = json!("not an object");
179        inject_description(&mut schema, "ignored");
180        assert_eq!(schema, json!("not an object"));
181    }
182}