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    /// Check if any MCP servers are active.
95    pub fn has_servers(&self) -> bool {
96        !self.servers.is_empty()
97    }
98
99    /// Call a tool on a specific server.
100    ///
101    /// # Concurrency
102    ///
103    /// Multiple concurrent calls to the same server will serialize at the
104    /// transport layer (`StdioTransport` holds a mutex over stdin writes and
105    /// uses a shared pending-response map for JSON-RPC correlation). This is
106    /// intentional: JSON-RPC over stdio is a byte stream, and interleaved
107    /// writes would corrupt messages. Calls to *different* servers run fully
108    /// in parallel since each has its own transport.
109    pub async fn call_tool(
110        &self,
111        server_name: &str,
112        tool_name: &str,
113        arguments: &serde_json::Value,
114    ) -> Result<McpToolResult> {
115        let client = self
116            .servers
117            .get(server_name)
118            .ok_or_else(|| anyhow!("MCP server '{}' not found or not running", server_name))?;
119
120        client.call_tool(tool_name, arguments).await
121    }
122
123    /// Convert an MCP tool result into text suitable for a tool result message.
124    /// Images are returned separately for multimodal attachment. Audio is
125    /// attached through the same channel — adapters that don't support audio
126    /// will silently drop it. Resource links + embedded resources render as
127    /// text so the model can follow up with another tool call.
128    pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
129        let mut text_parts = Vec::new();
130        let mut images = Vec::new();
131
132        for block in &result.content {
133            match block {
134                ContentBlock::Text(text) => text_parts.push(text.clone()),
135                ContentBlock::Image { data, .. } => images.push(data.clone()),
136                ContentBlock::Audio { data, mime_type } => {
137                    images.push(data.clone());
138                    text_parts.push(format!("[audio attachment: {}]", mime_type));
139                },
140                ContentBlock::ResourceLink {
141                    uri,
142                    name,
143                    description,
144                    mime_type,
145                } => {
146                    let label = name.as_deref().unwrap_or(uri.as_str());
147                    let desc = description.as_deref().unwrap_or("");
148                    let mime = mime_type.as_deref().unwrap_or("");
149                    text_parts.push(format!(
150                        "[resource link: {} ({}) — {} → {}]",
151                        label, mime, desc, uri
152                    ));
153                },
154                ContentBlock::Resource {
155                    uri,
156                    mime_type,
157                    text,
158                    blob,
159                } => {
160                    let mime = mime_type.as_deref().unwrap_or("");
161                    if let Some(t) = text {
162                        text_parts.push(format!("[resource {}]:\n{}", uri, t));
163                    } else if let Some(b) = blob {
164                        text_parts.push(format!(
165                            "[resource {} ({}): {} bytes of base64]",
166                            uri,
167                            mime,
168                            b.len()
169                        ));
170                    } else {
171                        text_parts.push(format!("[resource {} ({})]", uri, mime));
172                    }
173                },
174            }
175        }
176
177        let text = if text_parts.is_empty() {
178            if result.is_error {
179                "MCP tool returned an error with no message".to_string()
180            } else {
181                "MCP tool returned no text content".to_string()
182            }
183        } else {
184            text_parts.join("\n")
185        };
186
187        let images = if images.is_empty() {
188            None
189        } else {
190            Some(images)
191        };
192
193        (text, images)
194    }
195
196    /// Gracefully shut down all MCP servers.
197    pub async fn shutdown(&self) {
198        for (name, client) in &self.servers {
199            info!("Shutting down MCP server: {}", name);
200            client.shutdown().await;
201        }
202    }
203}