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 a client and return its tools under *advertised* names.
72    ///
73    /// Each advertised name is [`sanitize_tool_name`]d and made unique against
74    /// `reserved` (e.g. the built-in tool names) and every previously-registered
75    /// tool, so the set of names handed to the provider is always valid and
76    /// collision-free. The returned metadata carries the advertised names; the
77    /// alias back to `(server, original)` is recorded for routing.
78    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    /// Take a server's client back out of the executor, dropping the aliases
108    /// that routed to it. The caller owns the returned client and is expected
109    /// to shut it down - removal alone does not kill a stdio server's child
110    /// process. `None` if no such server is registered.
111    ///
112    /// The pool's idle-disconnect uses this: a per-agent server whose last
113    /// leasing run ended has no caller left, and before this the connection
114    /// (and its child process) lived until the daemon exited.
115    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    /// Compute a unique, provider-safe advertised name for `original`.
122    ///
123    /// Prefers the sanitized name; on a clash with `reserved` or an existing
124    /// alias, prefixes with the server name; if that still clashes, appends a
125    /// numeric suffix.
126    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    /// Execute a tool by its advertised name, routing to the owning server.
153    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        // The advertised → (server, original) alias is the authoritative route.
161        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    /// Execute a tool on a specific server.
173    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    /// Shutdown all connected MCP clients.
191    ///
192    /// `MCPClient::shutdown` always returns `Ok` by design (it swallows
193    /// subprocess errors so a dead server cannot block cleanup), so errors
194    /// are discarded here too.
195    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    /// Get the number of connected servers.
205    pub fn server_count(&self) -> usize {
206        self.clients.len()
207    }
208
209    /// Map a ToolResult into an ExecutionResult.
210    ///
211    /// Only the model-readable blocks contribute to `text`; binary payloads
212    /// (image/audio) and bare resource links do not, and an unmodelled block is
213    /// skipped with a warning rather than failing the call.
214    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        // A structured-only result would otherwise reach the model as an empty
235        // string. Servers *should* also mirror it into a text block, but not
236        // all do.
237        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    // ─── ToolExecutor::default ──────────────────────────────────────────
272
273    #[test]
274    fn test_tool_executor_default() {
275        let executor = ToolExecutor::default();
276        assert_eq!(executor.server_count(), 0);
277    }
278
279    // ─── map_result: text content ───────────────────────────────────────
280
281    #[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    // ─── execute_filtered: allowed tool ────────────────────────────────
411
412    // ─── execute: no server ─────────────────────────────────────────────
413
414    #[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    // ─── execute_on: unknown server ─────────────────────────────────────
425
426    #[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    // ─── shutdown_all: empty executor ───────────────────────────────────
438
439    #[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    // ─── add_client / execute / execute_on with a live client ───────────
449    //
450    // Same Python-backed JSON-RPC stub approach used in client.rs/discovery.rs
451    // tests. Note: MCPClient::shutdown() always returns Ok(()) by design (it
452    // swallows failures so a dead server can't block cleanup) - so
453    // shutdown_all()'s error-collection branch is intentionally left
454    // uncovered here; there's no way to make client.shutdown() fail without
455    // changing that documented "always succeeds" behavior.
456
457    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    /// Removing a server takes back its client and drops the aliases that
508    /// routed to it, so a later `execute` of its tools misses cleanly - and an
509    /// unknown name removes nothing.
510    #[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        // The caller owns the shutdown; this is what ends the child process.
523        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    /// The end-to-end path against a live stub server. This used to go through
557    /// `execute_filtered`, which is gone; the call it actually exercised -
558    /// route by advertised name, dispatch, map the result - is `execute`, so
559    /// the coverage moves rather than disappearing with its wrapper.
560    #[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    // ─── ExecutionResult ────────────────────────────────────────────────
587
588    #[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    // ─── execute_on: call_tool error propagation ────────────────────────
613    //
614    // Server returns a JSON-RPC error for tools/call, which causes
615    // execute_on's `client.call_tool(...).await?` to propagate the error.
616
617    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    // ─── map_result over the full content-block set ───────────────────────
674
675    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        // The end-to-end consequence of the `isError` rename: a failing tool
687        // must reach the model as a failure, not a success.
688        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        // Servers *should* mirror structured output into a text block; not all
746        // do, and without this the model would receive an empty string.
747        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    // ─── tool-name sanitization ───────────────────────────────────────────
781
782    #[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        // Dots are legal in MCP but rejected by the provider name rule.
790        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        // Illegal chars each become `_` (still a valid name); only a fully
803        // empty result falls back to a placeholder.
804        assert_eq!(sanitize_tool_name("...."), "____");
805        assert_eq!(sanitize_tool_name(""), "tool");
806    }
807
808    // ─── unique_advertised_name ───────────────────────────────────────────
809
810    #[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        // The base clashes with a built-in tool name → prefix with the server.
823        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        // Both the base and the server-prefixed form are already taken.
848        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        // And when _2 is taken too, it moves on to _3 (covers the loop step).
860        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    // ─── advertised routing with live clients ─────────────────────────────
871
872    /// A stub whose single tool is named `tool_name`, echoing a fixed reply.
873    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        // The LLM calls the advertised name; the server is called with its
915        // original name ("github.search").
916        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        // Reserve what alpha already advertised.
935        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        // Both route to their own server with the original name "search".
940        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}