Skip to main content

mermaid_cli/mcp/
server_manager.rs

1//! MCP server lifecycle management.
2//!
3//! Manages multiple MCP server processes, handles tool discovery,
4//! and routes tool calls to the correct server.
5
6use anyhow::{Result, anyhow};
7use std::collections::HashMap;
8use tracing::{info, warn};
9
10use super::client::{ContentBlock, McpClient, McpToolDef, McpToolResult};
11use super::transport::StdioTransport;
12use crate::app::McpServerConfig;
13
14/// Manages multiple MCP server connections.
15pub struct McpServerManager {
16    /// Active server connections: server_name → client
17    servers: HashMap<String, McpClient>,
18    /// Cached tool definitions: (server_name, tool_def)
19    tools: Vec<(String, McpToolDef)>,
20}
21
22impl McpServerManager {
23    /// Start all configured MCP servers, initialize them, and discover tools.
24    ///
25    /// Servers that fail to start are logged and skipped (non-fatal).
26    pub async fn start(configs: &HashMap<String, McpServerConfig>) -> Self {
27        let mut servers = HashMap::new();
28        let mut all_tools = Vec::new();
29
30        for (name, config) in configs {
31            info!(
32                "Starting MCP server: {} ({} {})",
33                name,
34                config.command,
35                // Redact args — they can carry secrets (e.g. `--api-key=…`) (#93).
36                crate::utils::redact_secrets(&config.args.join(" "))
37            );
38
39            match Self::start_one(name, config).await {
40                Ok((client, tools)) => {
41                    let tool_count = tools.len();
42                    for tool in &tools {
43                        all_tools.push((name.clone(), tool.clone()));
44                    }
45                    info!(
46                        "MCP server '{}' ready: {} tools ({})",
47                        name,
48                        tool_count,
49                        client
50                            .server_info
51                            .as_ref()
52                            .map(|s| s.name.as_str())
53                            .unwrap_or("?")
54                    );
55                    servers.insert(name.clone(), client);
56                },
57                Err(e) => {
58                    warn!("Failed to start MCP server '{}': {}", name, e);
59                },
60            }
61        }
62
63        Self {
64            servers,
65            tools: all_tools,
66        }
67    }
68
69    /// Start a single MCP server, initialize, and list tools.
70    async fn start_one(
71        name: &str,
72        config: &McpServerConfig,
73    ) -> Result<(McpClient, Vec<McpToolDef>)> {
74        let transport = StdioTransport::spawn(&config.command, &config.args, &config.env).await?;
75        let mut client = McpClient::new(transport);
76
77        client
78            .initialize()
79            .await
80            .map_err(|e| anyhow!("MCP server '{}' initialization failed: {}", name, e))?;
81
82        let tools = client
83            .list_tools()
84            .await
85            .map_err(|e| anyhow!("MCP server '{}' tool discovery failed: {}", name, e))?;
86
87        Ok((client, tools))
88    }
89
90    /// Get all discovered tools with their server names.
91    pub fn get_all_tools(&self) -> &[(String, McpToolDef)] {
92        &self.tools
93    }
94
95    /// True iff the named server started and has an active client,
96    /// even if it advertised zero tools.
97    pub fn has_server(&self, name: &str) -> bool {
98        self.servers.contains_key(name)
99    }
100
101    /// Check if any MCP servers are active.
102    pub fn has_servers(&self) -> bool {
103        !self.servers.is_empty()
104    }
105
106    /// Call a tool on a specific server.
107    ///
108    /// # Concurrency
109    ///
110    /// Multiple concurrent calls to the same server will serialize at the
111    /// transport layer (`StdioTransport` holds a mutex over stdin writes and
112    /// uses a shared pending-response map for JSON-RPC correlation). This is
113    /// intentional: JSON-RPC over stdio is a byte stream, and interleaved
114    /// writes would corrupt messages. Calls to *different* servers run fully
115    /// in parallel since each has its own transport.
116    pub async fn call_tool(
117        &self,
118        server_name: &str,
119        tool_name: &str,
120        arguments: &serde_json::Value,
121    ) -> Result<McpToolResult> {
122        let client = self
123            .servers
124            .get(server_name)
125            .ok_or_else(|| anyhow!("MCP server '{}' not found or not running", server_name))?;
126
127        client.call_tool(tool_name, arguments).await
128    }
129
130    /// Convert an MCP tool result into text suitable for a tool result message.
131    /// Images are returned separately for multimodal attachment. Audio is
132    /// attached through the same channel — adapters that don't support audio
133    /// will silently drop it. Resource links + embedded resources render as
134    /// text so the model can follow up with another tool call.
135    pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
136        let mut text_parts = Vec::new();
137        let mut images = Vec::new();
138
139        for block in &result.content {
140            match block {
141                ContentBlock::Text(text) => text_parts.push(text.clone()),
142                ContentBlock::Image { data, .. } => images.push(data.clone()),
143                ContentBlock::Audio { data, mime_type } => {
144                    images.push(data.clone());
145                    text_parts.push(format!("[audio attachment: {}]", mime_type));
146                },
147                ContentBlock::ResourceLink {
148                    uri,
149                    name,
150                    description,
151                    mime_type,
152                } => {
153                    let label = name.as_deref().unwrap_or(uri.as_str());
154                    let desc = description.as_deref().unwrap_or("");
155                    let mime = mime_type.as_deref().unwrap_or("");
156                    text_parts.push(format!(
157                        "[resource link: {} ({}) — {} → {}]",
158                        label, mime, desc, uri
159                    ));
160                },
161                ContentBlock::Resource {
162                    uri,
163                    mime_type,
164                    text,
165                    blob,
166                } => {
167                    let mime = mime_type.as_deref().unwrap_or("");
168                    if let Some(t) = text {
169                        text_parts.push(format!("[resource {}]:\n{}", uri, t));
170                    } else if let Some(b) = blob {
171                        text_parts.push(format!(
172                            "[resource {} ({}): {} bytes of base64]",
173                            uri,
174                            mime,
175                            b.len()
176                        ));
177                    } else {
178                        text_parts.push(format!("[resource {} ({})]", uri, mime));
179                    }
180                },
181            }
182        }
183
184        let text = if text_parts.is_empty() {
185            if result.is_error {
186                "MCP tool returned an error with no message".to_string()
187            } else {
188                "MCP tool returned no text content".to_string()
189            }
190        } else {
191            text_parts.join("\n")
192        };
193
194        let images = if images.is_empty() {
195            None
196        } else {
197            Some(images)
198        };
199
200        (text, images)
201    }
202
203    /// Gracefully shut down all MCP servers.
204    pub async fn shutdown(&self) {
205        for (name, client) in &self.servers {
206            info!("Shutting down MCP server: {}", name);
207            client.shutdown().await;
208        }
209    }
210
211    /// Stop a single named server: kill its child via the transport. The
212    /// stdout-reader task then exits on EOF — no explicit abort needed. Returns
213    /// `true` if a server matched.
214    ///
215    /// The registry entry lingers (the manager is shared immutably behind an
216    /// `Arc`, so the `HashMap` can't be mutated here), but the child's stdin
217    /// pipe is now closed: a later `call_tool` to a stopped server fails fast
218    /// (broken pipe), not a hang. Fully removing the entry would need
219    /// interior-mutability on the manager.
220    pub async fn stop_server(&self, name: &str) -> bool {
221        match self.servers.get(name) {
222            Some(client) => {
223                info!("Stopping MCP server: {}", name);
224                client.shutdown().await;
225                true
226            },
227            None => false,
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[tokio::test]
237    async fn stop_unknown_server_returns_false() {
238        // No servers configured ⇒ empty manager; stopping an unknown name is a
239        // no-op that reports `false` rather than panicking.
240        let mgr = McpServerManager::start(&HashMap::new()).await;
241        assert!(!mgr.has_servers());
242        assert!(!mgr.stop_server("does-not-exist").await);
243    }
244}