1use barbacane_compiler::CompiledOperation;
2use serde::Serialize;
3use std::collections::BTreeMap;
4
5#[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#[derive(Debug, Clone)]
18pub struct ToolEntry {
19 pub tool: McpTool,
20 pub operation_index: usize,
22 pub method: String,
24 pub path: String,
26 pub parameters: Vec<barbacane_compiler::Parameter>,
28}
29
30pub 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
59fn 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
73fn build_input_schema(op: &CompiledOperation) -> serde_json::Value {
75 let mut properties = serde_json::Map::new();
76 let mut required = Vec::new();
77
78 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 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
114fn 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 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 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 body_schema.get("properties").is_none() && body_schema.get("type").is_some() {
138 properties.insert("body".to_string(), body_schema.clone());
139 }
140}
141
142fn build_output_schema(op: &CompiledOperation) -> Option<serde_json::Value> {
146 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 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
167type DecomposedRequest = (String, Option<String>, Option<Vec<u8>>);
169
170pub 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 for param in &entry.parameters {
197 if param.location == "path" {
198 if let Some(val) = args.get(¶m.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(¶m.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(¶m.name) {
215 let val_str = match val {
216 serde_json::Value::String(s) => s.clone(),
217 other => other.to_string(),
218 };
219 query_parts.push(format!(
221 "{}={}",
222 percent_encode(¶m.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 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
251fn sanitize_path_param(name: &str, value: &str, is_wildcard: bool) -> Result<String, String> {
257 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 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 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
288fn 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 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 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 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 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 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 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 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")); }
720}