Normalize cancelled MCP invocations into tool errors (#2704)

## Summary
- convert inner MCP invocation cancellation into `UserError`
- preserve the normal function-tool failure path instead of leaking
cancellation out of `invoke_mcp_tool()`
- add focused regression coverage for cancellation through the
function-tool boundary

## Why
When that cancellation leaks out of `MCPUtil.invoke_mcp_tool()`, the
nested subagent can be cancelled instead of returning a normal tool
failure.

This PR contains only the invoke-layer normalization: if the inner MCP
invocation is cancelled, it becomes a normal tool error that the
existing function-tool error handling can surface to the model.

## Validation
- `ruff check src/agents/mcp/util.py tests/mcp/test_mcp_util.py`
- `uv run pytest -q tests/mcp/test_mcp_util.py -k 'cancellation or
crash_causes_error or graceful_error_handling'`
- `timeout 30 uv run mypy src/agents/mcp/util.py
tests/mcp/test_mcp_util.py`

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
elainegan-openai
2026-03-17 17:00:57 -07:00
committed by GitHub
parent 90009b2793
commit 22dd2afa28
3 changed files with 42 additions and 7 deletions
+10
View File
@@ -75,6 +75,16 @@ class UserError(AgentsException):
super().__init__(message)
class MCPToolCancellationError(AgentsException):
"""Exception raised when an MCP tool call is internally cancelled."""
message: str
def __init__(self, message: str):
self.message = message
super().__init__(message)
class ToolTimeoutError(AgentsException):
"""Exception raised when a function tool invocation exceeds its timeout."""
+5 -4
View File
@@ -14,7 +14,7 @@ from typing_extensions import NotRequired, TypedDict
from .. import _debug
from .._mcp_tool_metadata import resolve_mcp_tool_description_for_model, resolve_mcp_tool_title
from ..exceptions import AgentsException, ModelBehaviorError, UserError
from ..exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError, UserError
try:
from mcp.shared.exceptions import McpError as _McpError
@@ -369,7 +369,7 @@ class MCPUtil:
done, _ = await asyncio.wait({call_task}, return_when=asyncio.FIRST_COMPLETED)
finished_task = done.pop()
if finished_task.cancelled():
raise UserError(
raise MCPToolCancellationError(
f"Failed to call tool '{tool.name}' on MCP server '{server.name}': "
"tool execution was cancelled."
)
@@ -382,8 +382,9 @@ class MCPUtil:
except (asyncio.CancelledError, Exception):
pass
raise
except UserError:
# Re-raise UserError as-is (it already has a good message)
except (UserError, MCPToolCancellationError):
# Re-raise handled tool-call errors as-is; the FunctionTool failure pipeline
# will format them into model-visible tool errors when appropriate.
raise
except Exception as e:
if _McpError is not None and isinstance(e, _McpError):
+27 -3
View File
@@ -10,7 +10,7 @@ from mcp.types import CallToolResult, ImageContent, TextContent, Tool as MCPTool
from pydantic import BaseModel, TypeAdapter
from agents import Agent, FunctionTool, RunContextWrapper, default_tool_error_function
from agents.exceptions import AgentsException, ModelBehaviorError, UserError
from agents.exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError
from agents.mcp import MCPServer, MCPUtil
from agents.tool_context import ToolContext
@@ -241,7 +241,7 @@ async def test_mcp_tool_inner_cancellation_becomes_tool_error():
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="cancel_tool", inputSchema={})
with pytest.raises(UserError, match="tool execution was cancelled"):
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
agent = Agent(name="test-agent")
@@ -275,7 +275,7 @@ async def test_mcp_tool_inner_cancellation_still_becomes_tool_error_with_prior_c
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="cancel_tool", inputSchema={})
with pytest.raises(UserError, match="tool execution was cancelled"):
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
@@ -539,6 +539,30 @@ async def test_mcp_tool_timeout_handling():
assert "Timed out" in result
@pytest.mark.asyncio
async def test_mcp_tool_cancellation_returns_error_message():
server = CancelledFakeMCPServer()
server.add_tool("cancelled_tool", {})
mcp_tool = MCPTool(name="cancelled_tool", inputSchema={})
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
mcp_tool, server, convert_schemas_to_strict=False, agent=agent
)
tool_context = ToolContext(
context=None,
tool_name="cancelled_tool",
tool_call_id="test_call_cancelled",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
assert isinstance(result, str)
assert "cancelled" in result.lower()
@pytest.mark.asyncio
async def test_to_function_tool_legacy_call_without_agent_uses_server_policy():
"""Legacy three-argument to_function_tool calls should honor server policy."""