From 6443504ece880d93362e22788c954b072cec33db Mon Sep 17 00:00:00 2001 From: habema Date: Wed, 31 Dec 2025 16:18:28 +0300 Subject: [PATCH] Enhance MCPUtil tool invokation to match `tool.py:812-839` --- src/agents/mcp/util.py | 43 +++++++++++++++++-- tests/mcp/test_mcp_util.py | 88 +++++++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 6cfe5c96d..e96f22e6d 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -1,4 +1,5 @@ import functools +import inspect import json from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Optional, Protocol, Union @@ -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: @@ -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. @@ -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 "", diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index e434f7542..3ca410edd 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -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 @@ -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.