From 22dd2afa282211e3555b79273dbbbfbf1670d687 Mon Sep 17 00:00:00 2001 From: elainegan-openai <168589666+elainegan-openai@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:00:57 -0700 Subject: [PATCH] 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 --- src/agents/exceptions.py | 10 ++++++++++ src/agents/mcp/util.py | 9 +++++---- tests/mcp/test_mcp_util.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py index 01583282..f4ec379d 100644 --- a/src/agents/exceptions.py +++ b/src/agents/exceptions.py @@ -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.""" diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 49b875ab..f7b0b3be 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -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): diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 38db1df2..0c33a3d3 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -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."""