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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user