use std::collections::{BTreeMap, HashMap};
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::{Result, anyhow};
use std::sync::Arc;
use tracing::{info, warn};
use super::client::{ContentBlock, McpClient, McpToolDef, McpToolResult};
use super::sanitize;
use super::transport::{StdioTransport, Transport};
use super::transport_http::HttpTransport;
use crate::app::{McpServerConfig, TransportKind};
use crate::domain::McpToolSpec;
pub const MCP_STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
struct ServerRuntime {
client: Arc<McpClient>,
raw_tool_names: HashMap<String, String>,
specs: Vec<McpToolSpec>,
}
pub struct McpServerManager {
inner: RwLock<HashMap<String, ServerRuntime>>,
aliases: BTreeMap<String, String>,
shutting_down: AtomicBool,
}
impl McpServerManager {
pub fn new(configs: &HashMap<String, McpServerConfig>) -> Self {
let mut names: Vec<&str> = configs.keys().map(String::as_str).collect();
names.sort_unstable();
Self {
inner: RwLock::new(HashMap::new()),
aliases: sanitize::assign_server_aliases(names),
shutting_down: AtomicBool::new(false),
}
}
pub fn alias_for(&self, raw_name: &str) -> String {
self.aliases
.iter()
.find(|(_, raw)| raw.as_str() == raw_name)
.map(|(alias, _)| alias.clone())
.unwrap_or_else(|| sanitize::sanitize_segment(raw_name))
}
pub async fn start_server(
&self,
name: &str,
config: &McpServerConfig,
) -> Result<Vec<McpToolSpec>> {
self.start_server_with_timeout(name, config, MCP_STARTUP_TIMEOUT)
.await
}
pub(crate) async fn start_server_with_timeout(
&self,
name: &str,
config: &McpServerConfig,
timeout: Duration,
) -> Result<Vec<McpToolSpec>> {
match &config.url {
Some(url) => info!("Starting MCP server: {} ({})", name, url),
None => info!(
"Starting MCP server: {} ({} {})",
name,
config.command,
crate::utils::redact_secrets(&config.args.join(" "))
),
}
let started = tokio::time::timeout(timeout, Self::start_one(name, config)).await;
let (client, tools) = match started {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
warn!("Failed to start MCP server '{}': {}", name, e);
return Err(e);
},
Err(_) => {
warn!(
"MCP server '{}' startup timed out after {}s",
name,
timeout.as_secs()
);
return Err(anyhow!("startup timed out after {}s", timeout.as_secs()));
},
};
let alias = self.alias_for(name);
let (specs, raw_tool_names) = sanitize::sanitize_server_tools(&alias, &tools);
info!(
"MCP server '{}' ready: {} tools ({})",
name,
specs.len(),
client
.server_info
.as_ref()
.map(|s| s.name.as_str())
.unwrap_or("?")
);
let runtime = ServerRuntime {
client: Arc::new(client),
raw_tool_names,
specs: specs.clone(),
};
if self.shutting_down.load(Ordering::Acquire) {
runtime.client.shutdown().await;
return Err(anyhow!("manager shut down during startup"));
}
self.inner
.write()
.expect("mcp registry lock poisoned")
.insert(name.to_string(), runtime);
Ok(specs)
}
async fn start_one(
name: &str,
config: &McpServerConfig,
) -> Result<(McpClient, Vec<McpToolDef>)> {
let transport: Transport = match config.transport_kind()? {
TransportKind::Stdio => {
StdioTransport::spawn(&config.command, &config.args, &config.env)
.await?
.into()
},
TransportKind::Http => HttpTransport::new(config)?.into(),
};
let mut client = McpClient::new(transport);
client
.initialize()
.await
.map_err(|e| anyhow!("MCP server '{}' initialization failed: {}", name, e))?;
let tools = client
.list_tools()
.await
.map_err(|e| anyhow!("MCP server '{}' tool discovery failed: {}", name, e))?;
Ok((client, tools))
}
pub fn all_specs(&self) -> Vec<(String, McpToolSpec)> {
let guard = self.inner.read().expect("mcp registry lock poisoned");
let mut out: Vec<(String, McpToolSpec)> = guard
.iter()
.flat_map(|(name, rt)| rt.specs.iter().map(|s| (name.clone(), s.clone())))
.collect();
out.sort_by(|a, b| {
(a.0.as_str(), a.1.name.as_str()).cmp(&(b.0.as_str(), b.1.name.as_str()))
});
out
}
pub fn has_server(&self, name: &str) -> bool {
let guard = self.inner.read().expect("mcp registry lock poisoned");
guard.contains_key(name)
|| self
.aliases
.get(name)
.is_some_and(|raw| guard.contains_key(raw))
}
pub fn has_servers(&self) -> bool {
!self
.inner
.read()
.expect("mcp registry lock poisoned")
.is_empty()
}
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: &serde_json::Value,
) -> Result<McpToolResult> {
let (client, raw_tool) = {
let guard = self.inner.read().expect("mcp registry lock poisoned");
let (raw_server, runtime) = match guard.get_key_value(server) {
Some(hit) => hit,
None => {
let raw = self.aliases.get(server).ok_or_else(|| {
anyhow!("MCP server '{}' not found or not running", server)
})?;
guard.get_key_value(raw.as_str()).ok_or_else(|| {
anyhow!("MCP server '{}' not found or not running", server)
})?
},
};
let alias = self.alias_for(raw_server);
let advertised = format!("mcp__{alias}__{tool}");
let raw_tool = runtime
.raw_tool_names
.get(&advertised)
.cloned()
.unwrap_or_else(|| tool.to_string());
(Arc::clone(&runtime.client), raw_tool)
};
if client.is_shutdown() {
return Err(anyhow!("MCP server '{}' has been stopped", server));
}
client.call_tool(&raw_tool, arguments).await
}
pub fn format_tool_result(result: &McpToolResult) -> (String, Option<Vec<String>>) {
let mut text_parts = Vec::new();
let mut images = Vec::new();
for block in &result.content {
match block {
ContentBlock::Text(text) => text_parts.push(text.clone()),
ContentBlock::Image { data, .. } => images.push(data.clone()),
ContentBlock::Audio { data, mime_type } => {
images.push(data.clone());
text_parts.push(format!("[audio attachment: {}]", mime_type));
},
ContentBlock::ResourceLink {
uri,
name,
description,
mime_type,
} => {
let label = name.as_deref().unwrap_or(uri.as_str());
let desc = description.as_deref().unwrap_or("");
let mime = mime_type.as_deref().unwrap_or("");
text_parts.push(format!(
"[resource link: {} ({}) — {} → {}]",
label, mime, desc, uri
));
},
ContentBlock::Resource {
uri,
mime_type,
text,
blob,
} => {
let mime = mime_type.as_deref().unwrap_or("");
if let Some(t) = text {
text_parts.push(format!("[resource {}]:\n{}", uri, t));
} else if let Some(b) = blob {
text_parts.push(format!(
"[resource {} ({}): {} bytes of base64]",
uri,
mime,
b.len()
));
} else {
text_parts.push(format!("[resource {} ({})]", uri, mime));
}
},
}
}
let text = if text_parts.is_empty() {
if result.is_error {
"MCP tool returned an error with no message".to_string()
} else {
"MCP tool returned no text content".to_string()
}
} else {
text_parts.join("\n")
};
let images = if images.is_empty() {
None
} else {
Some(images)
};
(text, images)
}
pub async fn shutdown(&self) {
self.shutting_down.store(true, Ordering::Release);
let clients: Vec<(String, Arc<McpClient>)> = {
let guard = self.inner.read().expect("mcp registry lock poisoned");
guard
.iter()
.map(|(name, rt)| (name.clone(), Arc::clone(&rt.client)))
.collect()
};
for (name, client) in clients {
info!("Shutting down MCP server: {}", name);
client.shutdown().await;
}
}
pub async fn stop_server(&self, name: &str) -> bool {
let client = {
let guard = self.inner.read().expect("mcp registry lock poisoned");
let runtime = guard.get(name).or_else(|| {
self.aliases
.get(name)
.and_then(|raw| guard.get(raw.as_str()))
});
runtime.map(|rt| Arc::clone(&rt.client))
};
match client {
Some(client) => {
info!("Stopping MCP server: {}", name);
client.shutdown().await;
true
},
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stop_unknown_server_returns_false() {
let mgr = McpServerManager::new(&HashMap::new());
assert!(!mgr.has_servers());
assert!(!mgr.stop_server("does-not-exist").await);
}
#[test]
fn aliases_assigned_from_sorted_config_keys() {
let mut configs = HashMap::new();
configs.insert("my.server".to_string(), McpServerConfig::default());
configs.insert("plain".to_string(), McpServerConfig::default());
let mgr = McpServerManager::new(&configs);
assert_eq!(mgr.alias_for("my.server"), "my_server");
assert_eq!(mgr.alias_for("plain"), "plain");
assert_eq!(mgr.alias_for("un known"), "un_known");
}
#[cfg(unix)]
#[tokio::test]
async fn startup_timeout_reports_timed_out() {
let config = McpServerConfig {
command: "sleep".to_string(),
args: vec!["5".to_string()],
..Default::default()
};
let mut configs = HashMap::new();
configs.insert("sleepy".to_string(), config.clone());
let mgr = McpServerManager::new(&configs);
let err = mgr
.start_server_with_timeout("sleepy", &config, Duration::from_millis(200))
.await
.expect_err("must time out");
assert!(err.to_string().contains("timed out"), "{err}");
assert!(!mgr.has_server("sleepy"));
}
#[tokio::test]
async fn http_server_starts_and_lists_tools() {
use super::super::transport_http::test_fixture::{fixture, json_reply, status_reply};
let init_result = r#"{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fx","version":"1.0"}}"#;
let tools_result =
r#"{"tools":[{"name":"echo","description":"echoes","inputSchema":{"type":"object"}}]}"#;
let fx = fixture(vec![
json_reply(&format!(
r#"{{"jsonrpc":"2.0","id":1,"result":{init_result}}}"#
)),
status_reply(202, "Accepted"),
json_reply(&format!(
r#"{{"jsonrpc":"2.0","id":2,"result":{tools_result}}}"#
)),
])
.await;
let config = fx.config();
let mut configs = HashMap::new();
configs.insert("remote".to_string(), config.clone());
let mgr = McpServerManager::new(&configs);
let specs = mgr
.start_server_with_timeout("remote", &config, Duration::from_secs(30))
.await
.expect("http server must start");
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].name, "mcp__remote__echo");
assert!(mgr.has_server("remote"));
}
#[tokio::test]
async fn config_with_both_command_and_url_errors() {
let config = McpServerConfig {
command: "npx".to_string(),
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
let mut configs = HashMap::new();
configs.insert("conflicted".to_string(), config.clone());
let mgr = McpServerManager::new(&configs);
let err = mgr
.start_server_with_timeout("conflicted", &config, Duration::from_secs(5))
.await
.expect_err("must reject");
assert!(err.to_string().contains("mutually exclusive"), "{err}");
assert!(!mgr.has_server("conflicted"));
}
#[cfg(unix)]
#[tokio::test]
async fn straggler_insert_after_shutdown_is_reaped() {
let mgr = McpServerManager::new(&HashMap::new());
mgr.shutdown().await;
assert!(mgr.shutting_down.load(Ordering::Acquire));
assert!(!mgr.has_servers());
}
}