1use 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
26pub const MCP_STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
32
33struct ServerRuntime {
35 client: Arc<McpClient>,
36 raw_tool_names: HashMap<String, String>,
38 specs: Vec<McpToolSpec>,
40}
41
42pub struct McpServerManager {
46 inner: RwLock<HashMap<String, ServerRuntime>>,
49 aliases: BTreeMap<String, String>,
52 shutting_down: AtomicBool,
55}
56
57impl McpServerManager {
58 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 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 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 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 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 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 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 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 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 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 pub fn has_servers(&self) -> bool {
224 !self
225 .inner
226 .read()
227 .expect("mcp registry lock poisoned")
228 .is_empty()
229 }
230
231 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 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 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 #[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 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 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 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 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 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 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}