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_advertised(
79 &mut self,
80 server_name: String,
81 client: MCPClient,
82 reserved: &HashSet<String>,
83 ) -> Vec<ToolMetadata> {
84 let mut advertised = Vec::new();
85 for tool in client.cached_tools() {
86 let name = self.unique_advertised_name(&tool.name, &server_name, reserved);
87 self.aliases
88 .insert(name.clone(), (server_name.clone(), tool.name.clone()));
89 if name != tool.name {
90 tracing::debug!(
91 server = %server_name,
92 original = %tool.name,
93 advertised = %name,
94 "Renamed MCP tool to satisfy provider naming rules"
95 );
96 }
97 advertised.push(ToolMetadata {
98 name,
99 description: tool.description.clone(),
100 schema: tool.schema.clone(),
101 });
102 }
103 self.clients.insert(server_name, client);
104 advertised
105 }
106
107 pub fn remove_client(&mut self, server_name: &str) -> Option<MCPClient> {
116 let client = self.clients.remove(server_name)?;
117 self.aliases.retain(|_, (server, _)| server != server_name);
118 Some(client)
119 }
120
121 fn unique_advertised_name(
127 &self,
128 original: &str,
129 server: &str,
130 reserved: &HashSet<String>,
131 ) -> String {
132 let free = |name: &str| !reserved.contains(name) && !self.aliases.contains_key(name);
133
134 let base = sanitize_tool_name(original);
135 if free(&base) {
136 return base;
137 }
138 let prefixed = sanitize_tool_name(&format!("{server}__{original}"));
139 if free(&prefixed) {
140 return prefixed;
141 }
142 let mut n = 2;
143 loop {
144 let candidate = sanitize_tool_name(&format!("{prefixed}_{n}"));
145 if free(&candidate) {
146 return candidate;
147 }
148 n += 1;
149 }
150 }
151
152 pub async fn execute(
154 &mut self,
155 tool_name: &str,
156 arguments: Value,
157 ) -> anyhow::Result<ExecutionResult> {
158 tracing::info!(tool = %tool_name, "Executing tool");
159
160 if let Some((server, original)) = self.aliases.get(tool_name).cloned() {
162 return self.execute_on(&server, &original, arguments).await;
163 }
164
165 Err(anyhow::anyhow!(
166 "No MCP server found with tool '{}'. Available tools: {:?}",
167 tool_name,
168 self.aliases.keys().collect::<Vec<_>>()
169 ))
170 }
171
172 pub async fn execute_on(
174 &mut self,
175 server_name: &str,
176 tool_name: &str,
177 arguments: Value,
178 ) -> anyhow::Result<ExecutionResult> {
179 tracing::info!(server = %server_name, tool = %tool_name, "Executing tool on server");
180
181 let client = self
182 .clients
183 .get_mut(server_name)
184 .ok_or_else(|| anyhow::anyhow!("MCP server '{}' not found", server_name))?;
185
186 let tool_result = client.call_tool(tool_name, arguments).await?;
187 Ok(Self::map_result(tool_result))
188 }
189
190 pub async fn shutdown_all(&mut self) -> anyhow::Result<()> {
196 tracing::info!("Shutting down all MCP clients");
197 for client in self.clients.values_mut() {
198 let _ = client.shutdown().await;
199 }
200 self.clients.clear();
201 Ok(())
202 }
203
204 pub fn server_count(&self) -> usize {
206 self.clients.len()
207 }
208
209 fn map_result(tool_result: ToolResult) -> ExecutionResult {
215 let mut parts: Vec<&str> = Vec::new();
216 for content in &tool_result.content {
217 match content {
218 ToolResultContent::Text { text } => parts.push(text.as_str()),
219 ToolResultContent::Resource { resource } => {
220 if let Some(text) = resource.text.as_deref() {
221 parts.push(text);
222 }
223 }
224 ToolResultContent::Image { .. }
225 | ToolResultContent::Audio { .. }
226 | ToolResultContent::ResourceLink { .. } => {}
227 ToolResultContent::Unknown => {
228 tracing::warn!("Skipping unrecognized MCP content block in tool result");
229 }
230 }
231 }
232 let mut text = parts.join("\n");
233
234 if text.is_empty()
238 && let Some(structured) = &tool_result.structured_content
239 {
240 text = structured.to_string();
241 }
242
243 let data = serde_json::to_value(&tool_result.content).unwrap_or(Value::Null);
244
245 ExecutionResult {
246 success: !tool_result.is_error,
247 data,
248 text,
249 }
250 }
251}
252
253impl Default for ToolExecutor {
254 fn default() -> Self {
255 Self::new()
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::client::EmbeddedResource;
263 use crate::test_support::always_on_tracing_guard;
264
265 #[test]
266 fn test_tool_executor_creation() {
267 let executor = ToolExecutor::new();
268 assert_eq!(executor.server_count(), 0);
269 }
270
271 #[test]
274 fn test_tool_executor_default() {
275 let executor = ToolExecutor::default();
276 assert_eq!(executor.server_count(), 0);
277 }
278
279 #[test]
282 fn test_map_result_text_content() {
283 let tool_result = ToolResult {
284 content: vec![ToolResultContent::Text {
285 text: "Hello world".to_string(),
286 }],
287 structured_content: None,
288 is_error: false,
289 };
290 let result = ToolExecutor::map_result(tool_result);
291 assert!(result.success);
292 assert_eq!(result.text, "Hello world");
293 }
294
295 #[test]
296 fn test_map_result_error() {
297 let tool_result = ToolResult {
298 content: vec![ToolResultContent::Text {
299 text: "Something failed".to_string(),
300 }],
301 structured_content: None,
302 is_error: true,
303 };
304 let result = ToolExecutor::map_result(tool_result);
305 assert!(!result.success);
306 assert_eq!(result.text, "Something failed");
307 }
308
309 #[test]
310 fn test_map_result_empty_content() {
311 let tool_result = ToolResult {
312 content: vec![],
313 structured_content: None,
314 is_error: false,
315 };
316 let result = ToolExecutor::map_result(tool_result);
317 assert!(result.success);
318 assert_eq!(result.text, "");
319 }
320
321 #[test]
322 fn test_map_result_multiple_text() {
323 let tool_result = ToolResult {
324 content: vec![
325 ToolResultContent::Text {
326 text: "line1".to_string(),
327 },
328 ToolResultContent::Text {
329 text: "line2".to_string(),
330 },
331 ],
332 structured_content: None,
333 is_error: false,
334 };
335 let result = ToolExecutor::map_result(tool_result);
336 assert_eq!(result.text, "line1\nline2");
337 }
338
339 #[test]
340 fn test_map_result_image_excluded_from_text() {
341 let tool_result = ToolResult {
342 content: vec![
343 ToolResultContent::Text {
344 text: "before".to_string(),
345 },
346 ToolResultContent::Image {
347 data: "base64data".to_string(),
348 mime_type: "image/png".to_string(),
349 },
350 ToolResultContent::Text {
351 text: "after".to_string(),
352 },
353 ],
354 structured_content: None,
355 is_error: false,
356 };
357 let result = ToolExecutor::map_result(tool_result);
358 assert_eq!(result.text, "before\nafter");
359 }
360
361 #[test]
362 fn test_map_result_resource_with_text() {
363 let tool_result = ToolResult {
364 content: vec![ToolResultContent::Resource {
365 resource: EmbeddedResource {
366 uri: "file:///test".to_string(),
367 text: Some("resource content".to_string()),
368 blob: None,
369 mime_type: None,
370 },
371 }],
372 structured_content: None,
373 is_error: false,
374 };
375 let result = ToolExecutor::map_result(tool_result);
376 assert_eq!(result.text, "resource content");
377 }
378
379 #[test]
380 fn test_map_result_resource_without_text() {
381 let tool_result = ToolResult {
382 content: vec![ToolResultContent::Resource {
383 resource: EmbeddedResource {
384 uri: "file:///test".to_string(),
385 text: None,
386 blob: None,
387 mime_type: None,
388 },
389 }],
390 structured_content: None,
391 is_error: false,
392 };
393 let result = ToolExecutor::map_result(tool_result);
394 assert_eq!(result.text, "");
395 }
396
397 #[test]
398 fn test_map_result_data_is_json() {
399 let tool_result = ToolResult {
400 content: vec![ToolResultContent::Text {
401 text: "hi".to_string(),
402 }],
403 structured_content: None,
404 is_error: false,
405 };
406 let result = ToolExecutor::map_result(tool_result);
407 assert!(result.data.is_array());
408 }
409
410 #[tokio::test]
415 async fn test_execute_no_server_errors() {
416 let _guard = always_on_tracing_guard();
417 let mut executor = ToolExecutor::new();
418 let result = executor
419 .execute("nonexistent_tool", serde_json::json!({}))
420 .await;
421 assert!(result.is_err());
422 }
423
424 #[tokio::test]
427 async fn test_execute_on_unknown_server() {
428 let _guard = always_on_tracing_guard();
429 let mut executor = ToolExecutor::new();
430 let result = executor
431 .execute_on("unknown_server", "tool", serde_json::json!({}))
432 .await;
433 assert!(result.is_err());
434 assert!(result.unwrap_err().to_string().contains("not found"));
435 }
436
437 #[tokio::test]
440 async fn test_shutdown_all_empty() {
441 let _guard = always_on_tracing_guard();
442 let mut executor = ToolExecutor::new();
443 let result = executor.shutdown_all().await;
444 assert!(result.is_ok());
445 assert_eq!(executor.server_count(), 0);
446 }
447
448 const STUB_INIT_LIST_AND_CALL: &str = r#"
458import sys, json
459
460def respond(id, result):
461 msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
462 sys.stdout.write(msg + "\n")
463 sys.stdout.flush()
464
465for line in sys.stdin:
466 line = line.strip()
467 if not line:
468 continue
469 req = json.loads(line)
470 method = req.get("method", "")
471 id_ = req.get("id")
472 if method == "initialize":
473 respond(id_, {"capabilities": {"tools": {"listChanged": True}}, "protocolVersion": "2024-11-05"})
474 elif method == "notifications/initialized":
475 pass
476 elif method == "tools/list":
477 respond(id_, {"tools": [{"name": "echo", "description": "echo tool", "inputSchema": {}}]})
478 elif method == "tools/call":
479 respond(id_, {"content": [{"type": "text", "text": "hello from tool"}], "isError": False})
480 elif method == "notifications/cancelled":
481 pass
482 else:
483 respond(id_, {"error": {"code": -32601, "message": "method not found"}})
484"#;
485
486 async fn spawn_ready_client() -> MCPClient {
487 let mut client =
488 MCPClient::spawn("python3", &["-c", STUB_INIT_LIST_AND_CALL], &HashMap::new())
489 .await
490 .expect("failed to spawn stub server");
491 client.connect().await.expect("connect should succeed");
492 client
493 .list_tools()
494 .await
495 .expect("list_tools should succeed");
496 client
497 }
498
499 #[tokio::test]
500 async fn add_client_and_server_count_reflects_it() {
501 let mut executor = ToolExecutor::new();
502 let client = spawn_ready_client().await;
503 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
504 assert_eq!(executor.server_count(), 1);
505 }
506
507 #[tokio::test]
511 async fn remove_client_takes_the_server_and_its_aliases() {
512 let mut executor = ToolExecutor::new();
513 let client = spawn_ready_client().await;
514 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
515 assert_eq!(executor.server_count(), 1);
516
517 assert!(executor.remove_client("nope").is_none());
518 let mut taken = executor.remove_client("server1").expect("was registered");
519 assert_eq!(executor.server_count(), 0);
520 let result = executor.execute("echo", serde_json::json!({})).await;
521 assert!(result.is_err(), "the removed server's tools route nowhere");
522 taken.shutdown().await.expect("shutdown is best-effort Ok");
524 }
525
526 #[tokio::test]
527 async fn execute_finds_owning_server_and_calls_tool() {
528 let _guard = always_on_tracing_guard();
529 let mut executor = ToolExecutor::new();
530 let client = spawn_ready_client().await;
531 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
532
533 let result = executor
534 .execute("echo", serde_json::json!({"text": "hi"}))
535 .await
536 .expect("execute should succeed");
537 assert!(result.success);
538 assert_eq!(result.text, "hello from tool");
539 }
540
541 #[tokio::test]
542 async fn execute_on_specific_server_calls_tool() {
543 let _guard = always_on_tracing_guard();
544 let mut executor = ToolExecutor::new();
545 let client = spawn_ready_client().await;
546 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
547
548 let result = executor
549 .execute_on("server1", "echo", serde_json::json!({}))
550 .await
551 .expect("execute_on should succeed");
552 assert!(result.success);
553 assert_eq!(result.text, "hello from tool");
554 }
555
556 #[tokio::test]
561 async fn execute_routes_to_a_live_server_and_succeeds() {
562 let _guard = always_on_tracing_guard();
563 let mut executor = ToolExecutor::new();
564 let client = spawn_ready_client().await;
565 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
566
567 let result = executor
568 .execute("echo", serde_json::json!({}))
569 .await
570 .expect("execute should succeed");
571 assert!(result.success);
572 }
573
574 #[tokio::test]
575 async fn shutdown_all_with_live_client_succeeds_and_clears() {
576 let _guard = always_on_tracing_guard();
577 let mut executor = ToolExecutor::new();
578 let client = spawn_ready_client().await;
579 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
580
581 let result = executor.shutdown_all().await;
582 assert!(result.is_ok());
583 assert_eq!(executor.server_count(), 0);
584 }
585
586 #[test]
589 fn test_execution_result_clone() {
590 let result = ExecutionResult {
591 success: true,
592 data: serde_json::json!("test"),
593 text: "hello".to_string(),
594 };
595 let cloned = result.clone();
596 assert!(cloned.success);
597 assert_eq!(cloned.text, "hello");
598 }
599
600 #[test]
601 fn test_execution_result_debug() {
602 let result = ExecutionResult {
603 success: false,
604 data: Value::Null,
605 text: "error".to_string(),
606 };
607 let debug = format!("{:?}", result);
608 assert!(debug.contains("success"));
609 assert!(debug.contains("false"));
610 }
611
612 const STUB_CALL_ERROR: &str = r#"
618import sys, json
619
620def respond(id, result):
621 msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
622 sys.stdout.write(msg + "\n")
623 sys.stdout.flush()
624
625def error(id, message):
626 msg = json.dumps({"jsonrpc": "2.0", "id": id, "error": {"code": -32603, "message": message}})
627 sys.stdout.write(msg + "\n")
628 sys.stdout.flush()
629
630for line in sys.stdin:
631 line = line.strip()
632 if not line:
633 continue
634 req = json.loads(line)
635 method = req.get("method", "")
636 id_ = req.get("id")
637 if method == "initialize":
638 respond(id_, {"capabilities": {"tools": {}}, "protocolVersion": "2024-11-05"})
639 elif method == "notifications/initialized":
640 pass
641 elif method == "tools/list":
642 respond(id_, {"tools": [{"name": "echo", "description": "echo", "inputSchema": {}}]})
643 elif method == "tools/call":
644 error(id_, "tool execution failed")
645 elif method == "notifications/cancelled":
646 pass
647"#;
648
649 #[tokio::test]
650 async fn execute_on_propagates_call_tool_error() {
651 let _guard = always_on_tracing_guard();
652 let mut client = MCPClient::spawn("python3", &["-c", STUB_CALL_ERROR], &HashMap::new())
653 .await
654 .expect("spawn");
655 client.connect().await.expect("connect");
656 client.list_tools().await.expect("list_tools");
657
658 let mut executor = ToolExecutor::new();
659 let _ = executor.add_client_advertised("server1".to_string(), client, &HashSet::new());
660
661 let result = executor
662 .execute_on("server1", "echo", serde_json::json!({}))
663 .await;
664 assert!(result.is_err());
665 assert!(
666 result
667 .unwrap_err()
668 .to_string()
669 .contains("tool execution failed")
670 );
671 }
672
673 fn text_of(content: Vec<ToolResultContent>) -> String {
676 ToolExecutor::map_result(ToolResult {
677 content,
678 structured_content: None,
679 is_error: false,
680 })
681 .text
682 }
683
684 #[test]
685 fn map_result_reports_tool_execution_error_as_failure() {
686 let result = ToolExecutor::map_result(ToolResult {
689 content: vec![ToolResultContent::Text {
690 text: "Invalid departure date".to_string(),
691 }],
692 structured_content: None,
693 is_error: true,
694 });
695 assert!(!result.success);
696 assert_eq!(result.text, "Invalid departure date");
697 }
698
699 #[test]
700 fn map_result_skips_binary_blocks() {
701 let text = text_of(vec![
702 ToolResultContent::Text {
703 text: "before".to_string(),
704 },
705 ToolResultContent::Image {
706 data: "YWJj".to_string(),
707 mime_type: "image/png".to_string(),
708 },
709 ToolResultContent::Audio {
710 data: "YWJj".to_string(),
711 mime_type: "audio/wav".to_string(),
712 },
713 ToolResultContent::Text {
714 text: "after".to_string(),
715 },
716 ]);
717 assert_eq!(text, "before\nafter");
718 }
719
720 #[test]
721 fn map_result_skips_resource_links() {
722 let text = text_of(vec![ToolResultContent::ResourceLink {
723 uri: "file:///x".to_string(),
724 name: "x".to_string(),
725 description: None,
726 mime_type: None,
727 }]);
728 assert_eq!(text, "");
729 }
730
731 #[test]
732 fn map_result_skips_unknown_blocks_without_losing_the_rest() {
733 let _guard = always_on_tracing_guard();
734 let text = text_of(vec![
735 ToolResultContent::Unknown,
736 ToolResultContent::Text {
737 text: "still here".to_string(),
738 },
739 ]);
740 assert_eq!(text, "still here");
741 }
742
743 #[test]
744 fn map_result_falls_back_to_structured_content_when_no_text() {
745 let result = ToolExecutor::map_result(ToolResult {
748 content: vec![],
749 structured_content: Some(serde_json::json!({"temperature": 22.5})),
750 is_error: false,
751 });
752 assert_eq!(result.text, r#"{"temperature":22.5}"#);
753 }
754
755 #[test]
756 fn map_result_prefers_text_blocks_over_structured_content() {
757 let result = ToolExecutor::map_result(ToolResult {
758 content: vec![ToolResultContent::Text {
759 text: "human readable".to_string(),
760 }],
761 structured_content: Some(serde_json::json!({"a": 1})),
762 is_error: false,
763 });
764 assert_eq!(result.text, "human readable");
765 }
766
767 #[test]
768 fn map_result_embedded_resource_blob_contributes_no_text() {
769 let text = text_of(vec![ToolResultContent::Resource {
770 resource: EmbeddedResource {
771 uri: "file:///a.png".to_string(),
772 text: None,
773 blob: Some("YWJj".to_string()),
774 mime_type: Some("image/png".to_string()),
775 },
776 }]);
777 assert_eq!(text, "");
778 }
779
780 #[test]
783 fn sanitize_passes_a_clean_name_through() {
784 assert_eq!(sanitize_tool_name("get_weather-2"), "get_weather-2");
785 }
786
787 #[test]
788 fn sanitize_replaces_dots_and_other_illegal_chars() {
789 assert_eq!(sanitize_tool_name("admin.tools.list"), "admin_tools_list");
791 assert_eq!(sanitize_tool_name("weird name!/#"), "weird_name___");
792 }
793
794 #[test]
795 fn sanitize_truncates_to_the_limit() {
796 let long = "a".repeat(200);
797 assert_eq!(sanitize_tool_name(&long).len(), MAX_TOOL_NAME_LEN);
798 }
799
800 #[test]
801 fn sanitize_of_illegal_chars_becomes_underscores_and_empty_falls_back() {
802 assert_eq!(sanitize_tool_name("...."), "____");
805 assert_eq!(sanitize_tool_name(""), "tool");
806 }
807
808 #[test]
811 fn unique_name_prefers_the_sanitized_base() {
812 let exec = ToolExecutor::new();
813 let reserved = HashSet::new();
814 assert_eq!(
815 exec.unique_advertised_name("github.search", "gh", &reserved),
816 "github_search"
817 );
818 }
819
820 #[test]
821 fn unique_name_prefixes_on_a_reserved_collision() {
822 let exec = ToolExecutor::new();
824 let reserved: HashSet<String> = ["bash".to_string()].into_iter().collect();
825 assert_eq!(
826 exec.unique_advertised_name("bash", "srv", &reserved),
827 "srv__bash"
828 );
829 }
830
831 #[test]
832 fn unique_name_prefixes_on_an_existing_alias_collision() {
833 let mut exec = ToolExecutor::new();
834 exec.aliases.insert(
835 "search".to_string(),
836 ("a".to_string(), "search".to_string()),
837 );
838 assert_eq!(
839 exec.unique_advertised_name("search", "b", &HashSet::new()),
840 "b__search"
841 );
842 }
843
844 #[test]
845 fn unique_name_appends_a_number_when_the_prefix_also_collides() {
846 let mut exec = ToolExecutor::new();
847 exec.aliases.insert(
849 "search".to_string(),
850 ("a".to_string(), "search".to_string()),
851 );
852 exec.aliases
853 .insert("b__search".to_string(), ("x".to_string(), "y".to_string()));
854 assert_eq!(
855 exec.unique_advertised_name("search", "b", &HashSet::new()),
856 "b__search_2"
857 );
858
859 exec.aliases.insert(
861 "b__search_2".to_string(),
862 ("x".to_string(), "y".to_string()),
863 );
864 assert_eq!(
865 exec.unique_advertised_name("search", "b", &HashSet::new()),
866 "b__search_3"
867 );
868 }
869
870 fn stub_named(tool_name: &str) -> String {
874 format!(
875 r#"
876import sys, json
877def respond(id, result):
878 sys.stdout.write(json.dumps({{"jsonrpc": "2.0", "id": id, "result": result}}) + "\n")
879 sys.stdout.flush()
880for line in sys.stdin:
881 line = line.strip()
882 if not line:
883 continue
884 req = json.loads(line)
885 method, id_ = req.get("method", ""), req.get("id")
886 if method == "initialize":
887 respond(id_, {{"capabilities": {{}}, "protocolVersion": "2024-11-05"}})
888 elif method == "tools/list":
889 respond(id_, {{"tools": [{{"name": "{tool_name}", "inputSchema": {{}}}}]}})
890 elif method == "tools/call":
891 respond(id_, {{"content": [{{"type": "text", "text": "called " + req["params"]["name"]}}], "isError": False}})
892"#
893 )
894 }
895
896 async fn spawn_named(tool_name: &str) -> MCPClient {
897 let mut client =
898 MCPClient::spawn("python3", &["-c", &stub_named(tool_name)], &HashMap::new())
899 .await
900 .expect("spawn");
901 client.connect().await.expect("connect");
902 client.list_tools().await.expect("list");
903 client
904 }
905
906 #[tokio::test]
907 async fn a_dotted_tool_is_advertised_sanitized_and_still_routes() {
908 let _guard = always_on_tracing_guard();
909 let mut executor = ToolExecutor::new();
910 let client = spawn_named("github.search").await;
911 let advertised = executor.add_client_advertised("gh".to_string(), client, &HashSet::new());
912 assert_eq!(advertised[0].name, "github_search");
913
914 let result = executor
917 .execute("github_search", serde_json::json!({}))
918 .await
919 .expect("advertised name routes");
920 assert!(result.success);
921 assert_eq!(result.text, "called github.search");
922 }
923
924 #[tokio::test]
925 async fn two_servers_sharing_a_tool_name_are_disambiguated() {
926 let _guard = always_on_tracing_guard();
927 let mut executor = ToolExecutor::new();
928
929 let a = spawn_named("search").await;
930 let a_names = executor.add_client_advertised("alpha".to_string(), a, &HashSet::new());
931 assert_eq!(a_names[0].name, "search");
932
933 let b = spawn_named("search").await;
934 let reserved: HashSet<String> = a_names.iter().map(|t| t.name.clone()).collect();
936 let b_names = executor.add_client_advertised("beta".to_string(), b, &reserved);
937 assert_eq!(b_names[0].name, "beta__search");
938
939 assert!(
941 executor
942 .execute("search", serde_json::json!({}))
943 .await
944 .unwrap()
945 .success
946 );
947 assert!(
948 executor
949 .execute("beta__search", serde_json::json!({}))
950 .await
951 .unwrap()
952 .success
953 );
954 }
955
956 #[tokio::test]
957 async fn add_client_reserving_nothing_registers_identity_aliases() {
958 let _guard = always_on_tracing_guard();
959 let mut executor = ToolExecutor::new();
960 let client = spawn_named("plain").await;
961 let _ = executor.add_client_advertised("s".to_string(), client, &HashSet::new());
962 assert!(
963 executor
964 .execute("plain", serde_json::json!({}))
965 .await
966 .unwrap()
967 .success
968 );
969 }
970}