Skip to content

Commit 2e1d608

Browse files
fix: #879 return McpError as a structured error result instead of crashing the agent run (#2598)
1 parent 8d3aa15 commit 2e1d608

File tree

2 files changed

+72
-0
lines changed

2 files changed

+72
-0
lines changed

src/agents/mcp/util.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313

1414
from .. import _debug
1515
from ..exceptions import AgentsException, ModelBehaviorError, UserError
16+
17+
try:
18+
from mcp.shared.exceptions import McpError as _McpError
19+
except ImportError: # pragma: no cover – mcp is optional on Python < 3.10
20+
_McpError = None # type: ignore[assignment, misc]
1621
from ..logger import logger
1722
from ..run_context import RunContextWrapper
1823
from ..strict_schema import ensure_strict_json_schema
@@ -360,6 +365,19 @@ async def invoke_mcp_tool(
360365
# Re-raise UserError as-is (it already has a good message)
361366
raise
362367
except Exception as e:
368+
if _McpError is not None and isinstance(e, _McpError):
369+
# An MCP-level error (e.g. upstream HTTP 4xx/5xx, tool not found, etc.)
370+
# is not a programming error – re-raise so the FunctionTool failure
371+
# pipeline (failure_error_function) can handle it. The default handler
372+
# will surface the message as a structured error result; callers who set
373+
# failure_error_function=None will have the error raised as documented.
374+
error_text = e.error.message if hasattr(e, "error") and e.error else str(e)
375+
logger.warning(
376+
f"MCP tool {tool.name} on server '{server.name}' returned an error: "
377+
f"{error_text}"
378+
)
379+
raise
380+
363381
logger.error(f"Error invoking MCP tool {tool.name} on server '{server.name}': {e}")
364382
raise AgentsException(
365383
f"Error invoking MCP tool {tool.name} on server '{server.name}': {e}"

tests/mcp/test_mcp_util.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,60 @@ async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixtur
192192
assert "Error invoking MCP tool test_tool_1" in caplog.text
193193

194194

195+
@pytest.mark.asyncio
196+
async def test_mcp_invocation_mcp_error_reraises(caplog: pytest.LogCaptureFixture):
197+
"""Test that McpError from server.call_tool is re-raised so the FunctionTool failure
198+
pipeline (failure_error_function) can handle it.
199+
200+
When an MCP server raises McpError (e.g. upstream HTTP 4xx/5xx), invoke_mcp_tool
201+
re-raises so the configured failure_error_function shapes the model-visible error.
202+
With the default failure_error_function the FunctionTool returns a string error
203+
result; with failure_error_function=None the error is propagated to the caller.
204+
"""
205+
caplog.set_level(logging.DEBUG)
206+
207+
from mcp.shared.exceptions import McpError
208+
from mcp.types import ErrorData
209+
210+
class McpErrorFakeMCPServer(FakeMCPServer):
211+
async def call_tool(
212+
self,
213+
tool_name: str,
214+
arguments: dict[str, Any] | None,
215+
meta: dict[str, Any] | None = None,
216+
):
217+
raise McpError(ErrorData(code=-32000, message="upstream 422 Unprocessable Entity"))
218+
219+
server = McpErrorFakeMCPServer()
220+
server.add_tool("search", {})
221+
222+
ctx = RunContextWrapper(context=None)
223+
tool = MCPTool(name="search", inputSchema={})
224+
225+
# invoke_mcp_tool itself should re-raise McpError
226+
with pytest.raises(McpError):
227+
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
228+
229+
# Warning (not error) should be logged before re-raising
230+
assert "returned an error" in caplog.text
231+
232+
# Via FunctionTool with default failure_error_function: error becomes a string result
233+
mcp_tool = MCPTool(name="search", inputSchema={})
234+
agent = Agent(name="test-agent")
235+
function_tool = MCPUtil.to_function_tool(
236+
mcp_tool, server, convert_schemas_to_strict=False, agent=agent
237+
)
238+
tool_context = ToolContext(
239+
context=None,
240+
tool_name="search",
241+
tool_call_id="test_call_mcp_error",
242+
tool_arguments="{}",
243+
)
244+
result = await function_tool.on_invoke_tool(tool_context, "{}")
245+
assert isinstance(result, str)
246+
assert "upstream 422 Unprocessable Entity" in result or "error" in result.lower()
247+
248+
195249
@pytest.mark.asyncio
196250
async def test_mcp_tool_graceful_error_handling(caplog: pytest.LogCaptureFixture):
197251
"""Test that MCP tool errors are handled gracefully when invoked via FunctionTool.

0 commit comments

Comments
 (0)