Cancel MCP tool requests when response futures are dropped (#56132)

## Why

Dropping a local tool-call future did not notify the MCP server that the request was cancelled, leaving remote work running.

## What changed

- Send a cancellation notification for the request when its response future is dropped, covering both ordinary tool calls and tool-input rounds.
- Preserve write order for stdio and in-process transports so cancellation cannot arrive before its tool request, without waiting for tool responses.

## Testing

Add regression tests for cancellation on drop, request-before-cancellation ordering under reversed send polling and exhausted task budgets, and continued use of the shared connection after cancellation.

GitOrigin-RevId: fab02d546fd82c601756117d9ecbf1017c28ac11
This commit is contained in:
rhan-oai
2026-10-22 09:57:51 +00:00
committed by copyberry
parent c260087efe
commit 2dbe6bfa44
7 changed files with 299 additions and 36 deletions
+2
View File
@@ -23,6 +23,7 @@ mod oauth_client_credentials;
mod oauth_client_registration;
mod oauth_http_client;
mod oauth_refresh_mode;
mod ordered_byte_transport;
mod perform_oauth_login;
mod program_resolver;
mod protocol_mode;
@@ -32,6 +33,7 @@ mod startup_error;
mod stdio_server_launcher;
mod tool_input;
mod tool_list_changes;
mod tool_request;
mod trace_context;
mod user_verification;
mod utils;
@@ -0,0 +1,65 @@
//! Preserve byte-stream message order even when rmcp polls send futures out of order.
//! Only wrap transports whose send completes after writing, without awaiting a response.
use std::future::Future;
use std::io;
use std::sync::Arc;
use futures::FutureExt;
use rmcp::RoleClient;
use rmcp::service::RxJsonRpcMessage;
use rmcp::service::TxJsonRpcMessage;
use rmcp::transport::Transport;
use tokio::sync::Semaphore;
pub(crate) struct OrderedByteTransport<T> {
inner: T,
writes: Arc<Semaphore>,
}
impl<T> OrderedByteTransport<T> {
pub(crate) fn new(inner: T) -> Self {
Self {
inner,
writes: Arc::new(Semaphore::new(1)),
}
}
}
impl<T: Transport<RoleClient, Error = io::Error>> Transport<RoleClient>
for OrderedByteTransport<T>
{
type Error = io::Error;
fn send(
&mut self,
item: TxJsonRpcMessage<RoleClient>,
) -> impl Future<Output = Result<(), Self::Error>> + Send + 'static {
// Reserve FIFO order now, before rmcp independently schedules each send.
// Ignore cooperative budgeting for this one poll so it always queues.
let mut acquire = Box::pin(Arc::clone(&self.writes).acquire_owned());
let acquired = tokio::task::unconstrained(acquire.as_mut()).now_or_never();
let send = self.inner.send(item);
async move {
let _permit = match acquired {
Some(result) => result,
None => acquire.await,
}
.map_err(io::Error::other)?;
// Serialize only writes, never the corresponding tool responses.
send.await
}
}
async fn receive(&mut self) -> Option<RxJsonRpcMessage<RoleClient>> {
self.inner.receive().await
}
async fn close(&mut self) -> Result<(), Self::Error> {
self.inner.close().await
}
}
#[cfg(test)]
#[path = "ordered_byte_transport_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
//! Exercise write ordering independently of rmcp's send-task scheduling.
use super::OrderedByteTransport;
use rmcp::RoleClient;
use rmcp::RoleServer;
use rmcp::model::CallToolRequest;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CancelledNotification;
use rmcp::model::CancelledNotificationParam;
use rmcp::model::ClientJsonRpcMessage;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::RequestId;
use rmcp::transport::IntoTransport;
use rmcp::transport::Transport;
use std::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn cancellation_cannot_overtake_request() -> anyhow::Result<()> {
let (client_io, server_io) = tokio::io::duplex(/*max_buf_size*/ 4096);
let mut client =
OrderedByteTransport::new(IntoTransport::<RoleClient, _, _>::into_transport(client_io));
let mut server = IntoTransport::<RoleServer, _, _>::into_transport(server_io);
let request = ClientJsonRpcMessage::request(
ClientRequest::CallToolRequest(CallToolRequest::new(CallToolRequestParams::new("wait"))),
RequestId::Number(1),
);
let cancellation = ClientJsonRpcMessage::notification(
ClientNotification::CancelledNotification(CancelledNotification::new(
CancelledNotificationParam::new(Some(RequestId::Number(1)), None),
)),
);
// Exhaust the task budget: reserving write order must still happen synchronously.
while tokio::task::coop::has_budget_remaining() {
tokio::task::consume_budget().await;
}
let first = client.send(request.clone());
let discarded = client.send(request.clone());
let mut second = Box::pin(client.send(cancellation.clone()));
tokio::task::yield_now().await;
assert!(futures::poll!(second.as_mut()).is_pending());
drop(discarded);
assert!(futures::poll!(second.as_mut()).is_pending());
timeout(Duration::from_secs(5), first).await??;
timeout(Duration::from_secs(5), second).await??;
let received = [
timeout(Duration::from_secs(5), server.receive()).await?,
timeout(Duration::from_secs(5), server.receive()).await?,
];
pretty_assertions::assert_eq!(
serde_json::to_value(received)?,
serde_json::to_value([Some(request), Some(cancellation)])?
);
Ok(())
}
+15 -13
View File
@@ -60,6 +60,7 @@ use rmcp::service::RoleClient;
use rmcp::service::RunningService;
use rmcp::service::ServiceError;
use rmcp::transport::AuthorizationManager;
use rmcp::transport::IntoTransport;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::auth::AuthClient;
use rmcp::transport::auth::AuthError;
@@ -94,6 +95,7 @@ use crate::oauth::validate_refresh_token_issuer;
use crate::oauth_client_credentials::OAuthClientCredentials;
use crate::oauth_http_client::OAuthHttpClientAdapter;
use crate::oauth_refresh_mode::McpOAuthRefreshMode;
use crate::ordered_byte_transport::OrderedByteTransport;
use crate::protocol_mode::McpProtocolMode;
use crate::startup_error::is_authentication_required_error;
use crate::stdio_server_launcher::StdioServerCommand;
@@ -895,17 +897,9 @@ impl RmcpClient {
}
let mut options = rmcp::service::PeerRequestOptions::no_options();
options.meta = meta;
let result = service
.peer()
.send_request_with_option(
ClientRequest::CallToolRequest(rmcp::model::CallToolRequest::new(
rmcp_params,
)),
options,
)
.await?
.await_response()
.await?;
let (_, result) =
crate::tool_request::call_tool(service.peer(), rmcp_params, options)
.await?;
match result {
ServerResult::CallToolResult(result) => Ok(result),
_ => Err(rmcp::service::ServiceError::UnexpectedResponse),
@@ -1329,7 +1323,12 @@ impl RmcpClient {
PendingTransport::InProcess { transport } => (
client_service
.serve_with_lifecycle(
track_tool_list_changes(transport, Arc::clone(&self.tool_list_revision)),
track_tool_list_changes(
OrderedByteTransport::new(
IntoTransport::<RoleClient, _, _>::into_transport(transport),
),
Arc::clone(&self.tool_list_revision),
),
lifecycle,
)
.boxed(),
@@ -1338,7 +1337,10 @@ impl RmcpClient {
PendingTransport::Stdio { transport } => (
client_service
.serve_with_lifecycle(
track_tool_list_changes(*transport, Arc::clone(&self.tool_list_revision)),
track_tool_list_changes(
OrderedByteTransport::new(*transport),
Arc::clone(&self.tool_list_revision),
),
lifecycle,
)
.boxed(),
+5 -13
View File
@@ -6,10 +6,8 @@ use std::collections::BTreeMap;
use std::time::Duration;
use rmcp::RoleClient;
use rmcp::model::CallToolRequest;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::ClientRequest;
use rmcp::model::DEFAULT_MRTR_MAX_ROUNDS;
use rmcp::model::GetExtensions;
use rmcp::model::GetMeta;
@@ -43,17 +41,11 @@ pub(crate) async fn call_tool(
let mut state_only_rounds = 0;
let mut submitted_verification_proof = false;
for round in 0..DEFAULT_MRTR_MAX_ROUNDS {
let response = async {
let handle = service
.peer()
.send_request_with_option(
ClientRequest::CallToolRequest(CallToolRequest::new(params.clone())),
PeerRequestOptions::no_options(),
)
.await?;
let id = handle.id.clone();
Ok::<_, ServiceError>((id, handle.await_response().await?))
}
let response = crate::tool_request::call_tool(
service.peer(),
params.clone(),
PeerRequestOptions::no_options(),
)
.await;
let (id, result) = response.map_err(|error| {
if submitted_verification_proof
+49
View File
@@ -0,0 +1,49 @@
//! Cancel remote tool work when its local response future is dropped.
use rmcp::Peer;
use rmcp::RoleClient;
use rmcp::model::CallToolRequest;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CancelledNotificationParam;
use rmcp::model::ClientRequest;
use rmcp::model::RequestId;
use rmcp::model::ServerResult;
use rmcp::service::PeerRequestOptions;
use rmcp::service::ServiceError;
pub(crate) async fn call_tool(
peer: &Peer<RoleClient>,
params: CallToolRequestParams,
options: PeerRequestOptions,
) -> Result<(RequestId, ServerResult), ServiceError> {
let handle = peer
.send_request_with_option(
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
options,
)
.await?;
let id = handle.id.clone();
let mut cancellation = CancelOnDrop(Some((peer.clone(), id.clone())));
let result = handle.await_response().await;
cancellation.0 = None;
result.map(|result| (id, result))
}
struct CancelOnDrop(Option<(Peer<RoleClient>, RequestId)>);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
if let Some((peer, id)) = self.0.take()
&& let Ok(runtime) = tokio::runtime::Handle::try_current()
{
runtime.spawn(async move {
let _ = peer
.notify_cancelled(CancelledNotificationParam::new(
Some(id),
Some("tool call response future dropped".into()),
))
.await;
});
}
}
}
@@ -1,4 +1,4 @@
//! Ensures MCP cancellation releases pending elicitation routes and tool timeout pauses.
//! Ensures MCP cancellation stops remote tool work and releases pending elicitation routes.
use super::ElicitationClientService;
use super::ElicitationPauseState;
@@ -11,21 +11,24 @@ use pretty_assertions::assert_eq;
use rmcp::RoleServer;
use rmcp::ServerHandler;
use rmcp::ServiceExt;
use rmcp::model::CallToolResult;
use rmcp::model::CancelledNotification;
use rmcp::model::CancelledNotificationParam;
use rmcp::model::ClientConfig;
use rmcp::model::ClientJsonRpcMessage;
use rmcp::model::ClientNotification;
use rmcp::model::ClientRequest;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitationAction;
use rmcp::model::InitializeResult;
use rmcp::model::ProtocolVersion;
use rmcp::model::RequestId;
use rmcp::model::ServerJsonRpcMessage;
use rmcp::model::ServerNotification;
use rmcp::model::ServerRequest;
use rmcp::model::ServerResult;
use rmcp::service::PeerRequestOptions;
use rmcp::service::QuitReason;
use rmcp::service::RunningService;
use rmcp::service::ServerInitializeError;
use rmcp::service::serve_directly;
use rmcp::transport::IntoTransport;
use rmcp::transport::Transport;
@@ -42,9 +45,7 @@ struct ElicitationTestServer;
impl ServerHandler for ElicitationTestServer {}
struct ElicitationTestTransport {
servers: mpsc::UnboundedSender<
Result<RunningService<RoleServer, ElicitationTestServer>, ServerInitializeError>,
>,
servers: mpsc::UnboundedSender<tokio::io::DuplexStream>,
}
impl InProcessTransportFactory for ElicitationTestTransport {
@@ -52,14 +53,102 @@ impl InProcessTransportFactory for ElicitationTestTransport {
let servers = self.servers.clone();
Box::pin(async move {
let (client, server) = tokio::io::duplex(/*max_buf_size*/ 4096);
tokio::spawn(async move {
let _ = servers.send(ElicitationTestServer.serve(server).await);
});
servers.send(server).map_err(std::io::Error::other)?;
Ok(client)
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn dropping_tool_call_cancels_remote_request() -> anyhow::Result<()> {
// Run on the sole worker so independently spawned sends can be polled in reverse order.
tokio::spawn(async {
let (servers, mut server_rx) = mpsc::unbounded_channel();
let client = RmcpClient::new_in_process_client(Arc::new(ElicitationTestTransport { servers }))
.await?;
let mut server = IntoTransport::<RoleServer, _, _>::into_transport(server_rx.recv().await.unwrap());
let mut initialize = Box::pin(client.initialize(
ClientConfig::default().with_protocol_version(ProtocolVersion::V_2025_06_18),
Some(Duration::from_secs(5)),
Box::new(|_, _| Box::pin(async { anyhow::bail!("unexpected elicitation") })),
));
let request = tokio::select! {
result = &mut initialize => panic!("client initialized before handshake: {result:?}"),
request = timeout(Duration::from_secs(5), server.receive()) => request?,
};
let Some(ClientJsonRpcMessage::Request(request)) = request else {
anyhow::bail!("expected initialize request");
};
assert!(matches!(request.request, ClientRequest::InitializeRequest(_)));
server
.send(ServerJsonRpcMessage::response(
ServerResult::InitializeResult(
InitializeResult::default().with_protocol_version(ProtocolVersion::V_2025_06_18),
),
request.id,
))
.await?;
timeout(Duration::from_secs(5), initialize).await??;
let initialized = timeout(Duration::from_secs(5), server.receive()).await?;
assert!(matches!(
initialized,
Some(ClientJsonRpcMessage::Notification(notification))
if matches!(notification.notification, ClientNotification::InitializedNotification(_))
));
let mut call = Box::pin(client.call_tool(
"wait".into(),
/*arguments*/ None,
/*meta*/ None,
/*timeout*/ None,
));
// Drop before yielding to the transport's independently scheduled send tasks.
assert!(futures::poll!(call.as_mut()).is_pending());
drop(call);
let request = timeout(Duration::from_secs(5), server.receive()).await?;
let Some(ClientJsonRpcMessage::Request(request)) = request else {
anyhow::bail!("expected tool call before cancellation");
};
assert!(matches!(request.request, ClientRequest::CallToolRequest(_)));
let notification = timeout(Duration::from_secs(1), server.receive())
.await
.map_err(|_| anyhow::anyhow!("dropping the call did not cancel the server request"))?;
let Some(ClientJsonRpcMessage::Notification(notification)) = notification else {
anyhow::bail!("expected cancellation notification");
};
let ClientNotification::CancelledNotification(cancelled) = notification.notification else {
anyhow::bail!("expected cancellation notification");
};
assert_eq!(cancelled.params.request_id, Some(request.id));
// Cancellation applies to this request, not the shared MCP connection.
let mut next = Box::pin(client.call_tool(
"next".into(),
/*arguments*/ None,
/*meta*/ None,
/*timeout*/ None,
));
let request = tokio::select! {
result = &mut next => panic!("tool completed before response: {result:?}"),
request = timeout(Duration::from_secs(5), server.receive()) => request?,
};
let Some(ClientJsonRpcMessage::Request(request)) = request else {
anyhow::bail!("expected next tool call");
};
let result = CallToolResult::success(Vec::new());
server
.send(ServerJsonRpcMessage::response(
ServerResult::CallToolResult(result.clone()),
request.id,
))
.await?;
assert_eq!(timeout(Duration::from_secs(5), next).await??, result);
client.shutdown().await;
Ok(())
})
.await?
}
#[traced_test]
#[tokio::test]
async fn recovered_connections_accept_elicitations_with_previously_cancelled_ids()
@@ -77,7 +166,13 @@ async fn recovered_connections_accept_elicitations_with_previously_cancelled_ids
"elicitationId": "authorization",
}),
] {
let (servers, mut server_rx) = mpsc::unbounded_channel();
let (servers, mut transport_rx) = mpsc::unbounded_channel();
let (ready_tx, mut server_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(server) = transport_rx.recv().await {
let _ = ready_tx.send(ElicitationTestServer.serve(server).await);
}
});
let client =
RmcpClient::new_in_process_client(Arc::new(ElicitationTestTransport { servers }))
.await?;