Skip to content

Commit 22dd2af

Browse files
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>
1 parent 90009b2 commit 22dd2af

3 files changed

Lines changed: 42 additions & 7 deletions

File tree

src/agents/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ def __init__(self, message: str):
7575
super().__init__(message)
7676

7777

78+
class MCPToolCancellationError(AgentsException):
79+
"""Exception raised when an MCP tool call is internally cancelled."""
80+
81+
message: str
82+
83+
def __init__(self, message: str):
84+
self.message = message
85+
super().__init__(message)
86+
87+
7888
class ToolTimeoutError(AgentsException):
7989
"""Exception raised when a function tool invocation exceeds its timeout."""
8090

src/agents/mcp/util.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from .. import _debug
1616
from .._mcp_tool_metadata import resolve_mcp_tool_description_for_model, resolve_mcp_tool_title
17-
from ..exceptions import AgentsException, ModelBehaviorError, UserError
17+
from ..exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError, UserError
1818

1919
try:
2020
from mcp.shared.exceptions import McpError as _McpError
@@ -369,7 +369,7 @@ async def invoke_mcp_tool(
369369
done, _ = await asyncio.wait({call_task}, return_when=asyncio.FIRST_COMPLETED)
370370
finished_task = done.pop()
371371
if finished_task.cancelled():
372-
raise UserError(
372+
raise MCPToolCancellationError(
373373
f"Failed to call tool '{tool.name}' on MCP server '{server.name}': "
374374
"tool execution was cancelled."
375375
)
@@ -382,8 +382,9 @@ async def invoke_mcp_tool(
382382
except (asyncio.CancelledError, Exception):
383383
pass
384384
raise
385-
except UserError:
386-
# Re-raise UserError as-is (it already has a good message)
385+
except (UserError, MCPToolCancellationError):
386+
# Re-raise handled tool-call errors as-is; the FunctionTool failure pipeline
387+
# will format them into model-visible tool errors when appropriate.
387388
raise
388389
except Exception as e:
389390
if _McpError is not None and isinstance(e, _McpError):

tests/mcp/test_mcp_util.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pydantic import BaseModel, TypeAdapter
1111

1212
from agents import Agent, FunctionTool, RunContextWrapper, default_tool_error_function
13-
from agents.exceptions import AgentsException, ModelBehaviorError, UserError
13+
from agents.exceptions import AgentsException, MCPToolCancellationError, ModelBehaviorError
1414
from agents.mcp import MCPServer, MCPUtil
1515
from agents.tool_context import ToolContext
1616

@@ -241,7 +241,7 @@ async def test_mcp_tool_inner_cancellation_becomes_tool_error():
241241
ctx = RunContextWrapper(context=None)
242242
tool = MCPTool(name="cancel_tool", inputSchema={})
243243

244-
with pytest.raises(UserError, match="tool execution was cancelled"):
244+
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
245245
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
246246

247247
agent = Agent(name="test-agent")
@@ -275,7 +275,7 @@ async def test_mcp_tool_inner_cancellation_still_becomes_tool_error_with_prior_c
275275
ctx = RunContextWrapper(context=None)
276276
tool = MCPTool(name="cancel_tool", inputSchema={})
277277

278-
with pytest.raises(UserError, match="tool execution was cancelled"):
278+
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
279279
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
280280

281281

@@ -539,6 +539,30 @@ async def call_tool(
539539
assert "Timed out" in result
540540

541541

542+
@pytest.mark.asyncio
543+
async def test_mcp_tool_cancellation_returns_error_message():
544+
server = CancelledFakeMCPServer()
545+
server.add_tool("cancelled_tool", {})
546+
547+
mcp_tool = MCPTool(name="cancelled_tool", inputSchema={})
548+
agent = Agent(name="test-agent")
549+
function_tool = MCPUtil.to_function_tool(
550+
mcp_tool, server, convert_schemas_to_strict=False, agent=agent
551+
)
552+
553+
tool_context = ToolContext(
554+
context=None,
555+
tool_name="cancelled_tool",
556+
tool_call_id="test_call_cancelled",
557+
tool_arguments="{}",
558+
)
559+
560+
result = await function_tool.on_invoke_tool(tool_context, "{}")
561+
562+
assert isinstance(result, str)
563+
assert "cancelled" in result.lower()
564+
565+
542566
@pytest.mark.asyncio
543567
async def test_to_function_tool_legacy_call_without_agent_uses_server_policy():
544568
"""Legacy three-argument to_function_tool calls should honor server policy."""

0 commit comments

Comments
 (0)