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
167pub 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 for param in &entry.parameters {
182 if param.location == "path" {
183 if let Some(val) = args.get(¶m.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 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(¶m.name) {
195 let val_str = match val {
196 serde_json::Value::String(s) => s.clone(),
197 other => other.to_string(),
198 };
199 query_parts.push(format!(
201 "{}={}",
202 percent_encode(¶m.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 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
231fn 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 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 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 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")); }
586}