use std::future::Future;
use std::sync::Arc;
use rmcp::ErrorData;
use rmcp::ServiceExt;
use rmcp::handler::server::ServerHandler;
use rmcp::model::CallToolRequestParams;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ServerInfo;
use rmcp::model::ToolsCapability;
use rmcp::service::RequestContext;
use rmcp::service::RoleServer;
use crate::mcp::convert;
use crate::tool::Tool;
use crate::tool::ToolContext;
use crate::tool::ToolError;
use crate::tool::ToolOutput;
use crate::tool::ToolRegistry;
#[derive(Clone)]
pub struct McpServerAdapter {
registry: Arc<ToolRegistry>,
context: Arc<ToolContext>,
server_name: String,
server_version: String,
}
impl McpServerAdapter {
#[must_use]
pub fn new(
registry: ToolRegistry,
context: ToolContext,
server_name: String,
server_version: String,
) -> Self {
Self {
registry: Arc::new(registry),
context: Arc::new(context),
server_name,
server_version,
}
}
pub async fn serve_stdio(
self,
) -> Result<
rmcp::service::RunningService<RoleServer, Self>,
Box<rmcp::service::ServerInitializeError>,
> {
let transport = rmcp::transport::io::stdio();
self.serve(transport).await.map_err(Box::new)
}
}
async fn dispatch_guarded(
tool: &dyn Tool,
input: serde_json::Value,
context: &ToolContext,
ct: &tokio_util::sync::CancellationToken,
) -> Result<ToolOutput, ToolError> {
tokio::select! {
biased;
() = ct.cancelled() => Err(ToolError::Cancelled),
result = async { tool.call(input, context).await } => result,
}
}
impl ServerHandler for McpServerAdapter {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::default();
info.capabilities.tools = Some(ToolsCapability::default());
info.server_info.name.clone_from(&self.server_name);
info.server_info.version.clone_from(&self.server_version);
info
}
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<rmcp::model::ListToolsResult, ErrorData>> {
let tools = self
.registry
.all_schemas()
.into_iter()
.filter_map(|schema| {
let is_read_only = self
.registry
.get(&schema.tool)
.is_some_and(Tool::is_read_only);
convert::tool_schema_to_mcp(schema, is_read_only)
})
.collect();
std::future::ready(Ok(rmcp::model::ListToolsResult {
tools,
..Default::default()
}))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, ErrorData> {
let name = request.name.to_string();
let Some(tool) = self.registry.get(&name) else {
let names = self.registry.tool_names();
let available: Vec<&str> = names.iter().map(String::as_str).collect();
return Err(convert::not_found_error(&name, &available));
};
let input = request
.arguments
.map_or(serde_json::Value::Null, serde_json::Value::Object);
let result = dispatch_guarded(tool, input, &self.context, &context.ct).await;
Ok(convert::dispatch_result_to_call_tool(&name, result).into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolSchema};
use rmcp::service::PeerRequestOptions;
use serde_json::json;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &'static str {
"echo"
}
fn description(&self) -> &'static str {
"Echo"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "echo".into(),
description: "Echo".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
let msg = input
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
Box::pin(async move { Ok(ToolOutput::text(msg)) })
}
}
struct FailTool;
impl Tool for FailTool {
fn name(&self) -> &'static str {
"fail"
}
fn description(&self) -> &'static str {
"Always fails"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "fail".into(),
description: "Always fails".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
_input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
Box::pin(async { Err(ToolError::Execution("always fails".into())) })
}
}
struct PeekTool;
impl Tool for PeekTool {
fn name(&self) -> &'static str {
"peek"
}
fn description(&self) -> &'static str {
"Reads state without modifying it"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "peek".into(),
description: "Reads state without modifying it".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
_input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
Box::pin(async { Ok(ToolOutput::text("state")) })
}
fn is_read_only(&self) -> bool {
true
}
}
struct RecordingTool {
received: Arc<Mutex<Option<serde_json::Value>>>,
}
impl Tool for RecordingTool {
fn name(&self) -> &'static str {
"record"
}
fn description(&self) -> &'static str {
"Records the input value it received"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "record".into(),
description: "Records the input value it received".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
*self.received.lock().unwrap() = Some(input);
Box::pin(async { Ok(ToolOutput::text("recorded")) })
}
}
struct HangingTool {
started: Arc<tokio::sync::Notify>,
dropped: Arc<tokio::sync::Notify>,
}
impl Tool for HangingTool {
fn name(&self) -> &'static str {
"hanging"
}
fn description(&self) -> &'static str {
"Never completes"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "hanging".into(),
description: "Never completes".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
_input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
Box::pin(NeverCompletingCall {
started: Arc::clone(&self.started),
dropped: Arc::clone(&self.dropped),
})
}
}
struct NeverCompletingCall {
started: Arc<tokio::sync::Notify>,
dropped: Arc<tokio::sync::Notify>,
}
impl Future for NeverCompletingCall {
type Output = Result<ToolOutput, ToolError>;
fn poll(
self: Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
self.started.notify_one();
std::task::Poll::Pending
}
}
impl Drop for NeverCompletingCall {
fn drop(&mut self) {
self.dropped.notify_one();
}
}
struct CallCountingTool {
invoked: Arc<AtomicBool>,
}
impl Tool for CallCountingTool {
fn name(&self) -> &'static str {
"counted"
}
fn description(&self) -> &'static str {
"Records whether it was invoked"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "counted".into(),
description: "Records whether it was invoked".into(),
input_schema: json!({"type": "object"}),
}
}
fn call(
&self,
_input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
self.invoked.store(true, Ordering::SeqCst);
Box::pin(async { Ok(ToolOutput::text("ran")) })
}
}
fn echo_fail_adapter() -> McpServerAdapter {
let mut registry = ToolRegistry::new();
registry.register(EchoTool);
registry.register(FailTool);
McpServerAdapter::new(
registry,
ToolContext::default(),
"test".into(),
"0.0.1".into(),
)
}
async fn serve_and_connect(adapter: &McpServerAdapter) -> crate::mcp::McpClient {
let (server_end, client_end) = tokio::io::duplex(4096);
let server = adapter.clone();
tokio::spawn(async move {
let running = server.serve(server_end).await.expect("server serve");
let _ = running.waiting().await.ok();
});
().serve(client_end)
.await
.map(crate::mcp::McpClient::from_service)
.expect("client connect")
}
#[test]
fn get_info_advertises_tools_only() {
let adapter = echo_fail_adapter();
let info = adapter.get_info();
assert!(info.capabilities.tools.is_some());
assert!(info.capabilities.prompts.is_none());
assert!(info.capabilities.resources.is_none());
assert_eq!(info.server_info.name, "test");
assert_eq!(info.server_info.version, "0.0.1");
}
#[test]
fn adapter_is_clone_send_sync() {
fn _assert_clone_send_sync<T: Clone + Send + Sync>() {}
_assert_clone_send_sync::<McpServerAdapter>();
}
#[tokio::test]
async fn list_tools_serves_registry_via_mcp() {
let adapter = echo_fail_adapter();
let client = serve_and_connect(&adapter).await;
let tools = client
.service
.list_all_tools()
.await
.expect("list_all_tools");
drop(client);
let names: Vec<String> = tools.into_iter().map(|t| t.name.to_string()).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"echo".to_string()));
assert!(names.contains(&"fail".to_string()));
}
#[tokio::test]
async fn list_tools_forwards_read_only_hint() {
let mut registry = ToolRegistry::new();
registry.register(EchoTool);
registry.register(PeekTool);
let adapter = McpServerAdapter::new(
registry,
ToolContext::default(),
"test".into(),
"0.0.1".into(),
);
let client = serve_and_connect(&adapter).await;
let tools = client
.service
.list_all_tools()
.await
.expect("list_all_tools");
drop(client);
let peek = tools
.iter()
.find(|t| t.name.as_ref() == "peek")
.expect("peek is listed");
let annotations = peek
.annotations
.as_ref()
.expect("read-only hints forwarded");
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.destructive_hint, Some(false));
let echo = tools
.iter()
.find(|t| t.name.as_ref() == "echo")
.expect("echo is listed");
assert!(echo.annotations.is_none());
}
#[tokio::test]
async fn call_tool_round_trips_through_mcp() {
let adapter = echo_fail_adapter();
let client = serve_and_connect(&adapter).await;
let result = client
.service
.call_tool(
rmcp::model::CallToolRequestParams::new("echo")
.with_arguments(json!({"message": "hello"}).as_object().unwrap().clone()),
)
.await
.expect("call_tool");
drop(client);
let text = result
.content
.first()
.and_then(rmcp::model::ContentBlock::as_text)
.map(|t| t.text.as_str());
assert_eq!(text, Some("hello"));
}
#[tokio::test]
async fn call_tool_unknown_tool_is_a_method_not_found_protocol_error() {
let adapter = echo_fail_adapter();
let client = serve_and_connect(&adapter).await;
let err = client
.service
.call_tool(rmcp::model::CallToolRequestParams::new("grep"))
.await
.expect_err("unknown tool is a protocol error");
drop(client);
let msg = err.to_string();
assert!(
msg.contains("Tool not found") && msg.contains("grep"),
"error names the missing tool: {msg}"
);
assert!(msg.contains("echo"), "error lists available tools: {msg}");
}
#[tokio::test]
async fn call_tool_hard_tool_failure_is_a_tool_level_error_result() {
let adapter = echo_fail_adapter();
let client = serve_and_connect(&adapter).await;
let result = client
.service
.call_tool(rmcp::model::CallToolRequestParams::new("fail"))
.await
.expect("hard tool failure is Ok, not a protocol error");
drop(client);
assert_eq!(result.is_error, Some(true));
let text = result
.content
.first()
.and_then(rmcp::model::ContentBlock::as_text)
.map(|t| t.text.as_str());
assert_eq!(text, Some("Execution error: always fails"));
}
#[tokio::test]
async fn call_tool_without_arguments_passes_null_to_the_tool() {
let received = Arc::new(Mutex::new(None));
let mut registry = ToolRegistry::new();
registry.register(RecordingTool {
received: Arc::clone(&received),
});
let adapter = McpServerAdapter::new(
registry,
ToolContext::default(),
"test".into(),
"0.0.1".into(),
);
let client = serve_and_connect(&adapter).await;
let result = client
.service
.call_tool(rmcp::model::CallToolRequestParams::new("record"))
.await
.expect("argument-less call succeeds");
drop(client);
assert_eq!(result.is_error, Some(false));
assert_eq!(*received.lock().unwrap(), Some(serde_json::Value::Null));
}
#[tokio::test]
async fn call_tool_in_flight_call_is_cancelled_when_client_cancels() {
let started = Arc::new(tokio::sync::Notify::new());
let dropped = Arc::new(tokio::sync::Notify::new());
let mut registry = ToolRegistry::new();
registry.register(HangingTool {
started: Arc::clone(&started),
dropped: Arc::clone(&dropped),
});
let adapter = McpServerAdapter::new(
registry,
ToolContext::default(),
"test".into(),
"0.0.1".into(),
);
let client = serve_and_connect(&adapter).await;
let started_wait = started.notified();
let dropped_wait = dropped.notified();
tokio::pin!(started_wait, dropped_wait);
let request =
rmcp::model::CallToolRequest::new(rmcp::model::CallToolRequestParams::new("hanging"));
let handle = client
.service
.send_cancellable_request(
rmcp::model::ClientRequest::CallToolRequest(request),
PeerRequestOptions::no_options(),
)
.await
.expect("send request");
let guard = std::time::Duration::from_secs(10);
tokio::time::timeout(guard, started_wait)
.await
.expect("hanging tool was polled at least once");
handle
.cancel(Some("test cancellation".into()))
.await
.expect("cancel notification sent");
tokio::time::timeout(guard, dropped_wait)
.await
.expect("cancelled call's tool future was dropped");
}
#[tokio::test]
async fn dispatch_guarded_already_cancelled_token_never_invokes_the_tool() {
let invoked = Arc::new(AtomicBool::new(false));
let tool = CallCountingTool {
invoked: Arc::clone(&invoked),
};
let token = tokio_util::sync::CancellationToken::new();
token.cancel();
let result = dispatch_guarded(
&tool,
serde_json::Value::Null,
&ToolContext::default(),
&token,
)
.await;
assert!(
matches!(result, Err(ToolError::Cancelled)),
"already-cancelled request resolves to Cancelled, got {result:?}"
);
assert!(
!invoked.load(Ordering::SeqCst),
"already-cancelled request must not invoke the tool"
);
}
#[tokio::test]
async fn dispatch_guarded_live_token_invokes_the_tool() {
let invoked = Arc::new(AtomicBool::new(false));
let tool = CallCountingTool {
invoked: Arc::clone(&invoked),
};
let token = tokio_util::sync::CancellationToken::new();
let result = dispatch_guarded(
&tool,
serde_json::Value::Null,
&ToolContext::default(),
&token,
)
.await;
assert!(
result.is_ok(),
"live request dispatches normally: {result:?}"
);
assert!(
invoked.load(Ordering::SeqCst),
"live request must invoke the tool"
);
}
}