use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_MCP_CALL_TIMEOUT: Duration = Duration::from_mins(1);
use rmcp::ServiceExt;
use rmcp::handler::server::ServerHandler;
use rmcp::service::RoleClient;
use rmcp::service::RunningService;
use rmcp::transport::IntoTransport;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolSchema};
mod convert;
pub mod server;
pub use server::McpServerAdapter;
const DUPLEX_BUFFER: usize = 4096;
#[derive(Clone, Debug)]
pub struct McpClient {
service: Arc<RunningService<RoleClient, ()>>,
reconnect_spec: Option<ReconnectSpec>,
}
impl McpClient {
pub async fn in_process<S>(server: S) -> Result<Self, McpError>
where
S: ServerHandler,
{
let (server_end, client_end) = tokio::io::duplex(DUPLEX_BUFFER);
tokio::spawn(async move {
match server.serve(server_end).await {
Ok(running) => {
let _ = running.waiting().await.ok();
}
Err(e) => tracing::error!(
error = %e,
"in-process MCP server failed to initialize (the client side reports this via McpError::Handshake)"
),
}
});
let client = ().serve(client_end).await.map_err(|e| McpError::Handshake(e.to_string()))?;
Ok(Self {
service: Arc::new(client),
reconnect_spec: None,
})
}
#[must_use]
pub fn from_service(service: RunningService<RoleClient, ()>) -> Self {
Self {
service: Arc::new(service),
reconnect_spec: None,
}
}
fn wrap(service: RunningService<RoleClient, ()>, spec: Option<ReconnectSpec>) -> Self {
Self {
service: Arc::new(service),
reconnect_spec: spec,
}
}
async fn call_tool_forward(
&self,
server_name: &str,
input: serde_json::Value,
) -> Result<ToolOutput, ToolError> {
let mut params = rmcp::model::CallToolRequestParams::new(server_name.to_string());
if let serde_json::Value::Object(map) = input {
params = params.with_arguments(map);
}
let result = self
.service
.call_tool(params)
.await
.map_err(|e| ToolError::Execution(format!("MCP tools/call failed: {e}")))?;
convert::bridge_result(server_name, result).map_err(|e| ToolError::Execution(e.to_string()))
}
pub async fn stdio(command: CommandSpec) -> Result<Self, McpError> {
let transport = TokioChildProcess::new(command.as_tokio_command())
.map_err(|e| McpError::Handshake(e.to_string()))?;
let service = ().serve(transport).await.map_err(|e| McpError::Handshake(e.to_string()))?;
Ok(Self::wrap(service, Some(ReconnectSpec::Stdio(command))))
}
pub async fn http_sse(endpoint: impl Into<Arc<str>>) -> Result<Self, McpError> {
let endpoint = endpoint.into();
let transport = StreamableHttpClientTransport::from_uri(Arc::clone(&endpoint));
Self::http_connect(transport, endpoint, None).await
}
pub async fn http_sse_with_client(
endpoint: impl Into<Arc<str>>,
client: reqwest::Client,
) -> Result<Self, McpError> {
let endpoint = endpoint.into();
let transport = StreamableHttpClientTransport::with_client(
client.clone(),
StreamableHttpClientTransportConfig::with_uri(Arc::clone(&endpoint)),
);
Self::http_connect(transport, endpoint, Some(client)).await
}
async fn http_connect<T, E, A>(
transport: T,
endpoint: Arc<str>,
client: Option<reqwest::Client>,
) -> Result<Self, McpError>
where
T: IntoTransport<RoleClient, E, A>,
E: std::error::Error + Send + Sync + 'static,
{
let service = ().serve(transport).await.map_err(|e| McpError::Handshake(e.to_string()))?;
Ok(Self::wrap(
service,
Some(ReconnectSpec::HttpSse { endpoint, client }),
))
}
pub async fn reconnect(
&self,
retry: &crate::stream::handler::StreamRetryConfig,
) -> Result<Self, McpError> {
let Some(spec) = self.reconnect_spec.clone() else {
return Err(McpError::Handshake(
"this McpClient cannot be reconnected (in-process)".to_string(),
));
};
let mut last_err: Option<McpError> = None;
for attempt in 0..=retry.max_retries {
if attempt > 0 {
let delay = retry.jittered_base_delay(attempt.saturating_sub(1));
tokio::time::sleep(delay).await;
}
match spec.connect().await {
Ok(client) => return Ok(client),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
McpError::Handshake("reconnect made no attempts (max_retries overflow)".to_string())
}))
}
}
#[derive(Clone, Debug, Default)]
pub struct CommandSpec {
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub cwd: Option<String>,
}
impl CommandSpec {
fn as_tokio_command(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(&self.program);
cmd.args(&self.args);
for (key, value) in &self.env {
cmd.env(key, value);
}
if let Some(cwd) = &self.cwd {
cmd.current_dir(cwd);
}
cmd
}
}
#[derive(Clone, Debug)]
enum ReconnectSpec {
Stdio(CommandSpec),
HttpSse {
endpoint: Arc<str>,
client: Option<reqwest::Client>,
},
}
impl ReconnectSpec {
async fn connect(&self) -> Result<McpClient, McpError> {
match self {
Self::Stdio(command) => McpClient::stdio(command.clone()).await,
Self::HttpSse { endpoint, client } => match client {
Some(client) => {
McpClient::http_sse_with_client(Arc::clone(endpoint), client.clone()).await
}
None => McpClient::http_sse(Arc::clone(endpoint)).await,
},
}
}
}
#[derive(Debug)]
pub struct McpToolProvider {
client: McpClient,
tools: Vec<McpTool>,
prefix: Option<String>,
call_timeout: Duration,
}
impl McpToolProvider {
pub async fn connect(client: McpClient, name_prefix: Option<String>) -> Result<Self, McpError> {
let mut tools = Vec::new();
bridge_tool_list(
&client,
name_prefix.as_deref(),
DEFAULT_MCP_CALL_TIMEOUT,
&mut tools,
)
.await?;
Ok(Self {
client,
tools,
prefix: name_prefix,
call_timeout: DEFAULT_MCP_CALL_TIMEOUT,
})
}
#[must_use]
pub fn with_call_timeout(mut self, timeout: Duration) -> Self {
self.call_timeout = timeout;
for tool in &mut self.tools {
tool.call_timeout = timeout;
}
self
}
pub async fn refresh(&mut self) -> Result<(), McpError> {
let mut tools = Vec::new();
bridge_tool_list(
&self.client,
self.prefix.as_deref(),
self.call_timeout,
&mut tools,
)
.await?;
self.tools = tools;
Ok(())
}
#[must_use]
pub fn tools(&self) -> &[McpTool] {
&self.tools
}
pub fn register_into(&self, registry: &mut ToolRegistry) {
for tool in &self.tools {
registry.register(tool.clone());
}
}
#[must_use]
pub fn client(&self) -> &McpClient {
&self.client
}
}
#[derive(Clone, Debug)]
pub struct McpTool {
server_name: String,
exposed_name: String,
description: String,
input_schema: serde_json::Value,
output_schema: Option<serde_json::Value>,
client: McpClient,
read_only_hint: bool,
destructive_hint: bool,
call_timeout: Duration,
}
impl McpTool {
#[must_use]
pub fn output_schema(&self) -> Option<&serde_json::Value> {
self.output_schema.as_ref()
}
#[must_use]
pub fn is_destructive_hint(&self) -> bool {
self.destructive_hint
}
}
impl Tool for McpTool {
fn name(&self) -> &str {
&self.exposed_name
}
fn description(&self) -> &str {
&self.description
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: self.exposed_name.clone(),
description: self.description.clone(),
input_schema: self.input_schema.clone(),
}
}
fn call(
&self,
input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
let client = self.client.clone();
let server_name = self.server_name.clone();
let exposed_name = self.exposed_name.clone();
let call_timeout = self.call_timeout;
Box::pin(async move {
match tokio::time::timeout(call_timeout, client.call_tool_forward(&server_name, input))
.await
{
Ok(result) => result,
Err(_) => Ok(ToolOutput::error_text(format!(
"MCP tool '{exposed_name}' timed out after {call_timeout:?} without a response"
))),
}
})
}
fn is_concurrency_safe(&self) -> bool {
false
}
fn is_read_only(&self) -> bool {
self.read_only_hint
}
}
#[derive(Debug, thiserror::Error)]
pub enum McpError {
#[error("MCP handshake/transport error: {0}")]
Handshake(String),
#[error("MCP protocol error: {0}")]
Protocol(String),
#[error("MCP tool '{0}' reported an error with no content")]
EmptyToolError(String),
}
async fn bridge_tool_list(
client: &McpClient,
prefix: Option<&str>,
call_timeout: Duration,
out: &mut Vec<McpTool>,
) -> Result<(), McpError> {
let server_tools = client
.service
.list_all_tools()
.await
.map_err(|e| McpError::Protocol(e.to_string()))?;
let mut seen = std::collections::HashSet::new();
for server_tool in server_tools {
let Some(adapted) = bridge_tool(&server_tool, prefix, client, call_timeout) else {
continue;
};
if !seen.insert(adapted.exposed_name.clone()) {
tracing::warn!(
tool = %adapted.exposed_name,
"duplicate MCP tool name after prefixing; keeping the first"
);
continue;
}
out.push(adapted);
}
Ok(())
}
fn bridge_tool(
server_tool: &rmcp::model::Tool,
prefix: Option<&str>,
client: &McpClient,
call_timeout: Duration,
) -> Option<McpTool> {
let server_name = server_tool.name.to_string();
if server_name.is_empty() {
tracing::warn!("MCP server declared a tool with an empty name; skipping");
return None;
}
let exposed_name =
prefix.map_or_else(|| server_name.clone(), |p| format!("{p}__{server_name}"));
let description = server_tool
.description
.as_deref()
.unwrap_or_default()
.to_string();
let input_schema = serde_json::Value::Object(server_tool.input_schema.as_ref().clone());
let output_schema = server_tool
.output_schema
.as_ref()
.map(|schema| serde_json::Value::Object(schema.as_ref().clone()));
let (read_only_hint, destructive_hint) =
server_tool
.annotations
.as_ref()
.map_or((false, true), |annotations| {
(
annotations.read_only_hint.unwrap_or(false),
annotations.destructive_hint.unwrap_or(true),
)
});
Some(McpTool {
server_name,
exposed_name,
description,
input_schema,
output_schema,
client: client.clone(),
read_only_hint,
destructive_hint,
call_timeout,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::ToolContent as MessageToolContent;
use crate::message::ToolContentPart;
use rmcp::model::{CallToolResult, ContentBlock};
#[test]
fn bridge_result_single_text_becomes_text_payload() {
let res = CallToolResult::success(vec![ContentBlock::text("hi")]);
let out = convert::bridge_result("t", res).expect("success bridges");
assert!(!out.is_error);
assert!(matches!(out.payload, MessageToolContent::Text(_)));
assert_eq!(out.text_content(), "hi");
}
#[test]
fn bridge_result_single_image_becomes_single_part_multipart() {
let res = CallToolResult::success(vec![ContentBlock::image("Zm9v", "image/png")]);
let out = convert::bridge_result("t", res).expect("success bridges");
assert!(!out.is_error);
match out.payload {
MessageToolContent::Multipart(parts) => {
assert_eq!(parts.len(), 1, "single image → one-element multipart");
assert!(matches!(parts.first(), Some(ToolContentPart::Image { .. })));
}
other @ MessageToolContent::Text(_) => {
panic!("expected Multipart, got {other:?}")
}
}
}
#[test]
fn bridge_result_multiple_blocks_become_multipart_in_order() {
let res = CallToolResult::success(vec![
ContentBlock::text("a"),
ContentBlock::image("Zg==", "image/jpeg"),
ContentBlock::text("b"),
]);
let out = convert::bridge_result("t", res).expect("success bridges");
let MessageToolContent::Multipart(parts) = out.payload else {
panic!("expected Multipart");
};
assert_eq!(parts.len(), 3);
assert!(matches!(parts.first(), Some(ToolContentPart::Text { text }) if text == "a"));
assert!(matches!(parts.get(1), Some(ToolContentPart::Image { .. })));
assert!(matches!(parts.get(2), Some(ToolContentPart::Text { text }) if text == "b"));
}
#[test]
fn bridge_result_soft_error_returns_ok_with_is_error() {
let res = CallToolResult::error(vec![ContentBlock::text("boom")]);
let out = convert::bridge_result("t", res).expect("soft error is Ok");
assert!(out.is_error);
assert_eq!(out.text_content(), "boom");
}
#[test]
fn bridge_result_empty_error_is_hard_empty_tool_error_with_name() {
let res = CallToolResult::error(vec![]);
let err = convert::bridge_result("search", res).expect_err("empty error is hard Err");
match err {
McpError::EmptyToolError(name) => assert_eq!(name, "search"),
other => panic!("expected EmptyToolError, got {other:?}"),
}
}
#[test]
fn bridge_result_empty_success_yields_empty_text_output() {
let res = CallToolResult::success(vec![]);
let out = convert::bridge_result("t", res).expect("empty success is Ok");
assert!(!out.is_error);
assert_eq!(out.text_content(), "");
}
#[test]
fn bridge_result_structured_content_appended_as_text_part() {
let mut res = CallToolResult::success(vec![ContentBlock::text("body")]);
res.structured_content = Some(serde_json::json!({"count": 7}));
let out = convert::bridge_result("t", res).expect("success bridges");
let MessageToolContent::Multipart(parts) = out.payload else {
panic!("text + structured must be multipart");
};
assert_eq!(parts.len(), 2);
let structured_text = &parts
.last()
.and_then(|p| match p {
ToolContentPart::Text { text } => Some(text.as_str()),
ToolContentPart::Image { .. } => None,
})
.expect("structured part is text");
assert!(
structured_text.contains("count"),
"carries the structured json"
);
assert!(structured_text.contains('7'));
}
#[test]
fn bridge_result_error_with_structured_content_is_soft_error() {
let mut res = CallToolResult::error(vec![]);
res.structured_content = Some(serde_json::json!({"reason": "denied"}));
let out = convert::bridge_result("search", res).expect("structured error is soft Ok");
assert!(out.is_error, "is_error flag set");
let text = out.text_content();
assert!(
text.contains("denied"),
"structured payload surfaces as the error text: {text}"
);
}
#[test]
fn bridge_content_unsupported_kinds_surface_as_text_notes() {
let audio = convert::bridge_content(&ContentBlock::audio("AAAA", "audio/wav"));
assert!(
matches!(audio, ToolContentPart::Text { .. }),
"audio → text note (not dropped), got {audio:?}"
);
let resource =
convert::bridge_content(&ContentBlock::Resource(rmcp::model::EmbeddedResource::new(
rmcp::model::ResourceContents::text("body", "mem://x"),
)));
assert!(
matches!(resource, ToolContentPart::Text { ref text } if text.contains("mem://x") && text.contains("body")),
"embedded text resource surfaces its uri and text, got {resource:?}"
);
let link = rmcp::model::Resource::new("file:///a", "thing");
let link_part = convert::bridge_content(&ContentBlock::ResourceLink(link));
assert!(
matches!(link_part, ToolContentPart::Text { ref text } if text.contains("thing") && text.contains("file:///a")),
"resource link surfaces name and uri, got {link_part:?}"
);
}
#[test]
fn bridge_content_text_and_image_carry_through() {
let text = convert::bridge_content(&ContentBlock::text("hello"));
assert!(
matches!(&text, ToolContentPart::Text { text } if text == "hello"),
"got {text:?}"
);
let image = convert::bridge_content(&ContentBlock::image("Zm9v", "image/png"));
match image {
ToolContentPart::Image { source } => {
assert_eq!(source.media_type, "image/png");
assert_eq!(source.data, "Zm9v");
}
other @ ToolContentPart::Text { .. } => {
panic!("expected Image, got {other:?}")
}
}
}
}