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 mermaid_domain::McpToolSpec;
24use mermaid_domain::{McpServerConfig, TransportKind};
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    ///
84    /// # Errors
85    ///
86    /// Spawning or connecting to the server, the `initialize` handshake, the
87    /// `list_tools` discovery, and the whole sequence exceeding
88    /// [`MCP_STARTUP_TIMEOUT`]. On timeout the in-flight future is dropped,
89    /// which reaps the child — no stray process survives a failed start. One
90    /// server failing here never blocks the others; the caller reports it and
91    /// carries on.
92    pub async fn start_server(
93        &self,
94        name: &str,
95        config: &McpServerConfig,
96    ) -> Result<Vec<McpToolSpec>> {
97        self.start_server_with_timeout(name, config, MCP_STARTUP_TIMEOUT)
98            .await
99    }
100
101    /// Timeout-injectable body of [`Self::start_server`] (tests use a short
102    /// bound against a sleeping fixture).
103    pub(crate) async fn start_server_with_timeout(
104        &self,
105        name: &str,
106        config: &McpServerConfig,
107        timeout: Duration,
108    ) -> Result<Vec<McpToolSpec>> {
109        match &config.url {
110            Some(url) => info!("Starting MCP server: {} ({})", name, url),
111            None => info!(
112                "Starting MCP server: {} ({} {})",
113                name,
114                config.command,
115                // Redact args — they can carry secrets (e.g. `--api-key=…`) (#93).
116                mermaid_model::utils::redact_secrets(&config.args.join(" "))
117            ),
118        }
119
120        let started = tokio::time::timeout(timeout, Self::start_one(name, config)).await;
121        let (client, tools) = match started {
122            Ok(Ok(pair)) => pair,
123            Ok(Err(e)) => {
124                warn!("Failed to start MCP server '{}': {}", name, e);
125                return Err(e);
126            },
127            Err(_) => {
128                // Dropping the in-flight future reaps the child: the
129                // transport spawns with kill_on_drop(true).
130                warn!(
131                    "MCP server '{}' startup timed out after {}s",
132                    name,
133                    timeout.as_secs()
134                );
135                return Err(anyhow!("startup timed out after {}s", timeout.as_secs()));
136            },
137        };
138
139        let alias = self.alias_for(name);
140        let (specs, raw_tool_names) = sanitize::sanitize_server_tools(&alias, &tools);
141        info!(
142            "MCP server '{}' ready: {} tools ({})",
143            name,
144            specs.len(),
145            client
146                .server_info
147                .as_ref()
148                .map(|s| s.name.as_str())
149                .unwrap_or("?")
150        );
151
152        let runtime = ServerRuntime {
153            client: Arc::new(client),
154            raw_tool_names,
155            specs: specs.clone(),
156        };
157        if self.shutting_down.load(Ordering::Acquire) {
158            // Shutdown already ran; don't insert a client nothing will reap.
159            runtime.client.shutdown().await;
160            return Err(anyhow!("manager shut down during startup"));
161        }
162        self.inner
163            .write()
164            .expect("mcp registry lock poisoned")
165            .insert(name.to_string(), runtime);
166        Ok(specs)
167    }
168
169    /// Start a single MCP server, initialize, and list tools.
170    async fn start_one(
171        name: &str,
172        config: &McpServerConfig,
173    ) -> Result<(McpClient, Vec<McpToolDef>)> {
174        let transport: Transport = match config.transport_kind()? {
175            TransportKind::Stdio => {
176                StdioTransport::spawn(&config.command, &config.args, &config.env)
177                    .await?
178                    .into()
179            },
180            TransportKind::Http => HttpTransport::new(config)?.into(),
181        };
182        let mut client = McpClient::new(transport);
183
184        client
185            .initialize()
186            .await
187            .map_err(|e| anyhow!("MCP server '{name}' initialization failed: {e}"))?;
188
189        let tools = client
190            .list_tools()
191            .await
192            .map_err(|e| anyhow!("MCP server '{name}' tool discovery failed: {e}"))?;
193
194        Ok((client, tools))
195    }
196
197    /// All discovered tools as (raw server name, sanitized spec) pairs,
198    /// cloned out so no lock is held by the caller. Order: server name.
199    pub fn all_specs(&self) -> Vec<(String, McpToolSpec)> {
200        let guard = self.inner.read().expect("mcp registry lock poisoned");
201        let mut out: Vec<(String, McpToolSpec)> = guard
202            .iter()
203            .flat_map(|(name, rt)| rt.specs.iter().map(|s| (name.clone(), s.clone())))
204            .collect();
205        out.sort_by(|a, b| {
206            (a.0.as_str(), a.1.name.as_str()).cmp(&(b.0.as_str(), b.1.name.as_str()))
207        });
208        out
209    }
210
211    /// True iff the named server started and has an active client,
212    /// even if it advertised zero tools. Accepts raw or sanitized names.
213    pub fn has_server(&self, name: &str) -> bool {
214        let guard = self.inner.read().expect("mcp registry lock poisoned");
215        guard.contains_key(name)
216            || self
217                .aliases
218                .get(name)
219                .is_some_and(|raw| guard.contains_key(raw))
220    }
221
222    /// Check if any MCP servers are active.
223    pub fn has_servers(&self) -> bool {
224        !self
225            .inner
226            .read()
227            .expect("mcp registry lock poisoned")
228            .is_empty()
229    }
230
231    /// Call a tool on a specific server. `server` and `tool` accept
232    /// sanitized names (the advertised form) or raw names (an off-script
233    /// model echoing a server's own tool listing still routes).
234    ///
235    /// # Concurrency
236    ///
237    /// Multiple concurrent calls to the same server serialize at the
238    /// transport layer (`StdioTransport` holds a mutex over stdin writes and
239    /// uses a shared pending-response map for JSON-RPC correlation). Calls to
240    /// *different* servers run fully in parallel. The registry read lock is
241    /// dropped before awaiting the call.
242    /// The server-advertised `readOnlyHint` for a tool; `false` when the
243    /// server, the tool, or the annotation is unknown — an unannotated tool
244    /// is write-shaped, fail closed. Mirrors `call_tool`'s server/alias
245    /// resolution so the hint is read for exactly the tool that would run.
246    pub fn read_only_hint(&self, server: &str, tool: &str) -> bool {
247        let guard = self.inner.read().expect("mcp registry lock poisoned");
248        let (raw_server, runtime) = match guard.get_key_value(server) {
249            Some(hit) => hit,
250            None => {
251                let Some(raw) = self.aliases.get(server) else {
252                    return false;
253                };
254                let Some(hit) = guard.get_key_value(raw.as_str()) else {
255                    return false;
256                };
257                hit
258            },
259        };
260        let alias = self.alias_for(raw_server);
261        let advertised = format!("mcp__{alias}__{tool}");
262        runtime
263            .specs
264            .iter()
265            .find(|s| s.name == advertised)
266            .is_some_and(|s| s.read_only_hint)
267    }
268
269    /// Dispatch a tool call to `server`, resolving both the alias and the raw
270    /// tool name.
271    ///
272    /// # Errors
273    ///
274    /// A `server` that is neither a running raw name nor a known alias, a
275    /// server that has been stopped, and whatever the call itself fails with
276    /// (transport, timeout). A tool that runs and reports failure is not an
277    /// error — that is `isError` on the returned [`McpToolResult`].
278    pub async fn call_tool(
279        &self,
280        server: &str,
281        tool: &str,
282        arguments: &serde_json::Value,
283    ) -> Result<McpToolResult> {
284        let (client, raw_tool) = {
285            let guard = self.inner.read().expect("mcp registry lock poisoned");
286            let (raw_server, runtime) = match guard.get_key_value(server) {
287                Some(hit) => hit,
288                None => {
289                    let raw = self
290                        .aliases
291                        .get(server)
292                        .ok_or_else(|| anyhow!("MCP server '{server}' not found or not running"))?;
293                    guard
294                        .get_key_value(raw.as_str())
295                        .ok_or_else(|| anyhow!("MCP server '{server}' not found or not running"))?
296                },
297            };
298            // The advertised name is `mcp__<alias>__<tool>`; resolve the raw
299            // tool by reconstructing it, falling back to the name as given.
300            let alias = self.alias_for(raw_server);
301            let advertised = format!("mcp__{alias}__{tool}");
302            let raw_tool = runtime
303                .raw_tool_names
304                .get(&advertised)
305                .cloned()
306                .unwrap_or_else(|| tool.to_string());
307            (Arc::clone(&runtime.client), raw_tool)
308        };
309        if client.is_shutdown() {
310            return Err(anyhow!("MCP server '{server}' has been stopped"));
311        }
312
313        client.call_tool(&raw_tool, arguments).await
314    }
315
316    /// Convert an MCP tool result into text suitable for a tool result message.
317    /// Images are returned separately for multimodal attachment. Audio is
318    /// attached through the same channel — adapters that don't support audio
319    /// will silently drop it. Resource links + embedded resources render as
320    /// text so the model can follow up with another tool call.
321    #[must_use]
322    pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
323        let mut text_parts = Vec::new();
324        let mut images = Vec::new();
325
326        for block in &result.content {
327            match block {
328                ContentBlock::Text(text) => text_parts.push(text.clone()),
329                ContentBlock::Image { data, .. } => images.push(data.clone()),
330                ContentBlock::Audio { data, mime_type } => {
331                    images.push(data.clone());
332                    text_parts.push(format!("[audio attachment: {mime_type}]"));
333                },
334                ContentBlock::ResourceLink {
335                    uri,
336                    name,
337                    description,
338                    mime_type,
339                } => {
340                    let label = name.as_deref().unwrap_or(uri.as_str());
341                    let desc = description.as_deref().unwrap_or("");
342                    let mime = mime_type.as_deref().unwrap_or("");
343                    text_parts.push(format!(
344                        "[resource link: {label} ({mime}) — {desc} → {uri}]"
345                    ));
346                },
347                ContentBlock::Resource {
348                    uri,
349                    mime_type,
350                    text,
351                    blob,
352                } => {
353                    let mime = mime_type.as_deref().unwrap_or("");
354                    if let Some(t) = text {
355                        text_parts.push(format!("[resource {uri}]:\n{t}"));
356                    } else if let Some(b) = blob {
357                        text_parts.push(format!(
358                            "[resource {} ({}): {} bytes of base64]",
359                            uri,
360                            mime,
361                            b.len()
362                        ));
363                    } else {
364                        text_parts.push(format!("[resource {uri} ({mime})]"));
365                    }
366                },
367            }
368        }
369
370        let text = if text_parts.is_empty() {
371            if result.is_error {
372                "MCP tool returned an error with no message".to_string()
373            } else {
374                "MCP tool returned no text content".to_string()
375            }
376        } else {
377            text_parts.join("\n")
378        };
379
380        let images = if images.is_empty() {
381            None
382        } else {
383            Some(images)
384        };
385
386        (text, images)
387    }
388
389    /// Gracefully shut down all MCP servers. Sets the shutting-down flag
390    /// first so straggler startup tasks reap their own clients.
391    pub async fn shutdown(&self) {
392        self.shutting_down.store(true, Ordering::Release);
393        let clients: Vec<(String, Arc<McpClient>)> = {
394            let guard = self.inner.read().expect("mcp registry lock poisoned");
395            guard
396                .iter()
397                .map(|(name, rt)| (name.clone(), Arc::clone(&rt.client)))
398                .collect()
399        };
400        for (name, client) in clients {
401            info!("Shutting down MCP server: {}", name);
402            client.shutdown().await;
403        }
404    }
405
406    /// Stop a single named server: kill its child via the transport. The
407    /// stdout-reader task then exits on EOF — no explicit abort needed. Returns
408    /// `true` if a server matched (raw or sanitized name).
409    ///
410    /// The registry entry lingers, but the client is flagged shut down, so a
411    /// later `call_tool` to a stopped server returns a clean "has been
412    /// stopped" error rather than a broken-pipe transport failure.
413    pub async fn stop_server(&self, name: &str) -> bool {
414        let client = {
415            let guard = self.inner.read().expect("mcp registry lock poisoned");
416            let runtime = guard.get(name).or_else(|| {
417                self.aliases
418                    .get(name)
419                    .and_then(|raw| guard.get(raw.as_str()))
420            });
421            runtime.map(|rt| Arc::clone(&rt.client))
422        };
423        match client {
424            Some(client) => {
425                info!("Stopping MCP server: {}", name);
426                client.shutdown().await;
427                true
428            },
429            None => false,
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[tokio::test]
439    async fn stop_unknown_server_returns_false() {
440        // No servers configured ⇒ empty manager; stopping an unknown name is a
441        // no-op that reports `false` rather than panicking.
442        let mgr = McpServerManager::new(&HashMap::new());
443        assert!(!mgr.has_servers());
444        assert!(!mgr.stop_server("does-not-exist").await);
445    }
446
447    #[test]
448    fn aliases_assigned_from_sorted_config_keys() {
449        let mut configs = HashMap::new();
450        configs.insert("my.server".to_string(), McpServerConfig::default());
451        configs.insert("plain".to_string(), McpServerConfig::default());
452        let mgr = McpServerManager::new(&configs);
453        assert_eq!(mgr.alias_for("my.server"), "my_server");
454        assert_eq!(mgr.alias_for("plain"), "plain");
455        // Unknown names sanitize on the fly instead of panicking.
456        assert_eq!(mgr.alias_for("un known"), "un_known");
457    }
458
459    #[cfg(unix)]
460    #[tokio::test]
461    async fn startup_timeout_reports_timed_out() {
462        // A server whose process never speaks JSON-RPC: `sleep` hangs the
463        // initialize round-trip; the injected 200ms bound trips first.
464        let config = McpServerConfig {
465            command: "sleep".to_string(),
466            args: vec!["5".to_string()],
467            ..Default::default()
468        };
469        let mut configs = HashMap::new();
470        configs.insert("sleepy".to_string(), config.clone());
471        let mgr = McpServerManager::new(&configs);
472        let err = mgr
473            .start_server_with_timeout("sleepy", &config, Duration::from_millis(200))
474            .await
475            .expect_err("must time out");
476        assert!(err.to_string().contains("timed out"), "{err}");
477        assert!(!mgr.has_server("sleepy"));
478    }
479
480    #[tokio::test]
481    async fn http_server_starts_and_lists_tools() {
482        use super::super::transport_http::test_fixture::{fixture, json_reply, status_reply};
483        let init_result = r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx","version":"1.0"}}"#;
484        let tools_result =
485            r#"{"tools":[{"name":"echo","description":"echoes","inputSchema":{"type":"object"}}]}"#;
486        let fx = fixture(vec![
487            json_reply(&format!(
488                r#"{{"jsonrpc":"2.0","id":1,"result":{init_result}}}"#
489            )),
490            status_reply(202, "Accepted"),
491            json_reply(&format!(
492                r#"{{"jsonrpc":"2.0","id":2,"result":{tools_result}}}"#
493            )),
494        ])
495        .await;
496        let config = fx.config();
497        let mut configs = HashMap::new();
498        configs.insert("remote".to_string(), config.clone());
499        let mgr = McpServerManager::new(&configs);
500        let specs = mgr
501            .start_server_with_timeout("remote", &config, Duration::from_secs(30))
502            .await
503            .expect("http server must start");
504        assert_eq!(specs.len(), 1);
505        assert_eq!(specs[0].name, "mcp__remote__echo");
506        assert!(mgr.has_server("remote"));
507    }
508
509    #[tokio::test]
510    async fn config_with_both_command_and_url_errors() {
511        let config = McpServerConfig {
512            command: "npx".to_string(),
513            url: Some("https://example.com/mcp".to_string()),
514            ..Default::default()
515        };
516        let mut configs = HashMap::new();
517        configs.insert("conflicted".to_string(), config.clone());
518        let mgr = McpServerManager::new(&configs);
519        let err = mgr
520            .start_server_with_timeout("conflicted", &config, Duration::from_secs(5))
521            .await
522            .expect_err("must reject");
523        assert!(err.to_string().contains("mutually exclusive"), "{err}");
524        assert!(!mgr.has_server("conflicted"));
525    }
526
527    #[cfg(unix)]
528    #[tokio::test]
529    async fn straggler_insert_after_shutdown_is_reaped() {
530        // Once shutdown() has run, a late-resolving startup must not insert.
531        // Simulate by flipping the flag first: start_server_with_timeout on a
532        // server that would "succeed" cannot easily be faked without a real
533        // MCP process, so assert the flag's effect through the public path:
534        // a sleeping fixture that times out never inserts either way, and the
535        // flag stays set.
536        let mgr = McpServerManager::new(&HashMap::new());
537        mgr.shutdown().await;
538        assert!(mgr.shutting_down.load(Ordering::Acquire));
539        assert!(!mgr.has_servers());
540    }
541}