fix(mcp): an abandoned tool call tells the server to stop

`try_call_tool` wrapped `call_tool` in a bare `tokio::time::timeout`.
On a timeout or a cancelled turn the future dropped and the server kept
working: no `notifications/cancelled` ever left the client.

`call_tool_cancel_aware` sends the request through
`send_cancellable_request` and holds a guard armed with the request id.
A settled call disarms it. A dropped future (cancelled turn) spawns the
cancel from the guard's drop. A timeout sends the cancel inline, before
returning: the HTTP timeout arm resets the transport next, and dropping
the last service handle killed a spawned cancel before it left.

The inline send waits 500 ms at most. rmcp 2.2.0 awaits its own
timeout-cancel without a bound, and its streamable-HTTP worker awaits
each POST in turn, so against a JSON-mode server the cancel queues
behind the hung call: with rmcp's timeout path the call returned after
3.0 s against a 1 s budget. That server class cannot receive the cancel
until rmcp 3.x; the budget elapses, a warn is logged, and AGENTS.md
records the limitation. stdio and SSE-mode servers receive it.

Tests: dropped call, timed-out call followed by a service drop, settled
call; the HTTP outer-timeout test now asserts the return time.

Ported from upstream (grok-build mcp/servers.rs call_tool_cancel_aware,
1.0.33); upstream leans on rmcp 3.2's timeout path.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-19 02:07:30 -04:00
1 parent 3ccb6e5e91
commit be0983237e
3 files changed
+267 -29

No files matched your search

+35 -29
View File
@@ -32,6 +32,9 @@ use rmcp::{
use crate::oauth_config::McpOAuthConfig;
mod cancel_aware;
use cancel_aware::call_tool_cancel_aware;
use kigi_tools::types::{
output::{MCPOutput, MCPOutputDetails, ToolOutput},
tool::{ToolKind, ToolNamespace},
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
let mut params = CallToolRequestParams::new(self.tool.name.clone());
params.arguments = raw.as_object().cloned();
let result =
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
match result {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(service_err))
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
Err(service_err)
if should_recover_service_error(
&service_err,
client.is_http(),
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
)
.await
}
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
e.to_string(),
)),
Err(_) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
}
}
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
));
}
};
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
Err(_) => {
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
),
))
}
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
}
}
}
@@ -6223,11 +6223,17 @@ mod tests {
let mut reconnect = false;
let mut is_timeout = false;
let raw = serde_json::json!({});
let started = std::time::Instant::now();
let err = tool
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
.await
.expect_err("call must time out");
assert!(
started.elapsed() < std::time::Duration::from_millis(2500),
"cancel must not wait out the 3s hang: {:?}",
started.elapsed()
);
assert!(err.to_string().contains("timed out"), "got: {err}");
assert!(is_timeout, "is_timeout must be set");
assert!(reconnect, "timeout arm flags the reconnect after resetting");