Skip to main content

barbacane_lib/mcp/
tools.rs

1use barbacane_compiler::CompiledOperation;
2use serde::Serialize;
3use std::collections::BTreeMap;
4
5/// An MCP tool declaration generated from a compiled operation.
6#[derive(Debug, Clone, Serialize)]
7pub struct McpTool {
8    pub name: String,
9    pub description: String,
10    #[serde(rename = "inputSchema")]
11    pub input_schema: serde_json::Value,
12    #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")]
13    pub output_schema: Option<serde_json::Value>,
14}
15
16/// Metadata linking an MCP tool to its compiled operation.
17#[derive(Debug, Clone)]
18pub struct ToolEntry {
19    pub tool: McpTool,
20    /// Index into the Gateway's operations array.
21    pub operation_index: usize,
22    /// The HTTP method for this operation.
23    pub method: String,
24    /// The path template for this operation.
25    pub path: String,
26    /// Parameters needed for argument decomposition (path, query).
27    pub parameters: Vec<barbacane_compiler::Parameter>,
28}
29
30/// Generate MCP tools from compiled operations.
31///
32/// Only includes operations where `mcp_enabled == Some(true)` and `operation_id` is present.
33pub fn generate_tools(operations: &[CompiledOperation]) -> Vec<ToolEntry> {
34    operations
35        .iter()
36        .filter(|op| op.mcp_enabled == Some(true) && op.operation_id.is_some())
37        .map(|op| {
38            let name = op.operation_id.clone().expect("filtered above");
39            let description = build_description(op);
40            let input_schema = build_input_schema(op);
41            let output_schema = build_output_schema(op);
42
43            ToolEntry {
44                tool: McpTool {
45                    name,
46                    description,
47                    input_schema,
48                    output_schema,
49                },
50                operation_index: op.index,
51                method: op.method.clone(),
52                path: op.path.clone(),
53                parameters: op.parameters.clone(),
54            }
55        })
56        .collect()
57}
58
59/// Build the tool description from MCP override, summary, or description.
60fn build_description(op: &CompiledOperation) -> String {
61    if let Some(ref desc) = op.mcp_description {
62        return desc.clone();
63    }
64    if let Some(ref summary) = op.summary {
65        return summary.clone();
66    }
67    if let Some(ref description) = op.description {
68        return description.clone();
69    }
70    format!("{} {}", op.method, op.path)
71}
72
73/// Build the MCP tool inputSchema by merging path params, query params, and body schema.
74fn build_input_schema(op: &CompiledOperation) -> serde_json::Value {
75    let mut properties = serde_json::Map::new();
76    let mut required = Vec::new();
77
78    // Add path and query parameters
79    for param in &op.parameters {
80        if param.location != "path" && param.location != "query" {
81            continue;
82        }
83        let schema = param
84            .schema
85            .clone()
86            .unwrap_or(serde_json::json!({"type": "string"}));
87        properties.insert(param.name.clone(), schema);
88        if param.required {
89            required.push(serde_json::Value::String(param.name.clone()));
90        }
91    }
92
93    // Merge body schema properties
94    if let Some(ref body) = op.request_body {
95        if let Some(content) = body.content.get("application/json") {
96            if let Some(ref schema) = content.schema {
97                merge_body_schema(&mut properties, &mut required, schema);
98            }
99        }
100    }
101
102    let mut schema = serde_json::json!({
103        "type": "object",
104        "properties": serde_json::Value::Object(properties),
105    });
106
107    if !required.is_empty() {
108        schema["required"] = serde_json::Value::Array(required);
109    }
110
111    schema
112}
113
114/// Merge a request body JSON Schema's properties into the tool's inputSchema.
115fn merge_body_schema(
116    properties: &mut serde_json::Map<String, serde_json::Value>,
117    required: &mut Vec<serde_json::Value>,
118    body_schema: &serde_json::Value,
119) {
120    // If the body schema has properties, merge them
121    if let Some(body_props) = body_schema.get("properties").and_then(|v| v.as_object()) {
122        for (key, val) in body_props {
123            properties.insert(key.clone(), val.clone());
124        }
125    }
126
127    // Merge required fields from body schema
128    if let Some(body_required) = body_schema.get("required").and_then(|v| v.as_array()) {
129        for r in body_required {
130            if !required.contains(r) {
131                required.push(r.clone());
132            }
133        }
134    }
135
136    // If the body schema has no properties (e.g., it's a raw type), wrap it as "body"
137    if body_schema.get("properties").is_none() && body_schema.get("type").is_some() {
138        properties.insert("body".to_string(), body_schema.clone());
139    }
140}
141
142/// Build the outputSchema from the operation's response definitions.
143///
144/// Uses the 200 response's application/json schema, falling back to the first 2xx.
145fn build_output_schema(op: &CompiledOperation) -> Option<serde_json::Value> {
146    // Try 200 first
147    if let Some(resp) = op.responses.get("200") {
148        if let Some(content) = resp.content.get("application/json") {
149            return content.schema.clone();
150        }
151    }
152
153    // Fall back to first 2xx response with a JSON schema
154    for (status, resp) in &op.responses {
155        if status.starts_with('2') {
156            if let Some(content) = resp.content.get("application/json") {
157                if content.schema.is_some() {
158                    return content.schema.clone();
159                }
160            }
161        }
162    }
163
164    None
165}
166
167/// Decompose MCP tool call arguments back into HTTP request components.
168///
169/// Returns (resolved_path, query_string, body_json).
170pub fn decompose_arguments(
171    entry: &ToolEntry,
172    arguments: &serde_json::Value,
173) -> (String, Option<String>, Option<Vec<u8>>) {
174    let args = arguments.as_object().cloned().unwrap_or_default();
175
176    let mut path = entry.path.clone();
177    let mut query_parts: Vec<String> = Vec::new();
178    let mut consumed_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
179
180    // Substitute path parameters
181    for param in &entry.parameters {
182        if param.location == "path" {
183            if let Some(val) = args.get(&param.name) {
184                let val_str = match val {
185                    serde_json::Value::String(s) => s.clone(),
186                    other => other.to_string(),
187                };
188                path = path.replace(&format!("{{{}}}", param.name), &val_str);
189                // Also handle wildcard params {name+}
190                path = path.replace(&format!("{{{}+}}", param.name), &val_str);
191                consumed_keys.insert(param.name.clone());
192            }
193        } else if param.location == "query" {
194            if let Some(val) = args.get(&param.name) {
195                let val_str = match val {
196                    serde_json::Value::String(s) => s.clone(),
197                    other => other.to_string(),
198                };
199                // Percent-encode key and value to avoid breaking on & or =
200                query_parts.push(format!(
201                    "{}={}",
202                    percent_encode(&param.name),
203                    percent_encode(&val_str)
204                ));
205                consumed_keys.insert(param.name.clone());
206            }
207        }
208    }
209
210    let query = if query_parts.is_empty() {
211        None
212    } else {
213        Some(query_parts.join("&"))
214    };
215
216    // Remaining arguments become the body
217    let remaining: BTreeMap<String, serde_json::Value> = args
218        .into_iter()
219        .filter(|(k, _)| !consumed_keys.contains(k))
220        .collect();
221
222    let body = if remaining.is_empty() {
223        None
224    } else {
225        serde_json::to_vec(&remaining).ok()
226    };
227
228    (path, query, body)
229}
230
231/// Minimal percent-encoding for query string components.
232fn percent_encode(s: &str) -> String {
233    let mut result = String::with_capacity(s.len());
234    for b in s.bytes() {
235        match b {
236            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
237                result.push(b as char);
238            }
239            _ => {
240                result.push('%');
241                result.push(char::from(HEX[(b >> 4) as usize]));
242                result.push(char::from(HEX[(b & 0x0f) as usize]));
243            }
244        }
245    }
246    result
247}
248
249const HEX: &[u8; 16] = b"0123456789ABCDEF";
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use barbacane_compiler::{
255        ContentSchema, DispatchConfig, Parameter, RequestBody, ResponseContent,
256    };
257
258    fn make_operation(
259        index: usize,
260        method: &str,
261        path: &str,
262        operation_id: Option<&str>,
263        summary: Option<&str>,
264        mcp_enabled: Option<bool>,
265    ) -> CompiledOperation {
266        CompiledOperation {
267            index,
268            path: path.to_string(),
269            method: method.to_string(),
270            operation_id: operation_id.map(|s| s.to_string()),
271            summary: summary.map(|s| s.to_string()),
272            description: None,
273            parameters: vec![],
274            request_body: None,
275            dispatch: DispatchConfig {
276                name: "mock".to_string(),
277                config: serde_json::json!({}),
278            },
279            middlewares: vec![],
280            deprecated: false,
281            sunset: None,
282            messages: vec![],
283            bindings: BTreeMap::new(),
284            responses: BTreeMap::new(),
285            mcp_enabled,
286            mcp_description: None,
287        }
288    }
289
290    #[test]
291    fn generate_tools_filters_by_mcp_enabled() {
292        let ops = vec![
293            make_operation(
294                0,
295                "GET",
296                "/health",
297                Some("getHealth"),
298                Some("Health check"),
299                Some(true),
300            ),
301            make_operation(1, "GET", "/secret", Some("getSecret"), Some("Secret"), None),
302            make_operation(
303                2,
304                "POST",
305                "/orders",
306                Some("createOrder"),
307                Some("Create order"),
308                Some(true),
309            ),
310            make_operation(3, "GET", "/no-id", None, Some("No ID"), Some(true)),
311        ];
312        let tools = generate_tools(&ops);
313        assert_eq!(tools.len(), 2);
314        assert_eq!(tools[0].tool.name, "getHealth");
315        assert_eq!(tools[1].tool.name, "createOrder");
316    }
317
318    #[test]
319    fn input_schema_path_and_query_params() {
320        let mut op = make_operation(
321            0,
322            "GET",
323            "/users/{id}",
324            Some("getUser"),
325            Some("Get user"),
326            Some(true),
327        );
328        op.parameters = vec![
329            Parameter {
330                name: "id".to_string(),
331                location: "path".to_string(),
332                required: true,
333                schema: Some(serde_json::json!({"type": "string"})),
334            },
335            Parameter {
336                name: "fields".to_string(),
337                location: "query".to_string(),
338                required: false,
339                schema: Some(serde_json::json!({"type": "string"})),
340            },
341        ];
342        let schema = build_input_schema(&op);
343        let props = schema["properties"].as_object().expect("properties");
344        assert!(props.contains_key("id"));
345        assert!(props.contains_key("fields"));
346        let required = schema["required"].as_array().expect("required");
347        assert_eq!(required.len(), 1);
348        assert_eq!(required[0], "id");
349    }
350
351    #[test]
352    fn input_schema_with_body() {
353        let mut op = make_operation(
354            0,
355            "POST",
356            "/orders",
357            Some("createOrder"),
358            Some("Create order"),
359            Some(true),
360        );
361        op.request_body = Some(RequestBody {
362            required: true,
363            content: BTreeMap::from([(
364                "application/json".to_string(),
365                ContentSchema {
366                    schema: Some(serde_json::json!({
367                        "type": "object",
368                        "required": ["items"],
369                        "properties": {
370                            "items": {"type": "array"},
371                            "note": {"type": "string"}
372                        }
373                    })),
374                },
375            )]),
376        });
377        let schema = build_input_schema(&op);
378        let props = schema["properties"].as_object().expect("properties");
379        assert!(props.contains_key("items"));
380        assert!(props.contains_key("note"));
381        let required = schema["required"].as_array().expect("required");
382        assert!(required.contains(&serde_json::json!("items")));
383    }
384
385    #[test]
386    fn output_schema_from_200_response() {
387        let mut op = make_operation(
388            0,
389            "GET",
390            "/health",
391            Some("getHealth"),
392            Some("Health"),
393            Some(true),
394        );
395        op.responses = BTreeMap::from([(
396            "200".to_string(),
397            ResponseContent {
398                content: BTreeMap::from([(
399                    "application/json".to_string(),
400                    ContentSchema {
401                        schema: Some(
402                            serde_json::json!({"type": "object", "properties": {"status": {"type": "string"}}}),
403                        ),
404                    },
405                )]),
406            },
407        )]);
408        let schema = build_output_schema(&op).expect("should have output schema");
409        assert!(schema["properties"]["status"].is_object());
410    }
411
412    #[test]
413    fn output_schema_none_when_no_responses() {
414        let op = make_operation(
415            0,
416            "GET",
417            "/health",
418            Some("getHealth"),
419            Some("Health"),
420            Some(true),
421        );
422        assert!(build_output_schema(&op).is_none());
423    }
424
425    #[test]
426    fn decompose_path_and_query_params() {
427        let entry = ToolEntry {
428            tool: McpTool {
429                name: "getUser".to_string(),
430                description: "Get user".to_string(),
431                input_schema: serde_json::json!({}),
432                output_schema: None,
433            },
434            operation_index: 0,
435            method: "GET".to_string(),
436            path: "/users/{id}".to_string(),
437            parameters: vec![
438                Parameter {
439                    name: "id".to_string(),
440                    location: "path".to_string(),
441                    required: true,
442                    schema: None,
443                },
444                Parameter {
445                    name: "fields".to_string(),
446                    location: "query".to_string(),
447                    required: false,
448                    schema: None,
449                },
450            ],
451        };
452        let args = serde_json::json!({"id": "123", "fields": "name,email"});
453        let (path, query, body) = decompose_arguments(&entry, &args);
454        assert_eq!(path, "/users/123");
455        assert_eq!(query, Some("fields=name%2Cemail".to_string()));
456        assert!(body.is_none());
457    }
458
459    #[test]
460    fn decompose_remaining_args_become_body() {
461        let entry = ToolEntry {
462            tool: McpTool {
463                name: "createOrder".to_string(),
464                description: "Create".to_string(),
465                input_schema: serde_json::json!({}),
466                output_schema: None,
467            },
468            operation_index: 0,
469            method: "POST".to_string(),
470            path: "/orders".to_string(),
471            parameters: vec![],
472        };
473        let args = serde_json::json!({"items": [{"id": "a"}], "note": "rush"});
474        let (path, query, body) = decompose_arguments(&entry, &args);
475        assert_eq!(path, "/orders");
476        assert!(query.is_none());
477        let body = body.expect("body should be present");
478        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
479        assert!(parsed["items"].is_array());
480    }
481
482    #[test]
483    fn percent_encode_special_chars() {
484        assert_eq!(percent_encode("hello world"), "hello%20world");
485        assert_eq!(percent_encode("a=b&c"), "a%3Db%26c");
486        assert_eq!(percent_encode("simple"), "simple");
487    }
488
489    #[test]
490    fn description_priority() {
491        // mcp_description > summary > description > fallback
492        let mut op = make_operation(0, "GET", "/a", Some("op"), None, Some(true));
493        assert_eq!(build_description(&op), "GET /a");
494
495        op.description = Some("detailed desc".to_string());
496        assert_eq!(build_description(&op), "detailed desc");
497
498        op.summary = Some("short summary".to_string());
499        assert_eq!(build_description(&op), "short summary");
500
501        op.mcp_description = Some("mcp override".to_string());
502        assert_eq!(build_description(&op), "mcp override");
503    }
504
505    #[test]
506    fn decompose_wildcard_path_param() {
507        let entry = ToolEntry {
508            tool: McpTool {
509                name: "getFile".to_string(),
510                description: "Get file".to_string(),
511                input_schema: serde_json::json!({}),
512                output_schema: None,
513            },
514            operation_index: 0,
515            method: "GET".to_string(),
516            path: "/files/{path+}".to_string(),
517            parameters: vec![Parameter {
518                name: "path".to_string(),
519                location: "path".to_string(),
520                required: true,
521                schema: None,
522            }],
523        };
524        let args = serde_json::json!({"path": "docs/2024/report.pdf"});
525        let (path, query, body) = decompose_arguments(&entry, &args);
526        assert_eq!(path, "/files/docs/2024/report.pdf");
527        assert!(query.is_none());
528        assert!(body.is_none());
529    }
530
531    #[test]
532    fn decompose_non_string_path_param() {
533        let entry = ToolEntry {
534            tool: McpTool {
535                name: "getUser".to_string(),
536                description: "Get user".to_string(),
537                input_schema: serde_json::json!({}),
538                output_schema: None,
539            },
540            operation_index: 0,
541            method: "GET".to_string(),
542            path: "/users/{id}".to_string(),
543            parameters: vec![Parameter {
544                name: "id".to_string(),
545                location: "path".to_string(),
546                required: true,
547                schema: None,
548            }],
549        };
550        // Numeric value instead of string
551        let args = serde_json::json!({"id": 42});
552        let (path, _, _) = decompose_arguments(&entry, &args);
553        assert_eq!(path, "/users/42");
554    }
555
556    #[test]
557    fn decompose_missing_path_param_leaves_placeholder() {
558        let entry = ToolEntry {
559            tool: McpTool {
560                name: "getUser".to_string(),
561                description: "Get user".to_string(),
562                input_schema: serde_json::json!({}),
563                output_schema: None,
564            },
565            operation_index: 0,
566            method: "GET".to_string(),
567            path: "/users/{id}".to_string(),
568            parameters: vec![Parameter {
569                name: "id".to_string(),
570                location: "path".to_string(),
571                required: true,
572                schema: None,
573            }],
574        };
575        // Missing "id" argument
576        let args = serde_json::json!({});
577        let (path, _, _) = decompose_arguments(&entry, &args);
578        assert_eq!(path, "/users/{id}");
579    }
580
581    #[test]
582    fn percent_encode_utf8() {
583        let encoded = percent_encode("café");
584        assert!(encoded.contains("%C3%A9")); // é = 0xC3 0xA9 in UTF-8
585    }
586}