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/// Decomposed HTTP request components: (resolved_path, query_string, body_json).
168type DecomposedRequest = (String, Option<String>, Option<Vec<u8>>);
169
170/// Decompose MCP tool call arguments back into HTTP request components.
171///
172/// Returns `Ok((resolved_path, query_string, body_json))`, or `Err(message)`
173/// when a path-parameter value is unsafe.
174///
175/// MCP callers bypass the HTTP router, which normally guarantees a non-wildcard
176/// path parameter is a single URL segment (it splits the request line on `/`).
177/// Substituting a raw MCP value straight into the path template would let a
178/// caller inject `/` or `..` and reach an upstream resource never exposed as a
179/// tool, or forge the `request.path` that prefix-based authz middleware inspects
180/// (MCP-1). We therefore validate + percent-encode each substituted segment:
181///   * non-wildcard `{name}` — reject `/`, `\`, `?`, `#`, and any `..`; the
182///     value must be a single segment, then percent-encode it;
183///   * wildcard `{name+}` — may span segments (`/` allowed), but reject `?`,
184///     `#`, `\`, and any `..` path segment so it cannot traverse upward.
185pub fn decompose_arguments(
186    entry: &ToolEntry,
187    arguments: &serde_json::Value,
188) -> Result<DecomposedRequest, String> {
189    let args = arguments.as_object().cloned().unwrap_or_default();
190
191    let mut path = entry.path.clone();
192    let mut query_parts: Vec<String> = Vec::new();
193    let mut consumed_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
194
195    // Substitute path parameters
196    for param in &entry.parameters {
197        if param.location == "path" {
198            if let Some(val) = args.get(&param.name) {
199                let val_str = match val {
200                    serde_json::Value::String(s) => s.clone(),
201                    other => other.to_string(),
202                };
203                let wildcard_placeholder = format!("{{{}+}}", param.name);
204                let is_wildcard = path.contains(&wildcard_placeholder);
205                let substituted = sanitize_path_param(&param.name, &val_str, is_wildcard)?;
206                if is_wildcard {
207                    path = path.replace(&wildcard_placeholder, &substituted);
208                } else {
209                    path = path.replace(&format!("{{{}}}", param.name), &substituted);
210                }
211                consumed_keys.insert(param.name.clone());
212            }
213        } else if param.location == "query" {
214            if let Some(val) = args.get(&param.name) {
215                let val_str = match val {
216                    serde_json::Value::String(s) => s.clone(),
217                    other => other.to_string(),
218                };
219                // Percent-encode key and value to avoid breaking on & or =
220                query_parts.push(format!(
221                    "{}={}",
222                    percent_encode(&param.name),
223                    percent_encode(&val_str)
224                ));
225                consumed_keys.insert(param.name.clone());
226            }
227        }
228    }
229
230    let query = if query_parts.is_empty() {
231        None
232    } else {
233        Some(query_parts.join("&"))
234    };
235
236    // Remaining arguments become the body
237    let remaining: BTreeMap<String, serde_json::Value> = args
238        .into_iter()
239        .filter(|(k, _)| !consumed_keys.contains(k))
240        .collect();
241
242    let body = if remaining.is_empty() {
243        None
244    } else {
245        serde_json::to_vec(&remaining).ok()
246    };
247
248    Ok((path, query, body))
249}
250
251/// Validate and encode a value being substituted into a path template segment.
252///
253/// Returns the substitution string, or an error message if the value would
254/// break out of its intended segment(s). See [`decompose_arguments`] for the
255/// rationale (MCP-1).
256fn sanitize_path_param(name: &str, value: &str, is_wildcard: bool) -> Result<String, String> {
257    // Control characters (incl. CR/LF) are never valid in a path.
258    if value.chars().any(|c| c.is_control()) {
259        return Err(format!(
260            "path parameter '{name}' contains control characters"
261        ));
262    }
263    if value.contains(['?', '#', '\\']) {
264        return Err(format!(
265            "path parameter '{name}' contains a disallowed character (?, #, or \\)"
266        ));
267    }
268    if is_wildcard {
269        // Wildcards may span segments; reject upward traversal in any segment.
270        if value.split('/').any(|seg| seg == "..") {
271            return Err(format!(
272                "path parameter '{name}' contains a '..' path segment"
273            ));
274        }
275        Ok(value.to_string())
276    } else {
277        // Non-wildcard params must be a single segment: no '/', no '..'.
278        if value.contains('/') {
279            return Err(format!("path parameter '{name}' must not contain '/'"));
280        }
281        if value == ".." || value == "." {
282            return Err(format!("path parameter '{name}' must not be '.' or '..'"));
283        }
284        Ok(percent_encode(value))
285    }
286}
287
288/// Minimal percent-encoding for query string components.
289fn percent_encode(s: &str) -> String {
290    let mut result = String::with_capacity(s.len());
291    for b in s.bytes() {
292        match b {
293            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
294                result.push(b as char);
295            }
296            _ => {
297                result.push('%');
298                result.push(char::from(HEX[(b >> 4) as usize]));
299                result.push(char::from(HEX[(b & 0x0f) as usize]));
300            }
301        }
302    }
303    result
304}
305
306const HEX: &[u8; 16] = b"0123456789ABCDEF";
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use barbacane_compiler::{
312        ContentSchema, DispatchConfig, Parameter, RequestBody, ResponseContent,
313    };
314
315    fn make_operation(
316        index: usize,
317        method: &str,
318        path: &str,
319        operation_id: Option<&str>,
320        summary: Option<&str>,
321        mcp_enabled: Option<bool>,
322    ) -> CompiledOperation {
323        CompiledOperation {
324            index,
325            path: path.to_string(),
326            method: method.to_string(),
327            operation_id: operation_id.map(|s| s.to_string()),
328            summary: summary.map(|s| s.to_string()),
329            description: None,
330            parameters: vec![],
331            request_body: None,
332            dispatch: DispatchConfig {
333                name: "mock".to_string(),
334                config: serde_json::json!({}),
335            },
336            middlewares: vec![],
337            deprecated: false,
338            sunset: None,
339            messages: vec![],
340            bindings: BTreeMap::new(),
341            responses: BTreeMap::new(),
342            mcp_enabled,
343            mcp_description: None,
344        }
345    }
346
347    #[test]
348    fn generate_tools_filters_by_mcp_enabled() {
349        let ops = vec![
350            make_operation(
351                0,
352                "GET",
353                "/health",
354                Some("getHealth"),
355                Some("Health check"),
356                Some(true),
357            ),
358            make_operation(1, "GET", "/secret", Some("getSecret"), Some("Secret"), None),
359            make_operation(
360                2,
361                "POST",
362                "/orders",
363                Some("createOrder"),
364                Some("Create order"),
365                Some(true),
366            ),
367            make_operation(3, "GET", "/no-id", None, Some("No ID"), Some(true)),
368        ];
369        let tools = generate_tools(&ops);
370        assert_eq!(tools.len(), 2);
371        assert_eq!(tools[0].tool.name, "getHealth");
372        assert_eq!(tools[1].tool.name, "createOrder");
373    }
374
375    #[test]
376    fn input_schema_path_and_query_params() {
377        let mut op = make_operation(
378            0,
379            "GET",
380            "/users/{id}",
381            Some("getUser"),
382            Some("Get user"),
383            Some(true),
384        );
385        op.parameters = vec![
386            Parameter {
387                name: "id".to_string(),
388                location: "path".to_string(),
389                required: true,
390                schema: Some(serde_json::json!({"type": "string"})),
391            },
392            Parameter {
393                name: "fields".to_string(),
394                location: "query".to_string(),
395                required: false,
396                schema: Some(serde_json::json!({"type": "string"})),
397            },
398        ];
399        let schema = build_input_schema(&op);
400        let props = schema["properties"].as_object().expect("properties");
401        assert!(props.contains_key("id"));
402        assert!(props.contains_key("fields"));
403        let required = schema["required"].as_array().expect("required");
404        assert_eq!(required.len(), 1);
405        assert_eq!(required[0], "id");
406    }
407
408    #[test]
409    fn input_schema_with_body() {
410        let mut op = make_operation(
411            0,
412            "POST",
413            "/orders",
414            Some("createOrder"),
415            Some("Create order"),
416            Some(true),
417        );
418        op.request_body = Some(RequestBody {
419            required: true,
420            content: BTreeMap::from([(
421                "application/json".to_string(),
422                ContentSchema {
423                    schema: Some(serde_json::json!({
424                        "type": "object",
425                        "required": ["items"],
426                        "properties": {
427                            "items": {"type": "array"},
428                            "note": {"type": "string"}
429                        }
430                    })),
431                },
432            )]),
433        });
434        let schema = build_input_schema(&op);
435        let props = schema["properties"].as_object().expect("properties");
436        assert!(props.contains_key("items"));
437        assert!(props.contains_key("note"));
438        let required = schema["required"].as_array().expect("required");
439        assert!(required.contains(&serde_json::json!("items")));
440    }
441
442    #[test]
443    fn output_schema_from_200_response() {
444        let mut op = make_operation(
445            0,
446            "GET",
447            "/health",
448            Some("getHealth"),
449            Some("Health"),
450            Some(true),
451        );
452        op.responses = BTreeMap::from([(
453            "200".to_string(),
454            ResponseContent {
455                content: BTreeMap::from([(
456                    "application/json".to_string(),
457                    ContentSchema {
458                        schema: Some(
459                            serde_json::json!({"type": "object", "properties": {"status": {"type": "string"}}}),
460                        ),
461                    },
462                )]),
463            },
464        )]);
465        let schema = build_output_schema(&op).expect("should have output schema");
466        assert!(schema["properties"]["status"].is_object());
467    }
468
469    #[test]
470    fn output_schema_none_when_no_responses() {
471        let op = make_operation(
472            0,
473            "GET",
474            "/health",
475            Some("getHealth"),
476            Some("Health"),
477            Some(true),
478        );
479        assert!(build_output_schema(&op).is_none());
480    }
481
482    #[test]
483    fn decompose_path_and_query_params() {
484        let entry = ToolEntry {
485            tool: McpTool {
486                name: "getUser".to_string(),
487                description: "Get user".to_string(),
488                input_schema: serde_json::json!({}),
489                output_schema: None,
490            },
491            operation_index: 0,
492            method: "GET".to_string(),
493            path: "/users/{id}".to_string(),
494            parameters: vec![
495                Parameter {
496                    name: "id".to_string(),
497                    location: "path".to_string(),
498                    required: true,
499                    schema: None,
500                },
501                Parameter {
502                    name: "fields".to_string(),
503                    location: "query".to_string(),
504                    required: false,
505                    schema: None,
506                },
507            ],
508        };
509        let args = serde_json::json!({"id": "123", "fields": "name,email"});
510        let (path, query, body) = decompose_arguments(&entry, &args).expect("valid args");
511        assert_eq!(path, "/users/123");
512        assert_eq!(query, Some("fields=name%2Cemail".to_string()));
513        assert!(body.is_none());
514    }
515
516    #[test]
517    fn decompose_remaining_args_become_body() {
518        let entry = ToolEntry {
519            tool: McpTool {
520                name: "createOrder".to_string(),
521                description: "Create".to_string(),
522                input_schema: serde_json::json!({}),
523                output_schema: None,
524            },
525            operation_index: 0,
526            method: "POST".to_string(),
527            path: "/orders".to_string(),
528            parameters: vec![],
529        };
530        let args = serde_json::json!({"items": [{"id": "a"}], "note": "rush"});
531        let (path, query, body) = decompose_arguments(&entry, &args).expect("valid args");
532        assert_eq!(path, "/orders");
533        assert!(query.is_none());
534        let body = body.expect("body should be present");
535        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid json");
536        assert!(parsed["items"].is_array());
537    }
538
539    #[test]
540    fn percent_encode_special_chars() {
541        assert_eq!(percent_encode("hello world"), "hello%20world");
542        assert_eq!(percent_encode("a=b&c"), "a%3Db%26c");
543        assert_eq!(percent_encode("simple"), "simple");
544    }
545
546    #[test]
547    fn description_priority() {
548        // mcp_description > summary > description > fallback
549        let mut op = make_operation(0, "GET", "/a", Some("op"), None, Some(true));
550        assert_eq!(build_description(&op), "GET /a");
551
552        op.description = Some("detailed desc".to_string());
553        assert_eq!(build_description(&op), "detailed desc");
554
555        op.summary = Some("short summary".to_string());
556        assert_eq!(build_description(&op), "short summary");
557
558        op.mcp_description = Some("mcp override".to_string());
559        assert_eq!(build_description(&op), "mcp override");
560    }
561
562    #[test]
563    fn decompose_wildcard_path_param() {
564        let entry = ToolEntry {
565            tool: McpTool {
566                name: "getFile".to_string(),
567                description: "Get file".to_string(),
568                input_schema: serde_json::json!({}),
569                output_schema: None,
570            },
571            operation_index: 0,
572            method: "GET".to_string(),
573            path: "/files/{path+}".to_string(),
574            parameters: vec![Parameter {
575                name: "path".to_string(),
576                location: "path".to_string(),
577                required: true,
578                schema: None,
579            }],
580        };
581        let args = serde_json::json!({"path": "docs/2024/report.pdf"});
582        let (path, query, body) = decompose_arguments(&entry, &args).expect("valid args");
583        assert_eq!(path, "/files/docs/2024/report.pdf");
584        assert!(query.is_none());
585        assert!(body.is_none());
586    }
587
588    #[test]
589    fn decompose_non_string_path_param() {
590        let entry = ToolEntry {
591            tool: McpTool {
592                name: "getUser".to_string(),
593                description: "Get user".to_string(),
594                input_schema: serde_json::json!({}),
595                output_schema: None,
596            },
597            operation_index: 0,
598            method: "GET".to_string(),
599            path: "/users/{id}".to_string(),
600            parameters: vec![Parameter {
601                name: "id".to_string(),
602                location: "path".to_string(),
603                required: true,
604                schema: None,
605            }],
606        };
607        // Numeric value instead of string
608        let args = serde_json::json!({"id": 42});
609        let (path, _, _) = decompose_arguments(&entry, &args).expect("valid args");
610        assert_eq!(path, "/users/42");
611    }
612
613    #[test]
614    fn decompose_missing_path_param_leaves_placeholder() {
615        let entry = ToolEntry {
616            tool: McpTool {
617                name: "getUser".to_string(),
618                description: "Get user".to_string(),
619                input_schema: serde_json::json!({}),
620                output_schema: None,
621            },
622            operation_index: 0,
623            method: "GET".to_string(),
624            path: "/users/{id}".to_string(),
625            parameters: vec![Parameter {
626                name: "id".to_string(),
627                location: "path".to_string(),
628                required: true,
629                schema: None,
630            }],
631        };
632        // Missing "id" argument
633        let args = serde_json::json!({});
634        let (path, _, _) = decompose_arguments(&entry, &args).expect("valid args");
635        assert_eq!(path, "/users/{id}");
636    }
637
638    fn user_id_entry() -> ToolEntry {
639        ToolEntry {
640            tool: McpTool {
641                name: "getUser".to_string(),
642                description: "Get user".to_string(),
643                input_schema: serde_json::json!({}),
644                output_schema: None,
645            },
646            operation_index: 0,
647            method: "GET".to_string(),
648            path: "/users/{id}".to_string(),
649            parameters: vec![Parameter {
650                name: "id".to_string(),
651                location: "path".to_string(),
652                required: true,
653                schema: None,
654            }],
655        }
656    }
657
658    #[test]
659    fn decompose_rejects_traversal_in_non_wildcard_param() {
660        // MCP-1: a non-wildcard path param must not break out of its segment.
661        let entry = user_id_entry();
662        for evil in [
663            "../admin/secrets",
664            "..",
665            "a/b",
666            "x?inject=1",
667            "y#frag",
668            "back\\slash",
669        ] {
670            let args = serde_json::json!({ "id": evil });
671            assert!(
672                decompose_arguments(&entry, &args).is_err(),
673                "value {evil:?} must be rejected"
674            );
675        }
676    }
677
678    #[test]
679    fn decompose_percent_encodes_non_wildcard_segment() {
680        // Reserved but non-structural characters are encoded, not rejected, so
681        // the value stays a single opaque segment.
682        let entry = user_id_entry();
683        let args = serde_json::json!({"id": "a b:c"});
684        let (path, _, _) = decompose_arguments(&entry, &args).expect("encodable");
685        assert_eq!(path, "/users/a%20b%3Ac");
686    }
687
688    #[test]
689    fn decompose_rejects_traversal_segment_in_wildcard_param() {
690        // Wildcards may contain '/', but not an upward-traversal segment.
691        let entry = ToolEntry {
692            tool: McpTool {
693                name: "getFile".to_string(),
694                description: "Get file".to_string(),
695                input_schema: serde_json::json!({}),
696                output_schema: None,
697            },
698            operation_index: 0,
699            method: "GET".to_string(),
700            path: "/files/{path+}".to_string(),
701            parameters: vec![Parameter {
702                name: "path".to_string(),
703                location: "path".to_string(),
704                required: true,
705                schema: None,
706            }],
707        };
708        let args = serde_json::json!({"path": "docs/../../etc/passwd"});
709        assert!(decompose_arguments(&entry, &args).is_err());
710        // A legitimate deep path with no traversal is still allowed.
711        let ok = serde_json::json!({"path": "docs/2024/report.pdf"});
712        assert!(decompose_arguments(&entry, &ok).is_ok());
713    }
714
715    #[test]
716    fn percent_encode_utf8() {
717        let encoded = percent_encode("café");
718        assert!(encoded.contains("%C3%A9")); // é = 0xC3 0xA9 in UTF-8
719    }
720}