Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions src/agents/mcp/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import functools
import inspect
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Optional, Protocol, Union
Expand All @@ -11,8 +12,10 @@
from ..logger import logger
from ..run_context import RunContextWrapper
from ..strict_schema import ensure_strict_json_schema
from ..tool import FunctionTool, Tool
from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span
from ..tool import FunctionTool, Tool, default_tool_error_function
from ..tool_context import ToolContext
from ..tracing import FunctionSpanData, SpanError, get_current_span, mcp_tools_span
from ..util import _error_tracing
from ..util._types import MaybeAwaitable

if TYPE_CHECKING:
Expand Down Expand Up @@ -156,7 +159,7 @@ def to_function_tool(
cls, tool: "MCPTool", server: "MCPServer", convert_schemas_to_strict: bool
) -> FunctionTool:
"""Convert an MCP tool to an Agents SDK function tool."""
invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool)
invoke_func_impl = functools.partial(cls.invoke_mcp_tool, server, tool)
schema, is_strict = tool.inputSchema, False

# MCP spec doesn't require the inputSchema to have `properties`, but OpenAI spec does.
Expand All @@ -170,6 +173,40 @@ def to_function_tool(
except Exception as e:
logger.info(f"Error converting MCP schema to strict mode: {e}")

# Wrap the invoke function with error handling, similar to regular function tools.
# This ensures that MCP tool errors (like timeouts) are handled gracefully instead
# of halting the entire agent flow.
async def invoke_func(ctx: ToolContext[Any], input_json: str) -> str:
try:
return await invoke_func_impl(ctx, input_json)
except Exception as e:
# Use default error handling function to convert exception to error message.
result = default_tool_error_function(ctx, e)
if inspect.isawaitable(result):
result = await result

# Attach error to tracing span.
_error_tracing.attach_error_to_current_span(
SpanError(
message="Error running tool (non-fatal)",
data={
"tool_name": tool.name,
"error": str(e),
},
)
)

# Log the error.
if _debug.DONT_LOG_TOOL_DATA:
logger.debug(f"MCP tool {tool.name} failed")
else:
logger.error(
f"MCP tool {tool.name} failed: {input_json} {e}",
exc_info=e,
)

return result

return FunctionTool(
name=tool.name,
description=tool.description or "",
Expand Down
88 changes: 87 additions & 1 deletion tests/mcp/test_mcp_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from mcp.types import CallToolResult, TextContent, Tool as MCPTool
from pydantic import BaseModel, TypeAdapter

from agents import Agent, FunctionTool, RunContextWrapper
from agents import Agent, FunctionTool, RunContextWrapper, default_tool_error_function
from agents.exceptions import AgentsException, ModelBehaviorError
from agents.mcp import MCPServer, MCPUtil
from agents.tool_context import ToolContext

from .helpers import FakeMCPServer

Expand Down Expand Up @@ -130,6 +131,91 @@ async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixtur
assert "Error invoking MCP tool test_tool_1" in caplog.text


@pytest.mark.asyncio
async def test_mcp_tool_graceful_error_handling(caplog: pytest.LogCaptureFixture):
"""Test that MCP tool errors are handled gracefully when invoked via FunctionTool.

When an MCP tool is created via to_function_tool and then invoked, errors should be
caught and converted to error messages instead of raising exceptions. This allows
the agent to continue running after tool failures.
"""
caplog.set_level(logging.DEBUG)

# Create a server that will crash when calling a tool
server = CrashingFakeMCPServer()
server.add_tool("crashing_tool", {})

# Convert MCP tool to FunctionTool (this wraps invoke_mcp_tool with error handling)
mcp_tool = MCPTool(name="crashing_tool", inputSchema={})
function_tool = MCPUtil.to_function_tool(mcp_tool, server, convert_schemas_to_strict=False)

# Create tool context
tool_context = ToolContext(
context=None,
tool_name="crashing_tool",
tool_call_id="test_call_1",
tool_arguments="{}",
)

# Invoke the tool - should NOT raise an exception, but return an error message
result = await function_tool.on_invoke_tool(tool_context, "{}")

# Verify that the result is an error message (not an exception)
assert isinstance(result, str)
assert "error" in result.lower() or "occurred" in result.lower()

# Verify that the error message matches what default_tool_error_function would return
# The error gets wrapped in AgentsException by invoke_mcp_tool, so we check for that format
wrapped_error = AgentsException("Error invoking MCP tool crashing_tool: Crash!")
expected_error_msg = default_tool_error_function(tool_context, wrapped_error)
assert result == expected_error_msg

# Verify that the error was logged
assert (
"MCP tool crashing_tool failed" in caplog.text or "Error invoking MCP tool" in caplog.text
)


@pytest.mark.asyncio
async def test_mcp_tool_timeout_handling():
"""Test that MCP tool timeouts are handled gracefully.

This simulates a timeout scenario where the MCP server call_tool raises a timeout error.
The error should be caught and converted to an error message instead of halting the agent.
"""

class TimeoutFakeMCPServer(FakeMCPServer):
async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None):
# Simulate a timeout error - this would normally be wrapped in AgentsException
# by invoke_mcp_tool
raise Exception(
"Timed out while waiting for response to ClientRequest. Waited 1.0 seconds."
)

server = TimeoutFakeMCPServer()
server.add_tool("timeout_tool", {})

# Convert MCP tool to FunctionTool
mcp_tool = MCPTool(name="timeout_tool", inputSchema={})
function_tool = MCPUtil.to_function_tool(mcp_tool, server, convert_schemas_to_strict=False)

# Create tool context
tool_context = ToolContext(
context=None,
tool_name="timeout_tool",
tool_call_id="test_call_2",
tool_arguments="{}",
)

# Invoke the tool - should NOT raise an exception
result = await function_tool.on_invoke_tool(tool_context, "{}")

# Verify that the result is an error message
assert isinstance(result, str)
assert "error" in result.lower() or "occurred" in result.lower()
assert "Timed out" in result


@pytest.mark.asyncio
async def test_agent_convert_schemas_true():
"""Test that setting convert_schemas_to_strict to True converts non-strict schemas to strict.
Expand Down