#![cfg(feature = "mcp-client")]
use crate::core::config::{McpClientConfig, McpServerConnection};
use rmcp::{
Peer, RoleClient, ServiceExt,
model::{
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Implementation,
ProtocolVersion, Tool,
},
service::RunningService,
transport::TokioChildProcess,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::process::Command;
use tracing::{debug, error, info};
#[derive(Debug, thiserror::Error)]
pub enum McpClientError {
#[error("failed to spawn MCP server '{server}': {source}")]
Spawn {
server: String,
#[source]
source: std::io::Error,
},
#[error("failed to connect to MCP server '{server}': {message}")]
Connect { server: String, message: String },
#[error("MCP server '{server}' is not connected")]
NotConnected { server: String },
#[error("tool '{tool}' on MCP server '{server}' failed: {message}")]
ToolCall {
server: String,
tool: String,
message: String,
},
}
#[derive(Clone)]
pub struct McpClientManager {
servers: Arc<tokio::sync::RwLock<HashMap<String, McpServerInfo>>>,
}
struct McpServerInfo {
_service: RunningService<RoleClient, ClientInfo>,
peer: Peer<RoleClient>,
tools: Vec<Tool>,
}
impl Default for McpClientManager {
fn default() -> Self {
Self::new()
}
}
impl McpClientManager {
pub fn new() -> Self {
Self {
servers: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
}
}
pub async fn connect(config: &McpClientConfig) -> Result<Self, McpClientError> {
let manager = Self::new();
manager.initialize(config).await?;
Ok(manager)
}
pub async fn initialize(&self, config: &McpClientConfig) -> Result<(), McpClientError> {
if !config.enabled {
info!("MCP client is disabled");
return Ok(());
}
info!(
"Initializing MCP client with {} server(s)",
config.servers.len()
);
let mut connected = 0usize;
let mut last_err = None;
for server_config in &config.servers {
match self.connect_to_server(server_config).await {
Ok(()) => {
connected += 1;
info!("Connected to MCP server '{}'", server_config.name);
}
Err(e) => {
error!(
"Failed to connect to MCP server '{}': {e}",
server_config.name
);
last_err = Some(e);
}
}
}
if connected == 0 && !config.servers.is_empty() {
return Err(last_err.expect("a non-empty server list reports a failure"));
}
Ok(())
}
async fn connect_to_server(&self, config: &McpServerConnection) -> Result<(), McpClientError> {
debug!("Connecting to MCP server '{}'", config.name);
debug!("Command: {} {:?}", config.command, config.args);
let mut cmd = Command::new(&config.command);
cmd.args(&config.args);
for (key, value) in &config.env {
cmd.env(key, value);
}
if let Some(ref cwd) = config.cwd {
cmd.current_dir(cwd);
}
let (transport, _stderr) =
TokioChildProcess::builder(cmd)
.spawn()
.map_err(|source| McpClientError::Spawn {
server: config.name.clone(),
source,
})?;
let implementation = Implementation::new(format!("a2a-agent-{}", config.name), "0.1.0");
let client_info = ClientInfo::new(ClientCapabilities::default(), implementation)
.with_protocol_version(ProtocolVersion::V_2024_11_05);
let service = client_info
.serve(transport)
.await
.map_err(|e| McpClientError::Connect {
server: config.name.clone(),
message: e.to_string(),
})?;
let peer = service.peer().clone();
debug!("Listing tools from MCP server '{}'", config.name);
let tools_result = peer
.list_tools(None)
.await
.map_err(|e| McpClientError::Connect {
server: config.name.clone(),
message: format!("failed to list tools: {e}"),
})?;
info!(
"MCP server '{}' exposes {} tool(s)",
config.name,
tools_result.tools.len()
);
for tool in &tools_result.tools {
let desc = tool
.description
.as_ref()
.map(|d| d.as_ref())
.unwrap_or("no description");
debug!(" - {} ({})", tool.name, desc);
}
let server_info = McpServerInfo {
_service: service,
peer,
tools: tools_result.tools,
};
self.servers
.write()
.await
.insert(config.name.clone(), server_info);
Ok(())
}
pub async fn call_tool(
&self,
server_name: &str,
tool_name: &str,
arguments: Option<serde_json::Value>,
) -> Result<CallToolResult, McpClientError> {
let servers = self.servers.read().await;
let server = servers
.get(server_name)
.ok_or_else(|| McpClientError::NotConnected {
server: server_name.to_string(),
})?;
debug!("Calling tool '{tool_name}' on MCP server '{server_name}'");
let args_map = arguments.and_then(|v| v.as_object().cloned());
let mut params = CallToolRequestParams::new(tool_name.to_string());
if let Some(map) = args_map {
params = params.with_arguments(map);
}
server
.peer
.call_tool(params)
.await
.map_err(|e| McpClientError::ToolCall {
server: server_name.to_string(),
tool: tool_name.to_string(),
message: e.to_string(),
})
}
pub async fn list_all_tools(&self) -> Vec<(String, Tool)> {
let servers = self.servers.read().await;
servers
.iter()
.flat_map(|(name, info)| info.tools.iter().map(move |t| (name.clone(), t.clone())))
.collect()
}
pub async fn list_server_tools(&self, server_name: &str) -> Option<Vec<Tool>> {
let servers = self.servers.read().await;
servers.get(server_name).map(|s| s.tools.clone())
}
pub async fn is_connected(&self, server_name: &str) -> bool {
self.servers.read().await.contains_key(server_name)
}
pub async fn connected_servers(&self) -> Vec<String> {
self.servers.read().await.keys().cloned().collect()
}
}