Skip to main content

leviath_mcp/
execution.rs

1//! Tool execution via MCP.
2
3use serde_json::Value;
4use std::collections::{HashMap, HashSet};
5
6use crate::client::{MCPClient, ToolResult, ToolResultContent};
7use crate::discovery::ToolMetadata;
8
9/// Provider tool-name limit: the name advertised to the LLM must match
10/// `^[A-Za-z0-9_-]{1,64}$` (the Anthropic/OpenAI rule). MCP names are laxer
11/// (they allow dots), so any MCP name that violates this would make the
12/// provider reject the *entire* request.
13const MAX_TOOL_NAME_LEN: usize = 64;
14
15/// Sanitize an MCP tool name into the provider-accepted character set.
16///
17/// Every character outside `[A-Za-z0-9_-]` (notably `.`, which MCP allows and
18/// real servers use) becomes `_`, and the result is truncated to 64 bytes. An
19/// empty result (a name of only illegal characters) falls back to `tool`.
20pub 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/// Result of a tool execution, with convenience fields.
40#[derive(Debug, Clone)]
41pub struct ExecutionResult {
42    /// Whether execution succeeded
43    pub success: bool,
44    /// Result data as JSON (contains the content array)
45    pub data: Value,
46    /// Concatenated text content for convenience
47    pub text: String,
48}
49
50/// Tool execution service that routes tool calls to the correct MCP server.
51pub struct ToolExecutor {
52    /// Active MCP clients, keyed by server name
53    clients: HashMap<String, MCPClient>,
54    /// Advertised tool name → (server name, original tool name).
55    ///
56    /// The name advertised to the LLM is sanitized to the provider's character
57    /// rule and made unique across servers; this maps it back to the server and
58    /// the original name the server itself expects on a `tools/call`.
59    aliases: HashMap<String, (String, String)>,
60}
61
62impl ToolExecutor {
63    /// Create a new tool executor.
64    pub fn new() -> Self {
65        Self {
66            clients: HashMap::new(),
67            aliases: HashMap::new(),
68        }
69    }
70
71    /// Register an MCP client for a server, advertising its tools under names
72    /// safe for the LLM/provider.
73    ///
74    /// Equivalent to [`Self::add_client_advertised`] reserving nothing; kept for
75    /// callers that don't need the advertised metadata back.
76    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    /// Register a client and return its tools under *advertised* names.
81    ///
82    /// Each advertised name is [`sanitize_tool_name`]d and made unique against
83    /// `reserved` (e.g. the built-in tool names) and every previously-registered
84    /// tool, so the set of names handed to the provider is always valid and
85    /// collision-free. The returned metadata carries the advertised names; the
86    /// alias back to `(server, original)` is recorded for routing.
87    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    /// Compute a unique, provider-safe advertised name for `original`.
117    ///
118    /// Prefers the sanitized name; on a clash with `reserved` or an existing
119    /// alias, prefixes with the server name; if that still clashes, appends a
120    /// numeric suffix.
121    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    /// Execute a tool by its advertised name, routing to the owning server.
148    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        // The advertised → (server, original) alias is the authoritative route.
156        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    /// Execute a tool on a specific server.
168    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    /// Execute a tool only if it is in the allowed list.
186    ///
187    /// Returns an error if the tool is not in the allowed_tools list.
188    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    /// Shutdown all connected MCP clients.
208    ///
209    /// `MCPClient::shutdown` always returns `Ok` by design (it swallows
210    /// subprocess errors so a dead server cannot block cleanup), so errors
211    /// are discarded here too.
212    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    /// Get the number of connected servers.
222    pub fn server_count(&self) -> usize {
223        self.clients.len()
224    }
225
226    /// Map a ToolResult into an ExecutionResult.
227    ///
228    /// Only the model-readable blocks contribute to `text`; binary payloads
229    /// (image/audio) and bare resource links do not, and an unmodelled block is
230    /// skipped with a warning rather than failing the call.
231    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        // A structured-only result would otherwise reach the model as an empty
252        // string. Servers *should* also mirror it into a text block, but not
253        // all do.
254        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    // ─── ToolExecutor::default ──────────────────────────────────────────
303
304    #[test]
305    fn test_tool_executor_default() {
306        let executor = ToolExecutor::default();
307        assert_eq!(executor.server_count(), 0);
308    }
309
310    // ─── map_result: text content ───────────────────────────────────────
311
312    #[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    // ─── execute_filtered: allowed tool ────────────────────────────────
442
443    #[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        // Tool is allowed but no server has it
450        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    // ─── execute: no server ─────────────────────────────────────────────
458
459    #[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    // ─── execute_on: unknown server ─────────────────────────────────────
470
471    #[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    // ─── shutdown_all: empty executor ───────────────────────────────────
483
484    #[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    // ─── add_client / execute / execute_on with a live client ───────────
494    //
495    // Same Python-backed JSON-RPC stub approach used in client.rs/discovery.rs
496    // tests. Note: MCPClient::shutdown() always returns Ok(()) by design (it
497    // swallows failures so a dead server can't block cleanup) - so
498    // shutdown_all()'s error-collection branch is intentionally left
499    // uncovered here; there's no way to make client.shutdown() fail without
500    // changing that documented "always succeeds" behavior.
501
502    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    // ─── ExecutionResult ────────────────────────────────────────────────
610
611    #[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    // ─── execute_on: call_tool error propagation ────────────────────────
636    //
637    // Server returns a JSON-RPC error for tools/call, which causes
638    // execute_on's `client.call_tool(...).await?` to propagate the error.
639
640    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    // ─── map_result over the full content-block set ───────────────────────
697
698    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        // The end-to-end consequence of the `isError` rename: a failing tool
710        // must reach the model as a failure, not a success.
711        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        // Servers *should* mirror structured output into a text block; not all
769        // do, and without this the model would receive an empty string.
770        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    // ─── tool-name sanitization ───────────────────────────────────────────
804
805    #[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        // Dots are legal in MCP but rejected by the provider name rule.
813        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        // Illegal chars each become `_` (still a valid name); only a fully
826        // empty result falls back to a placeholder.
827        assert_eq!(sanitize_tool_name("...."), "____");
828        assert_eq!(sanitize_tool_name(""), "tool");
829    }
830
831    // ─── unique_advertised_name ───────────────────────────────────────────
832
833    #[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        // The base clashes with a built-in tool name → prefix with the server.
846        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        // Both the base and the server-prefixed form are already taken.
871        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        // And when _2 is taken too, it moves on to _3 (covers the loop step).
883        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    // ─── advertised routing with live clients ─────────────────────────────
894
895    /// A stub whose single tool is named `tool_name`, echoing a fixed reply.
896    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        // The LLM calls the advertised name; the server is called with its
938        // original name ("github.search").
939        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        // Reserve what alpha already advertised.
958        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        // Both route to their own server with the original name "search".
963        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}