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                config.args.join(" ")
36            );
37
38            match Self::start_one(name, config).await {
39                Ok((client, tools)) => {
40                    let tool_count = tools.len();
41                    for tool in &tools {
42                        all_tools.push((name.clone(), tool.clone()));
43                    }
44                    info!(
45                        "MCP server '{}' ready: {} tools ({})",
46                        name,
47                        tool_count,
48                        client
49                            .server_info
50                            .as_ref()
51                            .map(|s| s.name.as_str())
52                            .unwrap_or("?")
53                    );
54                    servers.insert(name.clone(), client);
55                },
56                Err(e) => {
57                    warn!("Failed to start MCP server '{}': {}", name, e);
58                },
59            }
60        }
61
62        Self {
63            servers,
64            tools: all_tools,
65        }
66    }
67
68    /// Start a single MCP server, initialize, and list tools.
69    async fn start_one(
70        name: &str,
71        config: &McpServerConfig,
72    ) -> Result<(McpClient, Vec<McpToolDef>)> {
73        let transport = StdioTransport::spawn(&config.command, &config.args, &config.env).await?;
74        let mut client = McpClient::new(transport);
75
76        client
77            .initialize()
78            .await
79            .map_err(|e| anyhow!("MCP server '{}' initialization failed: {}", name, e))?;
80
81        let tools = client
82            .list_tools()
83            .await
84            .map_err(|e| anyhow!("MCP server '{}' tool discovery failed: {}", name, e))?;
85
86        Ok((client, tools))
87    }
88
89    /// Get all discovered tools with their server names.
90    pub fn get_all_tools(&self) -> &[(String, McpToolDef)] {
91        &self.tools
92    }
93
94    /// True iff the named server started and has an active client,
95    /// even if it advertised zero tools.
96    pub fn has_server(&self, name: &str) -> bool {
97        self.servers.contains_key(name)
98    }
99
100    /// Check if any MCP servers are active.
101    pub fn has_servers(&self) -> bool {
102        !self.servers.is_empty()
103    }
104
105    /// Call a tool on a specific server.
106    ///
107    /// # Concurrency
108    ///
109    /// Multiple concurrent calls to the same server will serialize at the
110    /// transport layer (`StdioTransport` holds a mutex over stdin writes and
111    /// uses a shared pending-response map for JSON-RPC correlation). This is
112    /// intentional: JSON-RPC over stdio is a byte stream, and interleaved
113    /// writes would corrupt messages. Calls to *different* servers run fully
114    /// in parallel since each has its own transport.
115    pub async fn call_tool(
116        &self,
117        server_name: &str,
118        tool_name: &str,
119        arguments: &serde_json::Value,
120    ) -> Result<McpToolResult> {
121        let client = self
122            .servers
123            .get(server_name)
124            .ok_or_else(|| anyhow!("MCP server '{}' not found or not running", server_name))?;
125
126        client.call_tool(tool_name, arguments).await
127    }
128
129    /// Convert an MCP tool result into text suitable for a tool result message.
130    /// Images are returned separately for multimodal attachment. Audio is
131    /// attached through the same channel — adapters that don't support audio
132    /// will silently drop it. Resource links + embedded resources render as
133    /// text so the model can follow up with another tool call.
134    pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
135        let mut text_parts = Vec::new();
136        let mut images = Vec::new();
137
138        for block in &result.content {
139            match block {
140                ContentBlock::Text(text) => text_parts.push(text.clone()),
141                ContentBlock::Image { data, .. } => images.push(data.clone()),
142                ContentBlock::Audio { data, mime_type } => {
143                    images.push(data.clone());
144                    text_parts.push(format!("[audio attachment: {}]", mime_type));
145                },
146                ContentBlock::ResourceLink {
147                    uri,
148                    name,
149                    description,
150                    mime_type,
151                } => {
152                    let label = name.as_deref().unwrap_or(uri.as_str());
153                    let desc = description.as_deref().unwrap_or("");
154                    let mime = mime_type.as_deref().unwrap_or("");
155                    text_parts.push(format!(
156                        "[resource link: {} ({}) — {} → {}]",
157                        label, mime, desc, uri
158                    ));
159                },
160                ContentBlock::Resource {
161                    uri,
162                    mime_type,
163                    text,
164                    blob,
165                } => {
166                    let mime = mime_type.as_deref().unwrap_or("");
167                    if let Some(t) = text {
168                        text_parts.push(format!("[resource {}]:\n{}", uri, t));
169                    } else if let Some(b) = blob {
170                        text_parts.push(format!(
171                            "[resource {} ({}): {} bytes of base64]",
172                            uri,
173                            mime,
174                            b.len()
175                        ));
176                    } else {
177                        text_parts.push(format!("[resource {} ({})]", uri, mime));
178                    }
179                },
180            }
181        }
182
183        let text = if text_parts.is_empty() {
184            if result.is_error {
185                "MCP tool returned an error with no message".to_string()
186            } else {
187                "MCP tool returned no text content".to_string()
188            }
189        } else {
190            text_parts.join("\n")
191        };
192
193        let images = if images.is_empty() {
194            None
195        } else {
196            Some(images)
197        };
198
199        (text, images)
200    }
201
202    /// Gracefully shut down all MCP servers.
203    pub async fn shutdown(&self) {
204        for (name, client) in &self.servers {
205            info!("Shutting down MCP server: {}", name);
206            client.shutdown().await;
207        }
208    }
209}