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. Servers start
5//! concurrently (the effect layer spawns one task per server calling
6//! [`McpServerManager::start_server`]); each startup is bounded by
7//! [`MCP_STARTUP_TIMEOUT`] and inserts into the shared registry as it
8//! resolves, so one slow server never delays the rest.
9
10use std::collections::{BTreeMap, HashMap};
11use std::sync::RwLock;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Duration;
14
15use anyhow::{Result, anyhow};
16use std::sync::Arc;
17use tracing::{info, warn};
18
19use super::client::{ContentBlock, McpClient, McpToolDef, McpToolResult};
20use super::sanitize;
21use super::transport::{StdioTransport, Transport};
22use super::transport_http::HttpTransport;
23use crate::app::{McpServerConfig, TransportKind};
24use crate::domain::McpToolSpec;
25
26/// Wall-clock bound for one server's spawn + initialize + list_tools.
27/// The per-JSON-RPC request timeout inside the transport is 30s, so the
28/// slow-but-legitimate case (npx cold-downloading a package during
29/// `initialize`) already fits; this catches spawn-level hangs. A config
30/// override is deliberately deferred until someone needs it.
31pub const MCP_STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
32
33/// Per-server runtime: the live client plus sanitized-name bookkeeping.
34struct ServerRuntime {
35    client: Arc<McpClient>,
36    /// Sanitized full advertised name (`mcp__srv__tool`) -> raw tool name.
37    raw_tool_names: HashMap<String, String>,
38    /// Sanitized specs advertised for this server (also seeds subagents).
39    specs: Vec<McpToolSpec>,
40}
41
42/// Manages multiple MCP server connections behind interior mutability so
43/// per-server startup tasks can insert as they finish while synchronous
44/// consumers (`has_server`, `all_specs`) keep working.
45pub struct McpServerManager {
46    /// Keyed by RAW config server name. Guard is never held across .await:
47    /// readers clone the `Arc<McpClient>` and drop the lock before awaiting.
48    inner: RwLock<HashMap<String, ServerRuntime>>,
49    /// Sanitized server segment -> raw config name, assigned deterministically
50    /// from the sorted config key list at construction.
51    aliases: BTreeMap<String, String>,
52    /// Set by `shutdown()`; a straggler startup task that resolves after
53    /// shutdown must reap its client instead of inserting it.
54    shutting_down: AtomicBool,
55}
56
57impl McpServerManager {
58    /// Empty manager pre-seeded with deterministic server-name aliases.
59    pub fn new(configs: &HashMap<String, McpServerConfig>) -> Self {
60        let mut names: Vec<&str> = configs.keys().map(String::as_str).collect();
61        names.sort_unstable();
62        Self {
63            inner: RwLock::new(HashMap::new()),
64            aliases: sanitize::assign_server_aliases(names),
65            shutting_down: AtomicBool::new(false),
66        }
67    }
68
69    /// Sanitized alias for a raw server name (assigned at construction).
70    /// Falls back to sanitizing on the fly for names outside the config
71    /// set (defensive; callers always pass configured names).
72    pub fn alias_for(&self, raw_name: &str) -> String {
73        self.aliases
74            .iter()
75            .find(|(_, raw)| raw.as_str() == raw_name)
76            .map(|(alias, _)| alias.clone())
77            .unwrap_or_else(|| sanitize::sanitize_segment(raw_name))
78    }
79
80    /// Spawn + initialize + list_tools for one server, bounded by
81    /// [`MCP_STARTUP_TIMEOUT`]; inserts the runtime and returns the
82    /// sanitized specs for the reducer's `Msg::McpServerReady`.
83    pub async fn start_server(
84        &self,
85        name: &str,
86        config: &McpServerConfig,
87    ) -> Result<Vec<McpToolSpec>> {
88        self.start_server_with_timeout(name, config, MCP_STARTUP_TIMEOUT)
89            .await
90    }
91
92    /// Timeout-injectable body of [`Self::start_server`] (tests use a short
93    /// bound against a sleeping fixture).
94    pub(crate) async fn start_server_with_timeout(
95        &self,
96        name: &str,
97        config: &McpServerConfig,
98        timeout: Duration,
99    ) -> Result<Vec<McpToolSpec>> {
100        match &config.url {
101            Some(url) => info!("Starting MCP server: {} ({})", name, url),
102            None => info!(
103                "Starting MCP server: {} ({} {})",
104                name,
105                config.command,
106                // Redact args — they can carry secrets (e.g. `--api-key=…`) (#93).
107                crate::utils::redact_secrets(&config.args.join(" "))
108            ),
109        }
110
111        let started = tokio::time::timeout(timeout, Self::start_one(name, config)).await;
112        let (client, tools) = match started {
113            Ok(Ok(pair)) => pair,
114            Ok(Err(e)) => {
115                warn!("Failed to start MCP server '{}': {}", name, e);
116                return Err(e);
117            },
118            Err(_) => {
119                // Dropping the in-flight future reaps the child: the
120                // transport spawns with kill_on_drop(true).
121                warn!(
122                    "MCP server '{}' startup timed out after {}s",
123                    name,
124                    timeout.as_secs()
125                );
126                return Err(anyhow!("startup timed out after {}s", timeout.as_secs()));
127            },
128        };
129
130        let alias = self.alias_for(name);
131        let (specs, raw_tool_names) = sanitize::sanitize_server_tools(&alias, &tools);
132        info!(
133            "MCP server '{}' ready: {} tools ({})",
134            name,
135            specs.len(),
136            client
137                .server_info
138                .as_ref()
139                .map(|s| s.name.as_str())
140                .unwrap_or("?")
141        );
142
143        let runtime = ServerRuntime {
144            client: Arc::new(client),
145            raw_tool_names,
146            specs: specs.clone(),
147        };
148        if self.shutting_down.load(Ordering::Acquire) {
149            // Shutdown already ran; don't insert a client nothing will reap.
150            runtime.client.shutdown().await;
151            return Err(anyhow!("manager shut down during startup"));
152        }
153        self.inner
154            .write()
155            .expect("mcp registry lock poisoned")
156            .insert(name.to_string(), runtime);
157        Ok(specs)
158    }
159
160    /// Start a single MCP server, initialize, and list tools.
161    async fn start_one(
162        name: &str,
163        config: &McpServerConfig,
164    ) -> Result<(McpClient, Vec<McpToolDef>)> {
165        let transport: Transport = match config.transport_kind()? {
166            TransportKind::Stdio => {
167                StdioTransport::spawn(&config.command, &config.args, &config.env)
168                    .await?
169                    .into()
170            },
171            TransportKind::Http => HttpTransport::new(config)?.into(),
172        };
173        let mut client = McpClient::new(transport);
174
175        client
176            .initialize()
177            .await
178            .map_err(|e| anyhow!("MCP server '{}' initialization failed: {}", name, e))?;
179
180        let tools = client
181            .list_tools()
182            .await
183            .map_err(|e| anyhow!("MCP server '{}' tool discovery failed: {}", name, e))?;
184
185        Ok((client, tools))
186    }
187
188    /// All discovered tools as (raw server name, sanitized spec) pairs,
189    /// cloned out so no lock is held by the caller. Order: server name.
190    pub fn all_specs(&self) -> Vec<(String, McpToolSpec)> {
191        let guard = self.inner.read().expect("mcp registry lock poisoned");
192        let mut out: Vec<(String, McpToolSpec)> = guard
193            .iter()
194            .flat_map(|(name, rt)| rt.specs.iter().map(|s| (name.clone(), s.clone())))
195            .collect();
196        out.sort_by(|a, b| {
197            (a.0.as_str(), a.1.name.as_str()).cmp(&(b.0.as_str(), b.1.name.as_str()))
198        });
199        out
200    }
201
202    /// True iff the named server started and has an active client,
203    /// even if it advertised zero tools. Accepts raw or sanitized names.
204    pub fn has_server(&self, name: &str) -> bool {
205        let guard = self.inner.read().expect("mcp registry lock poisoned");
206        guard.contains_key(name)
207            || self
208                .aliases
209                .get(name)
210                .is_some_and(|raw| guard.contains_key(raw))
211    }
212
213    /// Check if any MCP servers are active.
214    pub fn has_servers(&self) -> bool {
215        !self
216            .inner
217            .read()
218            .expect("mcp registry lock poisoned")
219            .is_empty()
220    }
221
222    /// Call a tool on a specific server. `server` and `tool` accept
223    /// sanitized names (the advertised form) or raw names (an off-script
224    /// model echoing a server's own tool listing still routes).
225    ///
226    /// # Concurrency
227    ///
228    /// Multiple concurrent calls to the same server serialize at the
229    /// transport layer (`StdioTransport` holds a mutex over stdin writes and
230    /// uses a shared pending-response map for JSON-RPC correlation). Calls to
231    /// *different* servers run fully in parallel. The registry read lock is
232    /// dropped before awaiting the call.
233    pub async fn call_tool(
234        &self,
235        server: &str,
236        tool: &str,
237        arguments: &serde_json::Value,
238    ) -> Result<McpToolResult> {
239        let (client, raw_tool) = {
240            let guard = self.inner.read().expect("mcp registry lock poisoned");
241            let (raw_server, runtime) = match guard.get_key_value(server) {
242                Some(hit) => hit,
243                None => {
244                    let raw = self.aliases.get(server).ok_or_else(|| {
245                        anyhow!("MCP server '{}' not found or not running", server)
246                    })?;
247                    guard.get_key_value(raw.as_str()).ok_or_else(|| {
248                        anyhow!("MCP server '{}' not found or not running", server)
249                    })?
250                },
251            };
252            // The advertised name is `mcp__<alias>__<tool>`; resolve the raw
253            // tool by reconstructing it, falling back to the name as given.
254            let alias = self.alias_for(raw_server);
255            let advertised = format!("mcp__{alias}__{tool}");
256            let raw_tool = runtime
257                .raw_tool_names
258                .get(&advertised)
259                .cloned()
260                .unwrap_or_else(|| tool.to_string());
261            (Arc::clone(&runtime.client), raw_tool)
262        };
263        if client.is_shutdown() {
264            return Err(anyhow!("MCP server '{}' has been stopped", server));
265        }
266
267        client.call_tool(&raw_tool, arguments).await
268    }
269
270    /// Convert an MCP tool result into text suitable for a tool result message.
271    /// Images are returned separately for multimodal attachment. Audio is
272    /// attached through the same channel — adapters that don't support audio
273    /// will silently drop it. Resource links + embedded resources render as
274    /// text so the model can follow up with another tool call.
275    pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
276        let mut text_parts = Vec::new();
277        let mut images = Vec::new();
278
279        for block in &result.content {
280            match block {
281                ContentBlock::Text(text) => text_parts.push(text.clone()),
282                ContentBlock::Image { data, .. } => images.push(data.clone()),
283                ContentBlock::Audio { data, mime_type } => {
284                    images.push(data.clone());
285                    text_parts.push(format!("[audio attachment: {}]", mime_type));
286                },
287                ContentBlock::ResourceLink {
288                    uri,
289                    name,
290                    description,
291                    mime_type,
292                } => {
293                    let label = name.as_deref().unwrap_or(uri.as_str());
294                    let desc = description.as_deref().unwrap_or("");
295                    let mime = mime_type.as_deref().unwrap_or("");
296                    text_parts.push(format!(
297                        "[resource link: {} ({}) — {} → {}]",
298                        label, mime, desc, uri
299                    ));
300                },
301                ContentBlock::Resource {
302                    uri,
303                    mime_type,
304                    text,
305                    blob,
306                } => {
307                    let mime = mime_type.as_deref().unwrap_or("");
308                    if let Some(t) = text {
309                        text_parts.push(format!("[resource {}]:\n{}", uri, t));
310                    } else if let Some(b) = blob {
311                        text_parts.push(format!(
312                            "[resource {} ({}): {} bytes of base64]",
313                            uri,
314                            mime,
315                            b.len()
316                        ));
317                    } else {
318                        text_parts.push(format!("[resource {} ({})]", uri, mime));
319                    }
320                },
321            }
322        }
323
324        let text = if text_parts.is_empty() {
325            if result.is_error {
326                "MCP tool returned an error with no message".to_string()
327            } else {
328                "MCP tool returned no text content".to_string()
329            }
330        } else {
331            text_parts.join("\n")
332        };
333
334        let images = if images.is_empty() {
335            None
336        } else {
337            Some(images)
338        };
339
340        (text, images)
341    }
342
343    /// Gracefully shut down all MCP servers. Sets the shutting-down flag
344    /// first so straggler startup tasks reap their own clients.
345    pub async fn shutdown(&self) {
346        self.shutting_down.store(true, Ordering::Release);
347        let clients: Vec<(String, Arc<McpClient>)> = {
348            let guard = self.inner.read().expect("mcp registry lock poisoned");
349            guard
350                .iter()
351                .map(|(name, rt)| (name.clone(), Arc::clone(&rt.client)))
352                .collect()
353        };
354        for (name, client) in clients {
355            info!("Shutting down MCP server: {}", name);
356            client.shutdown().await;
357        }
358    }
359
360    /// Stop a single named server: kill its child via the transport. The
361    /// stdout-reader task then exits on EOF — no explicit abort needed. Returns
362    /// `true` if a server matched (raw or sanitized name).
363    ///
364    /// The registry entry lingers, but the client is flagged shut down, so a
365    /// later `call_tool` to a stopped server returns a clean "has been
366    /// stopped" error rather than a broken-pipe transport failure.
367    pub async fn stop_server(&self, name: &str) -> bool {
368        let client = {
369            let guard = self.inner.read().expect("mcp registry lock poisoned");
370            let runtime = guard.get(name).or_else(|| {
371                self.aliases
372                    .get(name)
373                    .and_then(|raw| guard.get(raw.as_str()))
374            });
375            runtime.map(|rt| Arc::clone(&rt.client))
376        };
377        match client {
378            Some(client) => {
379                info!("Stopping MCP server: {}", name);
380                client.shutdown().await;
381                true
382            },
383            None => false,
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[tokio::test]
393    async fn stop_unknown_server_returns_false() {
394        // No servers configured ⇒ empty manager; stopping an unknown name is a
395        // no-op that reports `false` rather than panicking.
396        let mgr = McpServerManager::new(&HashMap::new());
397        assert!(!mgr.has_servers());
398        assert!(!mgr.stop_server("does-not-exist").await);
399    }
400
401    #[test]
402    fn aliases_assigned_from_sorted_config_keys() {
403        let mut configs = HashMap::new();
404        configs.insert("my.server".to_string(), McpServerConfig::default());
405        configs.insert("plain".to_string(), McpServerConfig::default());
406        let mgr = McpServerManager::new(&configs);
407        assert_eq!(mgr.alias_for("my.server"), "my_server");
408        assert_eq!(mgr.alias_for("plain"), "plain");
409        // Unknown names sanitize on the fly instead of panicking.
410        assert_eq!(mgr.alias_for("un known"), "un_known");
411    }
412
413    #[cfg(unix)]
414    #[tokio::test]
415    async fn startup_timeout_reports_timed_out() {
416        // A server whose process never speaks JSON-RPC: `sleep` hangs the
417        // initialize round-trip; the injected 200ms bound trips first.
418        let config = McpServerConfig {
419            command: "sleep".to_string(),
420            args: vec!["5".to_string()],
421            ..Default::default()
422        };
423        let mut configs = HashMap::new();
424        configs.insert("sleepy".to_string(), config.clone());
425        let mgr = McpServerManager::new(&configs);
426        let err = mgr
427            .start_server_with_timeout("sleepy", &config, Duration::from_millis(200))
428            .await
429            .expect_err("must time out");
430        assert!(err.to_string().contains("timed out"), "{err}");
431        assert!(!mgr.has_server("sleepy"));
432    }
433
434    #[tokio::test]
435    async fn http_server_starts_and_lists_tools() {
436        use super::super::transport_http::test_fixture::{fixture, json_reply, status_reply};
437        let init_result = r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx","version":"1.0"}}"#;
438        let tools_result =
439            r#"{"tools":[{"name":"echo","description":"echoes","inputSchema":{"type":"object"}}]}"#;
440        let fx = fixture(vec![
441            json_reply(&format!(
442                r#"{{"jsonrpc":"2.0","id":1,"result":{init_result}}}"#
443            )),
444            status_reply(202, "Accepted"),
445            json_reply(&format!(
446                r#"{{"jsonrpc":"2.0","id":2,"result":{tools_result}}}"#
447            )),
448        ])
449        .await;
450        let config = fx.config();
451        let mut configs = HashMap::new();
452        configs.insert("remote".to_string(), config.clone());
453        let mgr = McpServerManager::new(&configs);
454        let specs = mgr
455            .start_server_with_timeout("remote", &config, Duration::from_secs(30))
456            .await
457            .expect("http server must start");
458        assert_eq!(specs.len(), 1);
459        assert_eq!(specs[0].name, "mcp__remote__echo");
460        assert!(mgr.has_server("remote"));
461    }
462
463    #[tokio::test]
464    async fn config_with_both_command_and_url_errors() {
465        let config = McpServerConfig {
466            command: "npx".to_string(),
467            url: Some("https://example.com/mcp".to_string()),
468            ..Default::default()
469        };
470        let mut configs = HashMap::new();
471        configs.insert("conflicted".to_string(), config.clone());
472        let mgr = McpServerManager::new(&configs);
473        let err = mgr
474            .start_server_with_timeout("conflicted", &config, Duration::from_secs(5))
475            .await
476            .expect_err("must reject");
477        assert!(err.to_string().contains("mutually exclusive"), "{err}");
478        assert!(!mgr.has_server("conflicted"));
479    }
480
481    #[cfg(unix)]
482    #[tokio::test]
483    async fn straggler_insert_after_shutdown_is_reaped() {
484        // Once shutdown() has run, a late-resolving startup must not insert.
485        // Simulate by flipping the flag first: start_server_with_timeout on a
486        // server that would "succeed" cannot easily be faked without a real
487        // MCP process, so assert the flag's effect through the public path:
488        // a sleeping fixture that times out never inserts either way, and the
489        // flag stays set.
490        let mgr = McpServerManager::new(&HashMap::new());
491        mgr.shutdown().await;
492        assert!(mgr.shutting_down.load(Ordering::Acquire));
493        assert!(!mgr.has_servers());
494    }
495}