use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, RwLock,
};
use rho_sdk::{
model::ToolSpec,
tool::{
PreparedToolInvocation, Tool, ToolError, ToolErrorKind, ToolInvocation, ToolOutput,
ToolPreparationContext, ToolPrepareFuture, ToolProgressSender, ToolSecurity,
},
CancellationToken,
};
use rmcp::{
model::{CallToolRequest, CallToolRequestParams, ClientRequest, ServerResult},
service::{PeerRequestOptions, RequestHandle, ServiceError, ServiceRole},
Peer, RoleClient,
};
use super::{
config::McpTransport,
definition::McpToolDefinition,
inflight::McpInFlightCalls,
progress::McpProgressRouter,
result::{self, RenderedResult},
};
pub(super) const MCP_TOOL_CALL_BUDGET: std::time::Duration = std::time::Duration::from_secs(120);
#[derive(Clone, Debug)]
pub(super) struct CallBudget {
deadline: Arc<Mutex<tokio::time::Instant>>,
}
impl CallBudget {
pub(super) fn new(budget: std::time::Duration) -> Self {
Self {
deadline: Arc::new(Mutex::new(tokio::time::Instant::now() + budget)),
}
}
async fn expired(&self) {
loop {
let deadline = self.deadline();
tokio::time::sleep_until(deadline).await;
if tokio::time::Instant::now() >= self.deadline() {
return;
}
}
}
fn extend(&self, by: std::time::Duration) {
let mut deadline = self.lock();
*deadline += by;
}
fn deadline(&self) -> tokio::time::Instant {
*self.lock()
}
fn lock(&self) -> std::sync::MutexGuard<'_, tokio::time::Instant> {
self.deadline
.lock()
.unwrap_or_else(|error| error.into_inner())
}
}
const CANCEL_REASON: &str = "Rho cancelled the turn";
#[derive(Debug)]
pub(super) struct McpToolSlot {
definition: RwLock<McpToolDefinition>,
available: AtomicBool,
}
impl McpToolSlot {
pub(super) fn new(definition: McpToolDefinition) -> Self {
Self {
definition: RwLock::new(definition),
available: AtomicBool::new(true),
}
}
pub(super) fn definition(&self) -> McpToolDefinition {
self.read().clone()
}
pub(super) fn refresh(&self, definition: McpToolDefinition) -> bool {
self.available.store(true, Ordering::Relaxed);
let mut current = self.write();
if *current == definition {
return false;
}
*current = definition;
true
}
pub(super) fn withdraw(&self) {
self.available.store(false, Ordering::Relaxed);
}
fn is_available(&self) -> bool {
self.available.load(Ordering::Relaxed)
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, McpToolDefinition> {
self.definition
.read()
.unwrap_or_else(|error| error.into_inner())
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, McpToolDefinition> {
self.definition
.write()
.unwrap_or_else(|error| error.into_inner())
}
}
pub(super) struct McpTool {
pub(super) slot: Arc<McpToolSlot>,
pub(super) identity: String,
pub(super) remote_name: String,
pub(super) peer: Peer<RoleClient>,
pub(super) progress: McpProgressRouter,
pub(super) calls: McpInFlightCalls,
pub(super) transport: McpTransport,
pub(super) max_output_bytes: usize,
}
impl Tool for McpTool {
fn spec(&self) -> ToolSpec {
self.slot.definition().spec
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([])
}
fn prepare<'a>(
&'a self,
invocation: ToolInvocation,
_context: ToolPreparationContext,
) -> ToolPrepareFuture<'a> {
let arguments = invocation.into_arguments();
Box::pin(async move {
if !self.slot.is_available() {
return Err(ToolError::new(
ToolErrorKind::Execution,
format!(
"MCP server `{}` withdrew tool `{}`; restart the session to refresh its tools",
self.identity, self.remote_name
),
));
}
let Some(arguments) = arguments.as_object().cloned() else {
return Err(ToolError::new(
ToolErrorKind::InvalidArguments,
"MCP tool arguments must be a JSON object",
));
};
let definition = self.slot.definition();
let metadata = definition.presentation.metadata(&self.transport);
Ok(PreparedToolInvocation::resource_aware(
[],
[],
metadata.clone(),
move |context| {
Box::pin(async move {
let (_registration, questions) = self.calls.register();
let budget = CallBudget::new(MCP_TOOL_CALL_BUDGET);
let call = call_remote_tool(
McpCall {
peer: &self.peer,
progress: &self.progress,
budget: &budget,
remote_name: self.remote_name.clone(),
arguments,
expectation: definition.expectation,
},
context.cancellation(),
Some(context.progress().clone()),
self.max_output_bytes,
);
let service = serve_caller_questions(questions, &context, &budget);
tokio::pin!(call, service);
let rendered = tokio::select! {
result = &mut call => result?,
never = &mut service => match never {},
};
let mut metadata = metadata;
for asset in rendered.assets {
metadata = metadata.asset(asset);
}
Ok(ToolOutput::text(rendered.text).metadata(metadata))
})
},
))
})
}
}
async fn serve_caller_questions(
mut questions: tokio::sync::mpsc::Receiver<super::inflight::McpUserQuestion>,
context: &rho_sdk::tool::AuthorizedToolContext,
budget: &CallBudget,
) -> std::convert::Infallible {
loop {
let Some(question) = questions.recv().await else {
std::future::pending::<()>().await;
continue;
};
let started = tokio::time::Instant::now();
let answer = context.request_host_input(question.request).await;
budget.extend(started.elapsed());
let _ = question.reply.send(answer);
}
}
pub(super) struct McpCall<'a> {
pub(super) peer: &'a Peer<RoleClient>,
pub(super) progress: &'a McpProgressRouter,
pub(super) budget: &'a CallBudget,
pub(super) remote_name: String,
pub(super) arguments: serde_json::Map<String, serde_json::Value>,
pub(super) expectation: super::result::ResultExpectation,
}
pub(super) async fn call_remote_tool(
call: McpCall<'_>,
cancellation: &CancellationToken,
progress_sender: Option<ToolProgressSender>,
max_output_bytes: usize,
) -> Result<RenderedResult, ToolError> {
let McpCall {
peer,
progress,
budget,
remote_name,
arguments,
expectation,
} = call;
let params = CallToolRequestParams::new(remote_name).with_arguments(arguments);
let mut handle = peer
.send_cancellable_request(
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
PeerRequestOptions::no_options(),
)
.await
.map_err(execution_error)?;
let _subscription =
progress_sender.map(|sender| progress.subscribe(handle.progress_token.clone(), sender));
let outcome = tokio::select! {
response = &mut handle.rx => CallOutcome::Answered(response),
() = cancellation.cancelled() => CallOutcome::Cancelled,
() = budget.expired() => CallOutcome::TimedOut,
};
let response = match outcome {
CallOutcome::Answered(response) => response,
CallOutcome::Cancelled => {
cancel_handle(handle).await;
return Err(ToolError::cancelled());
}
CallOutcome::TimedOut => {
cancel_handle(handle).await;
return Err(ToolError::new(
ToolErrorKind::Execution,
format!(
"MCP tool call exceeded its {}s budget",
MCP_TOOL_CALL_BUDGET.as_secs()
),
));
}
};
match response {
Ok(Ok(ServerResult::CallToolResult(result))) => {
result::render(&result, &expectation, max_output_bytes)
}
Ok(Ok(_)) => Err(ToolError::new(
ToolErrorKind::Execution,
"MCP server answered tools/call with an unexpected result",
)),
Ok(Err(error)) => Err(execution_error(error)),
Err(_) => Err(ToolError::new(
ToolErrorKind::Execution,
"MCP session closed before the tool call returned",
)),
}
}
enum CallOutcome<T> {
Answered(T),
Cancelled,
TimedOut,
}
fn execution_error(error: ServiceError) -> ToolError {
ToolError::new(ToolErrorKind::Execution, error.to_string())
}
async fn cancel_handle<R: ServiceRole>(handle: RequestHandle<R>) {
if let Err(error) = handle.cancel(Some(CANCEL_REASON.into())).await {
tracing::debug!(error = %error, "could not notify MCP server of cancellation");
}
}
pub(super) fn namespaced_tool_name(server: &str, tool: &str) -> String {
fn component(value: &str) -> String {
const ESCAPE_PREFIX: &str = "_rho_";
let already_safe = value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if already_safe && !value.starts_with(ESCAPE_PREFIX) && !value.contains("__") {
return value.to_string();
}
let mut encoded = String::with_capacity(ESCAPE_PREFIX.len() + value.len() * 2);
encoded.push_str(ESCAPE_PREFIX);
const HEX: &[u8; 16] = b"0123456789abcdef";
for byte in value.bytes() {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
format!("mcp__{}__{}", component(server), component(tool))
}