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 crate::app::{McpServerConfig, TransportKind};
24use crate::domain::McpToolSpec;
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(
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 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 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 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 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 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 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 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 pub fn has_servers(&self) -> bool {
215 !self
216 .inner
217 .read()
218 .expect("mcp registry lock poisoned")
219 .is_empty()
220 }
221
222 pub fn read_only_hint(&self, server: &str, tool: &str) -> bool {
238 let guard = self.inner.read().expect("mcp registry lock poisoned");
239 let (raw_server, runtime) = match guard.get_key_value(server) {
240 Some(hit) => hit,
241 None => {
242 let Some(raw) = self.aliases.get(server) else {
243 return false;
244 };
245 let Some(hit) = guard.get_key_value(raw.as_str()) else {
246 return false;
247 };
248 hit
249 },
250 };
251 let alias = self.alias_for(raw_server);
252 let advertised = format!("mcp__{alias}__{tool}");
253 runtime
254 .specs
255 .iter()
256 .find(|s| s.name == advertised)
257 .is_some_and(|s| s.read_only_hint)
258 }
259
260 pub async fn call_tool(
261 &self,
262 server: &str,
263 tool: &str,
264 arguments: &serde_json::Value,
265 ) -> Result<McpToolResult> {
266 let (client, raw_tool) = {
267 let guard = self.inner.read().expect("mcp registry lock poisoned");
268 let (raw_server, runtime) = match guard.get_key_value(server) {
269 Some(hit) => hit,
270 None => {
271 let raw = self.aliases.get(server).ok_or_else(|| {
272 anyhow!("MCP server '{}' not found or not running", server)
273 })?;
274 guard.get_key_value(raw.as_str()).ok_or_else(|| {
275 anyhow!("MCP server '{}' not found or not running", server)
276 })?
277 },
278 };
279 let alias = self.alias_for(raw_server);
282 let advertised = format!("mcp__{alias}__{tool}");
283 let raw_tool = runtime
284 .raw_tool_names
285 .get(&advertised)
286 .cloned()
287 .unwrap_or_else(|| tool.to_string());
288 (Arc::clone(&runtime.client), raw_tool)
289 };
290 if client.is_shutdown() {
291 return Err(anyhow!("MCP server '{}' has been stopped", server));
292 }
293
294 client.call_tool(&raw_tool, arguments).await
295 }
296
297 pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
303 let mut text_parts = Vec::new();
304 let mut images = Vec::new();
305
306 for block in &result.content {
307 match block {
308 ContentBlock::Text(text) => text_parts.push(text.clone()),
309 ContentBlock::Image { data, .. } => images.push(data.clone()),
310 ContentBlock::Audio { data, mime_type } => {
311 images.push(data.clone());
312 text_parts.push(format!("[audio attachment: {}]", mime_type));
313 },
314 ContentBlock::ResourceLink {
315 uri,
316 name,
317 description,
318 mime_type,
319 } => {
320 let label = name.as_deref().unwrap_or(uri.as_str());
321 let desc = description.as_deref().unwrap_or("");
322 let mime = mime_type.as_deref().unwrap_or("");
323 text_parts.push(format!(
324 "[resource link: {} ({}) — {} → {}]",
325 label, mime, desc, uri
326 ));
327 },
328 ContentBlock::Resource {
329 uri,
330 mime_type,
331 text,
332 blob,
333 } => {
334 let mime = mime_type.as_deref().unwrap_or("");
335 if let Some(t) = text {
336 text_parts.push(format!("[resource {}]:\n{}", uri, t));
337 } else if let Some(b) = blob {
338 text_parts.push(format!(
339 "[resource {} ({}): {} bytes of base64]",
340 uri,
341 mime,
342 b.len()
343 ));
344 } else {
345 text_parts.push(format!("[resource {} ({})]", uri, mime));
346 }
347 },
348 }
349 }
350
351 let text = if text_parts.is_empty() {
352 if result.is_error {
353 "MCP tool returned an error with no message".to_string()
354 } else {
355 "MCP tool returned no text content".to_string()
356 }
357 } else {
358 text_parts.join("\n")
359 };
360
361 let images = if images.is_empty() {
362 None
363 } else {
364 Some(images)
365 };
366
367 (text, images)
368 }
369
370 pub async fn shutdown(&self) {
373 self.shutting_down.store(true, Ordering::Release);
374 let clients: Vec<(String, Arc<McpClient>)> = {
375 let guard = self.inner.read().expect("mcp registry lock poisoned");
376 guard
377 .iter()
378 .map(|(name, rt)| (name.clone(), Arc::clone(&rt.client)))
379 .collect()
380 };
381 for (name, client) in clients {
382 info!("Shutting down MCP server: {}", name);
383 client.shutdown().await;
384 }
385 }
386
387 pub async fn stop_server(&self, name: &str) -> bool {
395 let client = {
396 let guard = self.inner.read().expect("mcp registry lock poisoned");
397 let runtime = guard.get(name).or_else(|| {
398 self.aliases
399 .get(name)
400 .and_then(|raw| guard.get(raw.as_str()))
401 });
402 runtime.map(|rt| Arc::clone(&rt.client))
403 };
404 match client {
405 Some(client) => {
406 info!("Stopping MCP server: {}", name);
407 client.shutdown().await;
408 true
409 },
410 None => false,
411 }
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[tokio::test]
420 async fn stop_unknown_server_returns_false() {
421 let mgr = McpServerManager::new(&HashMap::new());
424 assert!(!mgr.has_servers());
425 assert!(!mgr.stop_server("does-not-exist").await);
426 }
427
428 #[test]
429 fn aliases_assigned_from_sorted_config_keys() {
430 let mut configs = HashMap::new();
431 configs.insert("my.server".to_string(), McpServerConfig::default());
432 configs.insert("plain".to_string(), McpServerConfig::default());
433 let mgr = McpServerManager::new(&configs);
434 assert_eq!(mgr.alias_for("my.server"), "my_server");
435 assert_eq!(mgr.alias_for("plain"), "plain");
436 assert_eq!(mgr.alias_for("un known"), "un_known");
438 }
439
440 #[cfg(unix)]
441 #[tokio::test]
442 async fn startup_timeout_reports_timed_out() {
443 let config = McpServerConfig {
446 command: "sleep".to_string(),
447 args: vec!["5".to_string()],
448 ..Default::default()
449 };
450 let mut configs = HashMap::new();
451 configs.insert("sleepy".to_string(), config.clone());
452 let mgr = McpServerManager::new(&configs);
453 let err = mgr
454 .start_server_with_timeout("sleepy", &config, Duration::from_millis(200))
455 .await
456 .expect_err("must time out");
457 assert!(err.to_string().contains("timed out"), "{err}");
458 assert!(!mgr.has_server("sleepy"));
459 }
460
461 #[tokio::test]
462 async fn http_server_starts_and_lists_tools() {
463 use super::super::transport_http::test_fixture::{fixture, json_reply, status_reply};
464 let init_result = r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx","version":"1.0"}}"#;
465 let tools_result =
466 r#"{"tools":[{"name":"echo","description":"echoes","inputSchema":{"type":"object"}}]}"#;
467 let fx = fixture(vec![
468 json_reply(&format!(
469 r#"{{"jsonrpc":"2.0","id":1,"result":{init_result}}}"#
470 )),
471 status_reply(202, "Accepted"),
472 json_reply(&format!(
473 r#"{{"jsonrpc":"2.0","id":2,"result":{tools_result}}}"#
474 )),
475 ])
476 .await;
477 let config = fx.config();
478 let mut configs = HashMap::new();
479 configs.insert("remote".to_string(), config.clone());
480 let mgr = McpServerManager::new(&configs);
481 let specs = mgr
482 .start_server_with_timeout("remote", &config, Duration::from_secs(30))
483 .await
484 .expect("http server must start");
485 assert_eq!(specs.len(), 1);
486 assert_eq!(specs[0].name, "mcp__remote__echo");
487 assert!(mgr.has_server("remote"));
488 }
489
490 #[tokio::test]
491 async fn config_with_both_command_and_url_errors() {
492 let config = McpServerConfig {
493 command: "npx".to_string(),
494 url: Some("https://example.com/mcp".to_string()),
495 ..Default::default()
496 };
497 let mut configs = HashMap::new();
498 configs.insert("conflicted".to_string(), config.clone());
499 let mgr = McpServerManager::new(&configs);
500 let err = mgr
501 .start_server_with_timeout("conflicted", &config, Duration::from_secs(5))
502 .await
503 .expect_err("must reject");
504 assert!(err.to_string().contains("mutually exclusive"), "{err}");
505 assert!(!mgr.has_server("conflicted"));
506 }
507
508 #[cfg(unix)]
509 #[tokio::test]
510 async fn straggler_insert_after_shutdown_is_reaped() {
511 let mgr = McpServerManager::new(&HashMap::new());
518 mgr.shutdown().await;
519 assert!(mgr.shutting_down.load(Ordering::Acquire));
520 assert!(!mgr.has_servers());
521 }
522}