1use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5
6use crate::client::{MCPClient, ToolResult, ToolResultContent};
7use crate::discovery::ToolMetadata;
8
9const MAX_TOOL_NAME_LEN: usize = 64;
14
15pub fn sanitize_tool_name(name: &str) -> String {
21 let mut out: String = name
22 .chars()
23 .map(|c| {
24 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
25 c
26 } else {
27 '_'
28 }
29 })
30 .collect();
31 out.truncate(MAX_TOOL_NAME_LEN);
32 if out.is_empty() {
33 "tool".to_string()
34 } else {
35 out
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct ExecutionResult {
42 pub success: bool,
44 pub data: Value,
46 pub text: String,
48}
49
50pub struct ToolExecutor {
52 clients: HashMap<String, MCPClient>,
54 aliases: HashMap<String, (String, String)>,
60}
61
62impl ToolExecutor {
63 pub fn new() -> Self {
65 Self {
66 clients: HashMap::new(),
67 aliases: HashMap::new(),
68 }
69 }
70
71 pub fn add_client(&mut self, server_name: String, client: MCPClient) {
77 let _ = self.add_client_advertised(server_name, client, &HashSet::new());
78 }
79
80 pub fn add_client_advertised(
88 &mut self,
89 server_name: String,
90 client: MCPClient,
91 reserved: &HashSet<String>,
92 ) -> Vec<ToolMetadata> {
93 let mut advertised = Vec::new();
94 for tool in client.cached_tools() {
95 let name = self.unique_advertised_name(&tool.name, &server_name, reserved);
96 self.aliases
97 .insert(name.clone(), (server_name.clone(), tool.name.clone()));
98 if name != tool.name {
99 tracing::debug!(
100 server = %server_name,
101 original = %tool.name,
102 advertised = %name,
103 "Renamed MCP tool to satisfy provider naming rules"
104 );
105 }
106 advertised.push(ToolMetadata {
107 name,
108 description: tool.description.clone(),
109 schema: tool.schema.clone(),
110 });
111 }
112 self.clients.insert(server_name, client);
113 advertised
114 }
115
116 fn unique_advertised_name(
122 &self,
123 original: &str,
124 server: &str,
125 reserved: &HashSet<String>,
126 ) -> String {
127 let free = |name: &str| !reserved.contains(name) && !self.aliases.contains_key(name);
128
129 let base = sanitize_tool_name(original);
130 if free(&base) {
131 return base;
132 }
133 let prefixed = sanitize_tool_name(&format!("{server}__{original}"));
134 if free(&prefixed) {
135 return prefixed;
136 }
137 let mut n = 2;
138 loop {
139 let candidate = sanitize_tool_name(&format!("{prefixed}_{n}"));
140 if free(&candidate) {
141 return candidate;
142 }
143 n += 1;
144 }
145 }
146
147 pub async fn execute(
149 &mut self,
150 tool_name: &str,
151 arguments: Value,
152 ) -> anyhow::Result<ExecutionResult> {
153 tracing::info!(tool = %tool_name, "Executing tool");
154
155 if let Some((server, original)) = self.aliases.get(tool_name).cloned() {
157 return self.execute_on(&server, &original, arguments).await;
158 }
159
160 Err(anyhow::anyhow!(
161 "No MCP server found with tool '{}'. Available tools: {:?}",
162 tool_name,
163 self.aliases.keys().collect::<Vec<_>>()
164 ))
165 }
166
167 pub async fn execute_on(
169 &mut self,
170 server_name: &str,
171 tool_name: &str,
172 arguments: Value,
173 ) -> anyhow::Result<ExecutionResult> {
174 tracing::info!(server = %server_name, tool = %tool_name, "Executing tool on server");
175
176 let client = self
177 .clients
178 .get_mut(server_name)
179 .ok_or_else(|| anyhow::anyhow!("MCP server '{}' not found", server_name))?;
180
181 let tool_result = client.call_tool(tool_name, arguments).await?;
182 Ok(Self::map_result(tool_result))
183 }
184
185 pub async fn execute_filtered(
189 &mut self,
190 tool_name: &str,
191 arguments: Value,
192 allowed_tools: &[String],
193 ) -> anyhow::Result<ExecutionResult> {
194 if !allowed_tools.iter().any(|t| t == tool_name) {
195 return Ok(ExecutionResult {
196 success: false,
197 data: Value::Null,
198 text: format!(
199 "Tool '{}' is not allowed in the current stage. Allowed tools: {:?}",
200 tool_name, allowed_tools
201 ),
202 });
203 }
204 self.execute(tool_name, arguments).await
205 }
206
207 pub async fn shutdown_all(&mut self) -> anyhow::Result<()> {
213 tracing::info!("Shutting down all MCP clients");
214 for client in self.clients.values_mut() {
215 let _ = client.shutdown().await;
216 }
217 self.clients.clear();
218 Ok(())
219 }
220
221 pub fn server_count(&self) -> usize {
223 self.clients.len()
224 }
225
226 fn map_result(tool_result: ToolResult) -> ExecutionResult {
232 let mut parts: Vec<&str> = Vec::new();
233 for content in &tool_result.content {
234 match content {
235 ToolResultContent::Text { text } => parts.push(text.as_str()),
236 ToolResultContent::Resource { resource } => {
237 if let Some(text) = resource.text.as_deref() {
238 parts.push(text);
239 }
240 }
241 ToolResultContent::Image { .. }
242 | ToolResultContent::Audio { .. }
243 | ToolResultContent::ResourceLink { .. } => {}
244 ToolResultContent::Unknown => {
245 tracing::warn!("Skipping unrecognized MCP content block in tool result");
246 }
247 }
248 }
249 let mut text = parts.join("\n");
250
251 if text.is_empty()
255 && let Some(structured) = &tool_result.structured_content
256 {
257 text = structured.to_string();
258 }
259
260 let data = serde_json::to_value(&tool_result.content).unwrap_or(Value::Null);
261
262 ExecutionResult {
263 success: !tool_result.is_error,
264 data,
265 text,
266 }
267 }
268}
269
270impl Default for ToolExecutor {
271 fn default() -> Self {
272 Self::new()
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::client::EmbeddedResource;
280 use crate::test_support::always_on_tracing_guard;
281
282 #[tokio::test]
283 async fn test_execute_filtered_rejects_disallowed_tool() {
284 let mut executor = ToolExecutor::new();
285 let allowed = vec!["read_file".to_string(), "write_file".to_string()];
286
287 let result = executor
288 .execute_filtered("delete_file", serde_json::json!({}), &allowed)
289 .await
290 .unwrap();
291
292 assert!(!result.success);
293 assert!(result.text.contains("not allowed"));
294 }
295
296 #[test]
297 fn test_tool_executor_creation() {
298 let executor = ToolExecutor::new();
299 assert_eq!(executor.server_count(), 0);
300 }
301
302 #[test]
305 fn test_tool_executor_default() {
306 let executor = ToolExecutor::default();
307 assert_eq!(executor.server_count(), 0);
308 }
309
310 #[test]
313 fn test_map_result_text_content() {
314 let tool_result = ToolResult {
315 content: vec![ToolResultContent::Text {
316 text: "Hello world".to_string(),
317 }],
318 structured_content: None,
319 is_error: false,
320 };
321 let result = ToolExecutor::map_result(tool_result);
322 assert!(result.success);
323 assert_eq!(result.text, "Hello world");
324 }
325
326 #[test]
327 fn test_map_result_error() {
328 let tool_result = ToolResult {
329 content: vec![ToolResultContent::Text {
330 text: "Something failed".to_string(),
331 }],
332 structured_content: None,
333 is_error: true,
334 };
335 let result = ToolExecutor::map_result(tool_result);
336 assert!(!result.success);
337 assert_eq!(result.text, "Something failed");
338 }
339
340 #[test]
341 fn test_map_result_empty_content() {
342 let tool_result = ToolResult {
343 content: vec![],
344 structured_content: None,
345 is_error: false,
346 };
347 let result = ToolExecutor::map_result(tool_result);
348 assert!(result.success);
349 assert_eq!(result.text, "");
350 }
351
352 #[test]
353 fn test_map_result_multiple_text() {
354 let tool_result = ToolResult {
355 content: vec![
356 ToolResultContent::Text {
357 text: "line1".to_string(),
358 },
359 ToolResultContent::Text {
360 text: "line2".to_string(),
361 },
362 ],
363 structured_content: None,
364 is_error: false,
365 };
366 let result = ToolExecutor::map_result(tool_result);
367 assert_eq!(result.text, "line1\nline2");
368 }
369
370 #[test]
371 fn test_map_result_image_excluded_from_text() {
372 let tool_result = ToolResult {
373 content: vec![
374 ToolResultContent::Text {
375 text: "before".to_string(),
376 },
377 ToolResultContent::Image {
378 data: "base64data".to_string(),
379 mime_type: "image/png".to_string(),
380 },
381 ToolResultContent::Text {
382 text: "after".to_string(),
383 },
384 ],
385 structured_content: None,
386 is_error: false,
387 };
388 let result = ToolExecutor::map_result(tool_result);
389 assert_eq!(result.text, "before\nafter");
390 }
391
392 #[test]
393 fn test_map_result_resource_with_text() {
394 let tool_result = ToolResult {
395 content: vec![ToolResultContent::Resource {
396 resource: EmbeddedResource {
397 uri: "file:///test".to_string(),
398 text: Some("resource content".to_string()),
399 blob: None,
400 mime_type: None,
401 },
402 }],
403 structured_content: None,
404 is_error: false,
405 };
406 let result = ToolExecutor::map_result(tool_result);
407 assert_eq!(result.text, "resource content");
408 }
409
410 #[test]
411 fn test_map_result_resource_without_text() {
412 let tool_result = ToolResult {
413 content: vec![ToolResultContent::Resource {
414 resource: EmbeddedResource {
415 uri: "file:///test".to_string(),
416 text: None,
417 blob: None,
418 mime_type: None,
419 },
420 }],
421 structured_content: None,
422 is_error: false,
423 };
424 let result = ToolExecutor::map_result(tool_result);
425 assert_eq!(result.text, "");
426 }
427
428 #[test]
429 fn test_map_result_data_is_json() {
430 let tool_result = ToolResult {
431 content: vec![ToolResultContent::Text {
432 text: "hi".to_string(),
433 }],
434 structured_content: None,
435 is_error: false,
436 };
437 let result = ToolExecutor::map_result(tool_result);
438 assert!(result.data.is_array());
439 }
440
441 #[tokio::test]
444 async fn test_execute_filtered_allowed_tool_but_no_server() {
445 let _guard = always_on_tracing_guard();
446 let mut executor = ToolExecutor::new();
447 let allowed = vec!["read_file".to_string()];
448
449 let result = executor
451 .execute_filtered("read_file", serde_json::json!({}), &allowed)
452 .await;
453 assert!(result.is_err());
454 assert!(result.unwrap_err().to_string().contains("No MCP server"));
455 }
456
457 #[tokio::test]
460 async fn test_execute_no_server_errors() {
461 let _guard = always_on_tracing_guard();
462 let mut executor = ToolExecutor::new();
463 let result = executor
464 .execute("nonexistent_tool", serde_json::json!({}))
465 .await;
466 assert!(result.is_err());
467 }
468
469 #[tokio::test]
472 async fn test_execute_on_unknown_server() {
473 let _guard = always_on_tracing_guard();
474 let mut executor = ToolExecutor::new();
475 let result = executor
476 .execute_on("unknown_server", "tool", serde_json::json!({}))
477 .await;
478 assert!(result.is_err());
479 assert!(result.unwrap_err().to_string().contains("not found"));
480 }
481
482 #[tokio::test]
485 async fn test_shutdown_all_empty() {
486 let _guard = always_on_tracing_guard();
487 let mut executor = ToolExecutor::new();
488 let result = executor.shutdown_all().await;
489 assert!(result.is_ok());
490 assert_eq!(executor.server_count(), 0);
491 }
492
493 const STUB_INIT_LIST_AND_CALL: &str = r#"
503import sys, json
504
505def respond(id, result):
506 msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
507 sys.stdout.write(msg + "\n")
508 sys.stdout.flush()
509
510for line in sys.stdin:
511 line = line.strip()
512 if not line:
513 continue
514 req = json.loads(line)
515 method = req.get("method", "")
516 id_ = req.get("id")
517 if method == "initialize":
518 respond(id_, {"capabilities": {"tools": {"listChanged": True}}, "protocolVersion": "2024-11-05"})
519 elif method == "notifications/initialized":
520 pass
521 elif method == "tools/list":
522 respond(id_, {"tools": [{"name": "echo", "description": "echo tool", "inputSchema": {}}]})
523 elif method == "tools/call":
524 respond(id_, {"content": [{"type": "text", "text": "hello from tool"}], "isError": False})
525 elif method == "notifications/cancelled":
526 pass
527 else:
528 respond(id_, {"error": {"code": -32601, "message": "method not found"}})
529"#;
530
531 async fn spawn_ready_client() -> MCPClient {
532 let mut client =
533 MCPClient::spawn("python3", &["-c", STUB_INIT_LIST_AND_CALL], &HashMap::new())
534 .await
535 .expect("failed to spawn stub server");
536 client.connect().await.expect("connect should succeed");
537 client
538 .list_tools()
539 .await
540 .expect("list_tools should succeed");
541 client
542 }
543
544 #[tokio::test]
545 async fn add_client_and_server_count_reflects_it() {
546 let mut executor = ToolExecutor::new();
547 let client = spawn_ready_client().await;
548 executor.add_client("server1".to_string(), client);
549 assert_eq!(executor.server_count(), 1);
550 }
551
552 #[tokio::test]
553 async fn execute_finds_owning_server_and_calls_tool() {
554 let _guard = always_on_tracing_guard();
555 let mut executor = ToolExecutor::new();
556 let client = spawn_ready_client().await;
557 executor.add_client("server1".to_string(), client);
558
559 let result = executor
560 .execute("echo", serde_json::json!({"text": "hi"}))
561 .await
562 .expect("execute should succeed");
563 assert!(result.success);
564 assert_eq!(result.text, "hello from tool");
565 }
566
567 #[tokio::test]
568 async fn execute_on_specific_server_calls_tool() {
569 let _guard = always_on_tracing_guard();
570 let mut executor = ToolExecutor::new();
571 let client = spawn_ready_client().await;
572 executor.add_client("server1".to_string(), client);
573
574 let result = executor
575 .execute_on("server1", "echo", serde_json::json!({}))
576 .await
577 .expect("execute_on should succeed");
578 assert!(result.success);
579 assert_eq!(result.text, "hello from tool");
580 }
581
582 #[tokio::test]
583 async fn execute_filtered_allowed_tool_with_server_succeeds() {
584 let _guard = always_on_tracing_guard();
585 let mut executor = ToolExecutor::new();
586 let client = spawn_ready_client().await;
587 executor.add_client("server1".to_string(), client);
588
589 let allowed = vec!["echo".to_string()];
590 let result = executor
591 .execute_filtered("echo", serde_json::json!({}), &allowed)
592 .await
593 .expect("execute_filtered should succeed");
594 assert!(result.success);
595 }
596
597 #[tokio::test]
598 async fn shutdown_all_with_live_client_succeeds_and_clears() {
599 let _guard = always_on_tracing_guard();
600 let mut executor = ToolExecutor::new();
601 let client = spawn_ready_client().await;
602 executor.add_client("server1".to_string(), client);
603
604 let result = executor.shutdown_all().await;
605 assert!(result.is_ok());
606 assert_eq!(executor.server_count(), 0);
607 }
608
609 #[test]
612 fn test_execution_result_clone() {
613 let result = ExecutionResult {
614 success: true,
615 data: serde_json::json!("test"),
616 text: "hello".to_string(),
617 };
618 let cloned = result.clone();
619 assert!(cloned.success);
620 assert_eq!(cloned.text, "hello");
621 }
622
623 #[test]
624 fn test_execution_result_debug() {
625 let result = ExecutionResult {
626 success: false,
627 data: Value::Null,
628 text: "error".to_string(),
629 };
630 let debug = format!("{:?}", result);
631 assert!(debug.contains("success"));
632 assert!(debug.contains("false"));
633 }
634
635 const STUB_CALL_ERROR: &str = r#"
641import sys, json
642
643def respond(id, result):
644 msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
645 sys.stdout.write(msg + "\n")
646 sys.stdout.flush()
647
648def error(id, message):
649 msg = json.dumps({"jsonrpc": "2.0", "id": id, "error": {"code": -32603, "message": message}})
650 sys.stdout.write(msg + "\n")
651 sys.stdout.flush()
652
653for line in sys.stdin:
654 line = line.strip()
655 if not line:
656 continue
657 req = json.loads(line)
658 method = req.get("method", "")
659 id_ = req.get("id")
660 if method == "initialize":
661 respond(id_, {"capabilities": {"tools": {}}, "protocolVersion": "2024-11-05"})
662 elif method == "notifications/initialized":
663 pass
664 elif method == "tools/list":
665 respond(id_, {"tools": [{"name": "echo", "description": "echo", "inputSchema": {}}]})
666 elif method == "tools/call":
667 error(id_, "tool execution failed")
668 elif method == "notifications/cancelled":
669 pass
670"#;
671
672 #[tokio::test]
673 async fn execute_on_propagates_call_tool_error() {
674 let _guard = always_on_tracing_guard();
675 let mut client = MCPClient::spawn("python3", &["-c", STUB_CALL_ERROR], &HashMap::new())
676 .await
677 .expect("spawn");
678 client.connect().await.expect("connect");
679 client.list_tools().await.expect("list_tools");
680
681 let mut executor = ToolExecutor::new();
682 executor.add_client("server1".to_string(), client);
683
684 let result = executor
685 .execute_on("server1", "echo", serde_json::json!({}))
686 .await;
687 assert!(result.is_err());
688 assert!(
689 result
690 .unwrap_err()
691 .to_string()
692 .contains("tool execution failed")
693 );
694 }
695
696 fn text_of(content: Vec<ToolResultContent>) -> String {
699 ToolExecutor::map_result(ToolResult {
700 content,
701 structured_content: None,
702 is_error: false,
703 })
704 .text
705 }
706
707 #[test]
708 fn map_result_reports_tool_execution_error_as_failure() {
709 let result = ToolExecutor::map_result(ToolResult {
712 content: vec![ToolResultContent::Text {
713 text: "Invalid departure date".to_string(),
714 }],
715 structured_content: None,
716 is_error: true,
717 });
718 assert!(!result.success);
719 assert_eq!(result.text, "Invalid departure date");
720 }
721
722 #[test]
723 fn map_result_skips_binary_blocks() {
724 let text = text_of(vec![
725 ToolResultContent::Text {
726 text: "before".to_string(),
727 },
728 ToolResultContent::Image {
729 data: "YWJj".to_string(),
730 mime_type: "image/png".to_string(),
731 },
732 ToolResultContent::Audio {
733 data: "YWJj".to_string(),
734 mime_type: "audio/wav".to_string(),
735 },
736 ToolResultContent::Text {
737 text: "after".to_string(),
738 },
739 ]);
740 assert_eq!(text, "before\nafter");
741 }
742
743 #[test]
744 fn map_result_skips_resource_links() {
745 let text = text_of(vec![ToolResultContent::ResourceLink {
746 uri: "file:///x".to_string(),
747 name: "x".to_string(),
748 description: None,
749 mime_type: None,
750 }]);
751 assert_eq!(text, "");
752 }
753
754 #[test]
755 fn map_result_skips_unknown_blocks_without_losing_the_rest() {
756 let _guard = always_on_tracing_guard();
757 let text = text_of(vec![
758 ToolResultContent::Unknown,
759 ToolResultContent::Text {
760 text: "still here".to_string(),
761 },
762 ]);
763 assert_eq!(text, "still here");
764 }
765
766 #[test]
767 fn map_result_falls_back_to_structured_content_when_no_text() {
768 let result = ToolExecutor::map_result(ToolResult {
771 content: vec![],
772 structured_content: Some(serde_json::json!({"temperature": 22.5})),
773 is_error: false,
774 });
775 assert_eq!(result.text, r#"{"temperature":22.5}"#);
776 }
777
778 #[test]
779 fn map_result_prefers_text_blocks_over_structured_content() {
780 let result = ToolExecutor::map_result(ToolResult {
781 content: vec![ToolResultContent::Text {
782 text: "human readable".to_string(),
783 }],
784 structured_content: Some(serde_json::json!({"a": 1})),
785 is_error: false,
786 });
787 assert_eq!(result.text, "human readable");
788 }
789
790 #[test]
791 fn map_result_embedded_resource_blob_contributes_no_text() {
792 let text = text_of(vec![ToolResultContent::Resource {
793 resource: EmbeddedResource {
794 uri: "file:///a.png".to_string(),
795 text: None,
796 blob: Some("YWJj".to_string()),
797 mime_type: Some("image/png".to_string()),
798 },
799 }]);
800 assert_eq!(text, "");
801 }
802
803 #[test]
806 fn sanitize_passes_a_clean_name_through() {
807 assert_eq!(sanitize_tool_name("get_weather-2"), "get_weather-2");
808 }
809
810 #[test]
811 fn sanitize_replaces_dots_and_other_illegal_chars() {
812 assert_eq!(sanitize_tool_name("admin.tools.list"), "admin_tools_list");
814 assert_eq!(sanitize_tool_name("weird name!/#"), "weird_name___");
815 }
816
817 #[test]
818 fn sanitize_truncates_to_the_limit() {
819 let long = "a".repeat(200);
820 assert_eq!(sanitize_tool_name(&long).len(), MAX_TOOL_NAME_LEN);
821 }
822
823 #[test]
824 fn sanitize_of_illegal_chars_becomes_underscores_and_empty_falls_back() {
825 assert_eq!(sanitize_tool_name("...."), "____");
828 assert_eq!(sanitize_tool_name(""), "tool");
829 }
830
831 #[test]
834 fn unique_name_prefers_the_sanitized_base() {
835 let exec = ToolExecutor::new();
836 let reserved = HashSet::new();
837 assert_eq!(
838 exec.unique_advertised_name("github.search", "gh", &reserved),
839 "github_search"
840 );
841 }
842
843 #[test]
844 fn unique_name_prefixes_on_a_reserved_collision() {
845 let exec = ToolExecutor::new();
847 let reserved: HashSet<String> = ["bash".to_string()].into_iter().collect();
848 assert_eq!(
849 exec.unique_advertised_name("bash", "srv", &reserved),
850 "srv__bash"
851 );
852 }
853
854 #[test]
855 fn unique_name_prefixes_on_an_existing_alias_collision() {
856 let mut exec = ToolExecutor::new();
857 exec.aliases.insert(
858 "search".to_string(),
859 ("a".to_string(), "search".to_string()),
860 );
861 assert_eq!(
862 exec.unique_advertised_name("search", "b", &HashSet::new()),
863 "b__search"
864 );
865 }
866
867 #[test]
868 fn unique_name_appends_a_number_when_the_prefix_also_collides() {
869 let mut exec = ToolExecutor::new();
870 exec.aliases.insert(
872 "search".to_string(),
873 ("a".to_string(), "search".to_string()),
874 );
875 exec.aliases
876 .insert("b__search".to_string(), ("x".to_string(), "y".to_string()));
877 assert_eq!(
878 exec.unique_advertised_name("search", "b", &HashSet::new()),
879 "b__search_2"
880 );
881
882 exec.aliases.insert(
884 "b__search_2".to_string(),
885 ("x".to_string(), "y".to_string()),
886 );
887 assert_eq!(
888 exec.unique_advertised_name("search", "b", &HashSet::new()),
889 "b__search_3"
890 );
891 }
892
893 fn stub_named(tool_name: &str) -> String {
897 format!(
898 r#"
899import sys, json
900def respond(id, result):
901 sys.stdout.write(json.dumps({{"jsonrpc": "2.0", "id": id, "result": result}}) + "\n")
902 sys.stdout.flush()
903for line in sys.stdin:
904 line = line.strip()
905 if not line:
906 continue
907 req = json.loads(line)
908 method, id_ = req.get("method", ""), req.get("id")
909 if method == "initialize":
910 respond(id_, {{"capabilities": {{}}, "protocolVersion": "2024-11-05"}})
911 elif method == "tools/list":
912 respond(id_, {{"tools": [{{"name": "{tool_name}", "inputSchema": {{}}}}]}})
913 elif method == "tools/call":
914 respond(id_, {{"content": [{{"type": "text", "text": "called " + req["params"]["name"]}}], "isError": False}})
915"#
916 )
917 }
918
919 async fn spawn_named(tool_name: &str) -> MCPClient {
920 let mut client =
921 MCPClient::spawn("python3", &["-c", &stub_named(tool_name)], &HashMap::new())
922 .await
923 .expect("spawn");
924 client.connect().await.expect("connect");
925 client.list_tools().await.expect("list");
926 client
927 }
928
929 #[tokio::test]
930 async fn a_dotted_tool_is_advertised_sanitized_and_still_routes() {
931 let _guard = always_on_tracing_guard();
932 let mut executor = ToolExecutor::new();
933 let client = spawn_named("github.search").await;
934 let advertised = executor.add_client_advertised("gh".to_string(), client, &HashSet::new());
935 assert_eq!(advertised[0].name, "github_search");
936
937 let result = executor
940 .execute("github_search", serde_json::json!({}))
941 .await
942 .expect("advertised name routes");
943 assert!(result.success);
944 assert_eq!(result.text, "called github.search");
945 }
946
947 #[tokio::test]
948 async fn two_servers_sharing_a_tool_name_are_disambiguated() {
949 let _guard = always_on_tracing_guard();
950 let mut executor = ToolExecutor::new();
951
952 let a = spawn_named("search").await;
953 let a_names = executor.add_client_advertised("alpha".to_string(), a, &HashSet::new());
954 assert_eq!(a_names[0].name, "search");
955
956 let b = spawn_named("search").await;
957 let reserved: HashSet<String> = a_names.iter().map(|t| t.name.clone()).collect();
959 let b_names = executor.add_client_advertised("beta".to_string(), b, &reserved);
960 assert_eq!(b_names[0].name, "beta__search");
961
962 assert!(
964 executor
965 .execute("search", serde_json::json!({}))
966 .await
967 .unwrap()
968 .success
969 );
970 assert!(
971 executor
972 .execute("beta__search", serde_json::json!({}))
973 .await
974 .unwrap()
975 .success
976 );
977 }
978
979 #[tokio::test]
980 async fn add_client_reserving_nothing_registers_identity_aliases() {
981 let _guard = always_on_tracing_guard();
982 let mut executor = ToolExecutor::new();
983 let client = spawn_named("plain").await;
984 executor.add_client("s".to_string(), client);
985 assert!(
986 executor
987 .execute("plain", serde_json::json!({}))
988 .await
989 .unwrap()
990 .success
991 );
992 }
993}