Skip to main content

mermaid_cli/providers/tool/
mcp.rs

1//! MCP tool dispatch.
2//!
3//! Every MCP server advertises tools with names like `mcp__slack__send_message`.
4//! The reducer doesn't care about MCP mechanics — it just sees a tool
5//! call with that prefix and dispatches here. `McpToolProxy` parses
6//! the `server_name / tool_name` split and delegates to the process-
7//! global `McpServerManager`.
8//!
9//! The manager is a `OnceLock` initialized during startup (see
10//! `crate::mcp::server_manager`). The proxy doesn't own any state —
11//! it's a thin function packaged as a `ToolExecutor` so the registry
12//! can dispatch MCP calls uniformly with built-in tools.
13
14use async_trait::async_trait;
15
16use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
17use crate::mcp::{McpServerManager, manager_ref};
18
19use super::super::ctx::ExecContext;
20use super::ToolExecutor;
21
22/// `mcp_proxy` isn't an actual tool name the model sees — it's the
23/// dispatch target for every `mcp__*` tool call. The effect runner
24/// routes MCP-prefixed calls to this impl.
25pub struct McpToolProxy;
26
27#[async_trait]
28impl ToolExecutor for McpToolProxy {
29    fn name(&self) -> &'static str {
30        "mcp_proxy"
31    }
32
33    fn is_internal(&self) -> bool {
34        true
35    }
36
37    fn schema(&self) -> ToolDefinition {
38        // The proxy isn't itself advertised to the model; per-MCP
39        // tool schemas are sourced from `State.mcp.servers.*.tools`
40        // in the reducer. This schema is kept for registry-cohesion
41        // but never lands in `request.tools`.
42        ToolDefinition {
43            name: "mcp_proxy".to_string(),
44            description: "Internal dispatch target for mcp__* tool calls.".to_string(),
45            input_schema: serde_json::json!({
46                "type": "object",
47                "properties": {
48                    "server_name": { "type": "string" },
49                    "tool_name": { "type": "string" },
50                    "arguments": { "type": "object" }
51                },
52                "required": ["server_name", "tool_name"]
53            }),
54        }
55    }
56
57    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
58        // Args shape: { server_name, tool_name, arguments }. The effect
59        // runner constructs this from the model-emitted tool call.
60        let Some(server_name) = args.get("server_name").and_then(|v| v.as_str()) else {
61            return ToolOutcome::error("mcp_proxy requires 'server_name'", 0.0);
62        };
63        let Some(tool_name) = args.get("tool_name").and_then(|v| v.as_str()) else {
64            return ToolOutcome::error("mcp_proxy requires 'tool_name'", 0.0);
65        };
66        let tool_args = args
67            .get("arguments")
68            .cloned()
69            .unwrap_or(serde_json::json!({}));
70
71        // If init is still racing, park briefly — the model might
72        // have sprinted ahead on its very first message. If it never
73        // finishes within a reasonable bound we still fall through
74        // to a clean error rather than hanging forever.
75        if !manager_ref::is_ready() {
76            let _ = tokio::time::timeout(
77                std::time::Duration::from_secs(10),
78                manager_ref::wait_ready(),
79            )
80            .await;
81        }
82        let Some(manager) = manager_ref::get() else {
83            return ToolOutcome::error("MCP servers not initialized", 0.0);
84        };
85
86        let start = std::time::Instant::now();
87        let call = manager.call_tool(server_name, tool_name, &tool_args);
88
89        tokio::select! {
90            biased;
91            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
92            result = call => match result {
93                Ok(tool_result) => {
94                    let (text, images) = McpServerManager::format_tool_result(&tool_result);
95                    let mut outcome = ToolOutcome::success(
96                        text,
97                        format!("{}:{} completed", server_name, tool_name),
98                        start.elapsed().as_secs_f64(),
99                    )
100                    .with_metadata(mcp_metadata(server_name, tool_name));
101                    if let Some(images) = images {
102                        outcome = outcome.with_images(images);
103                    }
104                    outcome
105                },
106                Err(e) => ToolOutcome::error(
107                    format!("mcp_proxy({}:{}): {}", server_name, tool_name, e),
108                    start.elapsed().as_secs_f64(),
109                )
110                .with_metadata(mcp_metadata(server_name, tool_name)),
111            },
112        }
113    }
114}
115
116fn mcp_metadata(server_name: &str, tool_name: &str) -> ToolRunMetadata {
117    ToolRunMetadata {
118        detail: ToolMetadata::Mcp {
119            server: server_name.to_string(),
120            tool: tool_name.to_string(),
121        },
122        ..ToolRunMetadata::default()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::domain::{ToolCallId, TurnId};
130    use crate::providers::ctx::test_exec_context;
131    use std::path::PathBuf;
132
133    #[tokio::test]
134    async fn missing_server_name_errors() {
135        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
136        let outcome = McpToolProxy
137            .execute(serde_json::json!({"tool_name": "x"}), ctx)
138            .await;
139        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
140    }
141
142    #[tokio::test]
143    async fn missing_tool_name_errors() {
144        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
145        let outcome = McpToolProxy
146            .execute(serde_json::json!({"server_name": "x"}), ctx)
147            .await;
148        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
149    }
150
151    #[tokio::test]
152    async fn uninitialized_manager_errors_cleanly() {
153        // If the global OnceLock hasn't been initialized in this test
154        // context, calling through should error rather than panic.
155        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
156        let outcome = McpToolProxy
157            .execute(
158                serde_json::json!({"server_name": "s", "tool_name": "t"}),
159                ctx,
160            )
161            .await;
162        // Either Error (uninitialized) or Error (server not found) —
163        // both acceptable; the test asserts *not Finished*.
164        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
165    }
166}