Files
2026-07-13 12:39:17 +08:00

2017 lines
68 KiB
Python

import asyncio
import dataclasses
import json
import logging
from typing import Any
from unittest.mock import patch
import pytest
from inline_snapshot import snapshot
from mcp.types import CallToolResult, ImageContent, TextContent, Tool as MCPTool
from pydantic import BaseModel, TypeAdapter
import agents._debug as _debug
from agents import (
Agent,
FunctionTool,
Handoff,
RunContextWrapper,
default_tool_error_function,
handoff,
)
from agents.exceptions import (
AgentsException,
MCPToolCancellationError,
ModelBehaviorError,
UserError,
)
from agents.mcp import MCPServer, MCPUtil
from agents.tool_context import ToolContext
from .helpers import FakeMCPServer
class Foo(BaseModel):
bar: str
baz: int
class Bar(BaseModel):
qux: dict[str, str]
Baz = TypeAdapter(dict[str, str])
def _convertible_schema() -> dict[str, Any]:
schema = Foo.model_json_schema()
schema["additionalProperties"] = False
return schema
@pytest.mark.asyncio
async def test_get_all_function_tools():
"""Test that the get_all_function_tools function returns all function tools from a list of MCP
servers.
"""
names = ["test_tool_1", "test_tool_2", "test_tool_3", "test_tool_4", "test_tool_5"]
schemas = [
{},
{},
{},
Foo.model_json_schema(),
Bar.model_json_schema(),
]
server1 = FakeMCPServer()
server1.add_tool(names[0], schemas[0])
server1.add_tool(names[1], schemas[1])
server2 = FakeMCPServer()
server2.add_tool(names[2], schemas[2])
server2.add_tool(names[3], schemas[3])
server3 = FakeMCPServer()
server3.add_tool(names[4], schemas[4])
servers: list[MCPServer] = [server1, server2, server3]
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(servers, False, run_context, agent)
assert len(tools) == 5
assert all(tool.name in names for tool in tools)
for idx, tool in enumerate(tools):
assert isinstance(tool, FunctionTool)
if schemas[idx] == {}:
assert tool.params_json_schema == snapshot({"properties": {}})
else:
assert tool.params_json_schema == schemas[idx]
assert tool.name == names[idx]
# Also make sure it works with strict schemas
tools = await MCPUtil.get_all_function_tools(servers, True, run_context, agent)
assert len(tools) == 5
assert all(tool.name in names for tool in tools)
@pytest.mark.asyncio
async def test_get_all_function_tools_duplicate_error_is_deterministic():
server1 = FakeMCPServer(server_name="server_1")
server1.add_tool("zeta", {})
server1.add_tool("alpha", {})
server2 = FakeMCPServer(server_name="server_2")
server2.add_tool("alpha", {})
server2.add_tool("zeta", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
with pytest.raises(UserError) as exc_info:
await MCPUtil.get_all_function_tools([server1, server2], False, run_context, agent)
assert str(exc_info.value) == (
"Duplicate tool names found across MCP servers: alpha, zeta. "
"Pass `include_server_in_tool_names=True` to "
"`MCPUtil.get_all_function_tools()` or set "
"`mcp_config={'include_server_in_tool_names': True}` on the "
"agent to prefix tool names with their server name and avoid "
"collisions."
)
@pytest.mark.asyncio
async def test_get_all_function_tools_duplicate_error_without_hint_when_prefixed():
"""When include_server_in_tool_names is already enabled, duplicates should
not suggest enabling the same option again.
"""
server1 = FakeMCPServer(server_name="server_1")
server1.add_tool("alpha", {})
server2 = FakeMCPServer(server_name="server_2")
server2.add_tool("beta", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
def _return_colliding_names(server_tool_batches, *, reserved_names):
return {(0, 0): "mcp_same__tool", (1, 0): "mcp_same__tool"}
with patch.object(
MCPUtil, "_build_prefixed_tool_name_overrides", side_effect=_return_colliding_names
):
with pytest.raises(UserError) as exc_info:
await MCPUtil.get_all_function_tools(
[server1, server2],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
assert str(exc_info.value) == "Duplicate tool names found across MCP servers: mcp_same__tool"
@pytest.mark.asyncio
async def test_get_all_function_tools_can_prefix_server_tool_names():
captured_meta_context: dict[str, Any] = {}
def resolve_meta(context):
captured_meta_context["server_name"] = context.server_name
captured_meta_context["tool_name"] = context.tool_name
return None
server1 = FakeMCPServer(server_name="docs")
server1.add_tool("search", {})
server1.add_tool("fetch", {})
server2 = FakeMCPServer(server_name="calendar", tool_meta_resolver=resolve_meta)
server2.add_tool("search", {})
server2.add_tool("update", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server1, server2],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
tool_names = [tool.name for tool in tools]
assert tool_names == [
"mcp_docs__search",
"mcp_docs__fetch",
"mcp_calendar__search",
"mcp_calendar__update",
]
calendar_search_tool = tools[2]
assert isinstance(calendar_search_tool, FunctionTool)
assert calendar_search_tool._tool_origin is not None
assert calendar_search_tool._tool_origin.mcp_server_name == "calendar"
tool_context = ToolContext(
context=None,
tool_name=calendar_search_tool.name,
tool_call_id="call_calendar_search",
tool_arguments="{}",
)
await calendar_search_tool.on_invoke_tool(tool_context, "{}")
assert server1.tool_calls == []
assert server2.tool_calls == ["search"]
assert captured_meta_context == {"server_name": "calendar", "tool_name": "search"}
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_non_ascii_server_names_safely():
server = FakeMCPServer(server_name="天気サーバー")
server.add_tool("search", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
assert len(tools) == 1
assert tools[0].name == "mcp_server__search"
assert all(char.isascii() and (char.isalnum() or char in {"_", "-"}) for char in tools[0].name)
assert len(tools[0].name) <= 64
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_non_ascii_tool_names_safely():
server = FakeMCPServer(server_name="docs")
server.add_tool("検索", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
assert len(tools) == 1
tool = tools[0]
assert isinstance(tool, FunctionTool)
assert tool.name == "mcp_docs__tool"
assert all(char.isascii() and (char.isalnum() or char in {"_", "-"}) for char in tool.name)
assert len(tool.name) <= 64
tool_context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call_non_ascii_tool",
tool_arguments="{}",
)
await tool.on_invoke_tool(tool_context, "{}")
assert server.tool_calls == ["検索"]
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_long_names_with_deterministic_hashes():
long_server_name = "server_" + ("a" * 100)
long_tool_name = "tool_" + ("b" * 100)
server1 = FakeMCPServer(server_name=long_server_name)
server1.add_tool(long_tool_name, {})
server2 = FakeMCPServer(server_name=long_server_name)
server2.add_tool(long_tool_name, {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server1, server2],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
tool_names = [tool.name for tool in tools]
assert len(tool_names) == 2
assert len(set(tool_names)) == 2
assert all(len(name) <= 64 for name in tool_names)
assert all(
char.isascii() and (char.isalnum() or char in {"_", "-"})
for name in tool_names
for char in name
)
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_normalized_server_name_collisions():
servers: list[MCPServer] = []
for server_name in ["foo", "foo!", "foo_0beec7b5"]:
server = FakeMCPServer(server_name=server_name)
server.add_tool("create_issue", {})
servers.append(server)
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
servers,
False,
run_context,
agent,
include_server_in_tool_names=True,
)
tool_names = [tool.name for tool in tools]
assert len(tool_names) == 3
assert len(set(tool_names)) == 3
assert "mcp_foo__create_issue" not in tool_names
assert "mcp_foo_0beec7b5__create_issue" in tool_names
assert sum(name.startswith("mcp_foo__create_issue_") for name in tool_names) == 2
assert all(len(name) <= 64 for name in tool_names)
assert all(
char.isascii() and (char.isalnum() or char in {"_", "-"})
for name in tool_names
for char in name
)
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_normalized_tool_collisions_stably():
async def public_names_by_original_tool(tool_names: list[str]) -> dict[str, str]:
server = FakeMCPServer(server_name="docs")
for tool_name in tool_names:
server.add_tool(tool_name, {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server],
False,
run_context,
agent,
include_server_in_tool_names=True,
)
return {
original_tool.name: public_tool.name
for original_tool, public_tool in zip(server.tools, tools, strict=False)
}
first_order = await public_names_by_original_tool(["search", "search!"])
reversed_order = await public_names_by_original_tool(["search!", "search"])
assert first_order == reversed_order
assert set(first_order) == {"search", "search!"}
assert "mcp_docs__search" not in first_order.values()
assert len(set(first_order.values())) == 2
assert all(name.startswith("mcp_docs__search_") for name in first_order.values())
assert all(len(name) <= 64 for name in first_order.values())
@pytest.mark.asyncio
async def test_get_all_function_tools_prefixes_normalized_server_collisions_stably():
async def public_names_by_server(server_names: list[str]) -> dict[str, str]:
servers: list[MCPServer] = []
for server_name in server_names:
server = FakeMCPServer(server_name=server_name)
server.add_tool("create_issue", {})
servers.append(server)
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
servers,
False,
run_context,
agent,
include_server_in_tool_names=True,
)
return {
server.name: public_tool.name
for server, public_tool in zip(servers, tools, strict=False)
}
first_order = await public_names_by_server(["foo", "foo!"])
reversed_order = await public_names_by_server(["foo!", "foo"])
assert first_order == reversed_order
assert set(first_order) == {"foo", "foo!"}
assert "mcp_foo__create_issue" not in first_order.values()
assert len(set(first_order.values())) == 2
assert all(name.startswith("mcp_foo__create_issue_") for name in first_order.values())
assert all(len(name) <= 64 for name in first_order.values())
@pytest.mark.asyncio
async def test_get_all_function_tools_reserves_existing_tool_names_when_prefixing():
server = FakeMCPServer(server_name="docs")
server.add_tool("search", {})
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools(
[server],
False,
run_context,
agent,
include_server_in_tool_names=True,
reserved_tool_names={"mcp_docs__search"},
)
assert len(tools) == 1
tool = tools[0]
assert isinstance(tool, FunctionTool)
assert tool.name != "mcp_docs__search"
assert tool.name.startswith("mcp_docs__search_")
assert len(tool.name) <= 64
tool_context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call_reserved_name",
tool_arguments="{}",
)
await tool.on_invoke_tool(tool_context, "{}")
assert server.tool_calls == ["search"]
@pytest.mark.asyncio
async def test_agent_get_mcp_tools_reserves_handoff_tool_names_when_prefixing():
server = FakeMCPServer(server_name="calendar")
server.add_tool("search", {})
handoff_agent = Agent(name="calendar_agent", instructions="Calendar agent")
agent = Agent(
name="test_agent",
instructions="Test agent",
handoffs=[handoff(handoff_agent, tool_name_override="mcp_calendar__search")],
mcp_servers=[server],
mcp_config={"include_server_in_tool_names": True},
)
tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
assert len(tools) == 1
tool = tools[0]
assert isinstance(tool, FunctionTool)
assert tool.name != "mcp_calendar__search"
assert tool.name.startswith("mcp_calendar__search_")
assert len(tool.name) <= 64
tool_context = ToolContext(
context=None,
tool_name=tool.name,
tool_call_id="call_handoff_reserved_name",
tool_arguments="{}",
)
await tool.on_invoke_tool(tool_context, "{}")
assert server.tool_calls == ["search"]
@pytest.mark.asyncio
async def test_agent_get_mcp_tools_reserves_plain_agent_handoff_names_when_prefixing():
handoff_agent = Agent(name="calendar_agent", instructions="Calendar agent")
agent = Agent(
name="test_agent",
instructions="Test agent",
handoffs=[handoff_agent],
mcp_config={"include_server_in_tool_names": True},
)
reserved_names = await agent._get_mcp_tool_reserved_names(RunContextWrapper(context=None))
assert Handoff.default_tool_name(handoff_agent) in reserved_names
@pytest.mark.asyncio
async def test_agent_get_mcp_tools_ignores_disabled_handoff_tool_names_when_prefixing():
server = FakeMCPServer(server_name="calendar")
server.add_tool("search", {})
handoff_agent = Agent(name="calendar_agent", instructions="Calendar agent")
agent = Agent(
name="test_agent",
instructions="Test agent",
handoffs=[
handoff(
handoff_agent,
tool_name_override="mcp_calendar__search",
is_enabled=False,
)
],
mcp_servers=[server],
mcp_config={"include_server_in_tool_names": True},
)
tools = await agent.get_mcp_tools(RunContextWrapper(context=None))
assert len(tools) == 1
assert tools[0].name == "mcp_calendar__search"
@pytest.mark.asyncio
async def test_invoke_mcp_tool():
"""Test that the invoke_mcp_tool function invokes an MCP tool and returns the result."""
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
# Just making sure it doesn't crash
@pytest.mark.asyncio
async def test_mcp_meta_resolver_merges_and_passes():
captured: dict[str, Any] = {}
def resolve_meta(context):
captured["run_context"] = context.run_context
captured["server_name"] = context.server_name
captured["tool_name"] = context.tool_name
captured["arguments"] = context.arguments
return {"request_id": "req-123", "locale": "ja"}
server = FakeMCPServer(tool_meta_resolver=resolve_meta)
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context={"request_id": "req-123"})
tool = MCPTool(name="test_tool_1", inputSchema={})
await MCPUtil.invoke_mcp_tool(
server,
tool,
ctx,
"{}",
meta={"locale": "en", "extra": "value"},
)
assert server.tool_metas[-1] == {"request_id": "req-123", "locale": "en", "extra": "value"}
assert captured["run_context"] is ctx
assert captured["server_name"] == server.name
assert captured["tool_name"] == "test_tool_1"
assert captured["arguments"] == {}
@pytest.mark.asyncio
async def test_mcp_meta_resolver_does_not_mutate_arguments():
def resolve_meta(context):
if context.arguments is not None:
context.arguments["mutated"] = "yes"
return {"meta": "ok"}
server = FakeMCPServer(tool_meta_resolver=resolve_meta)
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
await MCPUtil.invoke_mcp_tool(server, tool, ctx, '{"foo": "bar"}')
result = server.tool_results[-1]
prefix = f"result_{tool.name}_"
assert result.startswith(prefix)
args = json.loads(result[len(prefix) :])
assert args == {"foo": "bar"}
@pytest.mark.asyncio
async def test_to_function_tool_passes_static_mcp_meta():
server = FakeMCPServer()
tool = MCPTool(
name="test_tool_1",
inputSchema={},
_meta={"locale": "en", "extra": "value"},
)
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
tool_context = ToolContext(
context=None,
tool_name="test_tool_1",
tool_call_id="test_call_static_meta",
tool_arguments="{}",
)
await function_tool.on_invoke_tool(tool_context, "{}")
assert server.tool_metas[-1] == {"locale": "en", "extra": "value"}
@pytest.mark.asyncio
async def test_to_function_tool_merges_static_mcp_meta_with_resolver():
captured: dict[str, Any] = {}
def resolve_meta(context):
captured["run_context"] = context.run_context
captured["server_name"] = context.server_name
captured["tool_name"] = context.tool_name
captured["arguments"] = context.arguments
return {"request_id": "req-123", "locale": "ja"}
server = FakeMCPServer(tool_meta_resolver=resolve_meta)
tool = MCPTool(
name="test_tool_1",
inputSchema={},
_meta={"locale": "en", "extra": "value"},
)
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
tool_context = ToolContext(
context={"request_id": "req-123"},
tool_name="test_tool_1",
tool_call_id="test_call_static_meta_with_resolver",
tool_arguments="{}",
)
await function_tool.on_invoke_tool(tool_context, "{}")
assert server.tool_metas[-1] == {"request_id": "req-123", "locale": "en", "extra": "value"}
assert captured["server_name"] == server.name
assert captured["tool_name"] == "test_tool_1"
assert captured["arguments"] == {}
@pytest.mark.asyncio
async def test_to_function_tool_does_not_reuse_nested_static_mcp_meta():
class MutatingMetaServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
if meta is not None:
meta["nested"]["headers"].append("mutated")
return await super().call_tool(tool_name, arguments, meta=meta)
server = MutatingMetaServer()
tool = MCPTool(
name="test_tool_1",
inputSchema={},
_meta={"nested": {"headers": ["original"]}},
)
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
tool_context = ToolContext(
context=None,
tool_name="test_tool_1",
tool_call_id="test_call_static_meta",
tool_arguments="{}",
)
await function_tool.on_invoke_tool(tool_context, "{}")
await function_tool.on_invoke_tool(tool_context, "{}")
assert server.tool_metas[0] == {"nested": {"headers": ["original", "mutated"]}}
assert server.tool_metas[1] == {"nested": {"headers": ["original", "mutated"]}}
@pytest.mark.asyncio
async def test_mcp_invoke_bad_json_errors(caplog: pytest.LogCaptureFixture):
caplog.set_level(logging.DEBUG)
"""Test that bad JSON input errors are logged and re-raised."""
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
with pytest.raises(ModelBehaviorError):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "not_json")
assert "Invalid JSON input for tool test_tool_1" in caplog.text
@pytest.mark.asyncio
async def test_mcp_invoke_bad_json_redacts_payload_when_dont_log_tool_data(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
caplog.set_level(logging.DEBUG)
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
bad_json = '{"secret":"SECRET_TOKEN_123"'
with pytest.raises(ModelBehaviorError) as exc_info:
await MCPUtil.invoke_mcp_tool(server, tool, ctx, bad_json)
assert str(exc_info.value) == "Invalid JSON input for tool test_tool_1"
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
assert "SECRET_TOKEN_123" not in str(exc_info.value)
assert "SECRET_TOKEN_123" not in caplog.text
@pytest.mark.asyncio
async def test_mcp_invoke_bad_json_includes_payload_when_tool_logging_enabled(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
caplog.set_level(logging.DEBUG)
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
bad_json = '{"secret":"SECRET_TOKEN_123"'
with pytest.raises(ModelBehaviorError) as exc_info:
await MCPUtil.invoke_mcp_tool(server, tool, ctx, bad_json)
assert str(exc_info.value) == f"Invalid JSON input for tool test_tool_1: {bad_json}"
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
assert exc_info.value.__cause__.doc == bad_json
assert "SECRET_TOKEN_123" in str(exc_info.value)
assert "SECRET_TOKEN_123" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize("input_json", ["[]", '"value"', "123", "null"])
async def test_mcp_invoke_rejects_non_object_json_input(input_json: str):
server = FakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
with pytest.raises(ModelBehaviorError, match="expected a JSON object"):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, input_json)
assert server.tool_calls == []
class CrashingFakeMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
raise Exception("Crash!")
class CancelledFakeMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
raise asyncio.CancelledError("synthetic mcp cancel")
class SlowFakeMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
await asyncio.sleep(60)
return await super().call_tool(tool_name, arguments, meta=meta)
class CleanupOnCancelFakeMCPServer(FakeMCPServer):
def __init__(self, cleanup_finished: asyncio.Event):
super().__init__()
self.cleanup_finished = cleanup_finished
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
await asyncio.sleep(0.05)
self.cleanup_finished.set()
raise
@pytest.mark.asyncio
async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixture):
caplog.set_level(logging.DEBUG)
"""Test that bad JSON input errors are logged and re-raised."""
server = CrashingFakeMCPServer()
server.add_tool("test_tool_1", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool_1", inputSchema={})
with pytest.raises(AgentsException):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
assert "Error invoking MCP tool test_tool_1" in caplog.text
@pytest.mark.asyncio
async def test_mcp_tool_inner_cancellation_becomes_tool_error():
server = CancelledFakeMCPServer()
server.add_tool("cancel_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="cancel_tool", inputSchema={})
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
tool, server, convert_schemas_to_strict=False, agent=agent
)
tool_context = ToolContext(
context=None,
tool_name="cancel_tool",
tool_call_id="test_call_cancelled",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
assert isinstance(result, str)
assert "tool execution was cancelled" in result
@pytest.mark.asyncio
async def test_mcp_tool_inner_cancellation_still_becomes_tool_error_with_prior_cancel_state():
current_task = asyncio.current_task()
assert current_task is not None
current_task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.sleep(0)
server = CancelledFakeMCPServer()
server.add_tool("cancel_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="cancel_tool", inputSchema={})
with pytest.raises(MCPToolCancellationError, match="tool execution was cancelled"):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
@pytest.mark.asyncio
async def test_mcp_tool_outer_cancellation_still_propagates():
server = SlowFakeMCPServer()
server.add_tool("slow_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="slow_tool", inputSchema={})
task = asyncio.create_task(MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}"))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_mcp_tool_outer_cancellation_after_inner_completion_still_propagates(
monkeypatch: pytest.MonkeyPatch,
):
server = FakeMCPServer()
server.add_tool("fast_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="fast_tool", inputSchema={})
async def fake_wait(tasks, *, return_when):
del return_when
(task,) = tuple(tasks)
await task
raise asyncio.CancelledError("synthetic outer cancellation")
monkeypatch.setattr(asyncio, "wait", fake_wait)
with pytest.raises(asyncio.CancelledError):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
@pytest.mark.asyncio
async def test_mcp_tool_outer_cancellation_after_inner_exception_still_propagates(
monkeypatch: pytest.MonkeyPatch,
):
server = CrashingFakeMCPServer()
server.add_tool("boom_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="boom_tool", inputSchema={})
async def fake_wait(tasks, *, return_when):
del return_when
(task,) = tuple(tasks)
try:
await task
except Exception:
pass
raise asyncio.CancelledError("synthetic outer cancellation")
monkeypatch.setattr(asyncio, "wait", fake_wait)
with pytest.raises(asyncio.CancelledError):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
@pytest.mark.asyncio
async def test_mcp_tool_outer_cancellation_after_inner_cancellation_still_propagates(
monkeypatch: pytest.MonkeyPatch,
):
server = SlowFakeMCPServer()
server.add_tool("slow_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="slow_tool", inputSchema={})
async def fake_wait(tasks, *, return_when):
del return_when
(task,) = tuple(tasks)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
raise asyncio.CancelledError("synthetic combined cancellation")
monkeypatch.setattr(asyncio, "wait", fake_wait)
with pytest.raises(asyncio.CancelledError):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
@pytest.mark.asyncio
async def test_mcp_tool_outer_cancellation_waits_for_inner_cleanup():
cleanup_finished = asyncio.Event()
server = CleanupOnCancelFakeMCPServer(cleanup_finished)
server.add_tool("slow_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="slow_tool", inputSchema={})
task = asyncio.create_task(MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}"))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleanup_finished.is_set()
@pytest.mark.asyncio
async def test_mcp_invocation_mcp_error_reraises(caplog: pytest.LogCaptureFixture):
"""Test that McpError from server.call_tool is re-raised so the FunctionTool failure
pipeline (failure_error_function) can handle it.
When an MCP server raises McpError (e.g. upstream HTTP 4xx/5xx), invoke_mcp_tool
re-raises so the configured failure_error_function shapes the model-visible error.
With the default failure_error_function the FunctionTool returns a string error
result; with failure_error_function=None the error is propagated to the caller.
"""
caplog.set_level(logging.DEBUG)
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
class McpErrorFakeMCPServer(FakeMCPServer):
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
):
raise McpError(ErrorData(code=-32000, message="upstream 422 Unprocessable Entity"))
server = McpErrorFakeMCPServer()
server.add_tool("search", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="search", inputSchema={})
# invoke_mcp_tool itself should re-raise McpError
with pytest.raises(McpError):
await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Warning (not error) should be logged before re-raising
assert "returned an error" in caplog.text
# Via FunctionTool with default failure_error_function: error becomes a string result
mcp_tool = MCPTool(name="search", 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="search",
tool_call_id="test_call_mcp_error",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
assert isinstance(result, str)
assert "upstream 422 Unprocessable Entity" in result or "error" in result.lower()
@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={})
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
mcp_tool, server, convert_schemas_to_strict=False, agent=agent
)
# 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
# The error message now includes the server name
wrapped_error = AgentsException(
"Error invoking MCP tool crashing_tool on server 'fake_mcp_server': 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,
meta: dict[str, Any] | None = 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={})
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
mcp_tool, server, convert_schemas_to_strict=False, agent=agent
)
# 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_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."""
server = FakeMCPServer(require_approval="always")
server.add_tool("legacy_tool", {})
# Backward compatibility: old call style omitted the `agent` argument.
function_tool = MCPUtil.to_function_tool(
MCPTool(name="legacy_tool", inputSchema={}),
server,
convert_schemas_to_strict=False,
)
# Legacy calls should still respect server-level approval settings.
assert function_tool.needs_approval is True
tool_context = ToolContext(
context=None,
tool_name="legacy_tool",
tool_call_id="legacy_call_1",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
if isinstance(result, str):
assert "result_legacy_tool_" in result
elif isinstance(result, dict):
assert "result_legacy_tool_" in str(result.get("text", ""))
else:
pytest.fail(f"Unexpected tool result type: {type(result).__name__}")
@pytest.mark.asyncio
async def test_to_function_tool_legacy_call_callable_policy_requires_approval():
"""Legacy to_function_tool calls should default to approval for callable policies."""
server = FakeMCPServer()
server.add_tool("legacy_callable_tool", {})
def require_approval(
_run_context: RunContextWrapper[Any],
_agent: Agent,
_tool: MCPTool,
) -> bool:
return False
server._needs_approval_policy = require_approval # type: ignore[assignment]
function_tool = MCPUtil.to_function_tool(
MCPTool(name="legacy_callable_tool", inputSchema={}),
server,
convert_schemas_to_strict=False,
)
assert function_tool.needs_approval is True
@pytest.mark.asyncio
async def test_to_function_tool_callable_policy_uses_agent_and_tool():
"""Callable require_approval policies should bridge into FunctionTool.needs_approval."""
captured: dict[str, Any] = {}
def require_approval(
run_context: RunContextWrapper[Any],
agent: Agent,
tool: MCPTool,
) -> bool:
captured["run_context"] = run_context
captured["agent"] = agent
captured["tool"] = tool
return tool.name == "guarded_tool"
server = FakeMCPServer(require_approval=require_approval)
tool = MCPTool(name="guarded_tool", inputSchema={})
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
tool,
server,
convert_schemas_to_strict=False,
agent=agent,
)
assert callable(function_tool.needs_approval)
run_context = RunContextWrapper(context={"request_id": "req_123"})
needs_approval = await function_tool.needs_approval(run_context, {}, "call_123")
assert needs_approval is True
assert captured["run_context"] is run_context
assert captured["agent"] is agent
assert captured["tool"].name == "guarded_tool"
@pytest.mark.asyncio
async def test_to_function_tool_async_callable_policy_is_awaited():
"""Async require_approval policies should be awaited before tool execution."""
async def require_approval(
_run_context: RunContextWrapper[Any],
_agent: Agent,
tool: MCPTool,
) -> bool:
await asyncio.sleep(0)
return tool.name == "async_guarded_tool"
server = FakeMCPServer(require_approval=require_approval)
tool = MCPTool(name="async_guarded_tool", inputSchema={})
agent = Agent(name="test-agent")
function_tool = MCPUtil.to_function_tool(
tool,
server,
convert_schemas_to_strict=False,
agent=agent,
)
assert callable(function_tool.needs_approval)
needs_approval = await function_tool.needs_approval(
RunContextWrapper(context=None),
{},
"call_async_123",
)
assert needs_approval is True
@pytest.mark.asyncio
async def test_mcp_tool_failure_error_function_agent_default():
"""Agent-level failure_error_function should handle MCP tool failures."""
def custom_failure(_ctx: RunContextWrapper[Any], _exc: Exception) -> str:
return "custom_mcp_failure"
server = CrashingFakeMCPServer()
server.add_tool("crashing_tool", {})
agent = Agent(
name="test-agent",
mcp_servers=[server],
mcp_config={"failure_error_function": custom_failure},
)
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
function_tool = next(tool for tool in tools if tool.name == "crashing_tool")
assert isinstance(function_tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="crashing_tool",
tool_call_id="test_call_custom_1",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
assert result == "custom_mcp_failure"
@pytest.mark.asyncio
async def test_mcp_tool_failure_error_function_server_override():
"""Server-level failure_error_function should override agent defaults."""
def agent_failure(_ctx: RunContextWrapper[Any], _exc: Exception) -> str:
return "agent_failure"
def server_failure(_ctx: RunContextWrapper[Any], _exc: Exception) -> str:
return "server_failure"
server = CrashingFakeMCPServer(failure_error_function=server_failure)
server.add_tool("crashing_tool", {})
agent = Agent(
name="test-agent",
mcp_servers=[server],
mcp_config={"failure_error_function": agent_failure},
)
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
function_tool = next(tool for tool in tools if tool.name == "crashing_tool")
assert isinstance(function_tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="crashing_tool",
tool_call_id="test_call_custom_2",
tool_arguments="{}",
)
result = await function_tool.on_invoke_tool(tool_context, "{}")
assert result == "server_failure"
@pytest.mark.asyncio
async def test_mcp_tool_failure_error_function_server_none_raises():
"""Server-level None should re-raise MCP tool failures."""
server = CrashingFakeMCPServer(failure_error_function=None)
server.add_tool("crashing_tool", {})
agent = Agent(
name="test-agent",
mcp_servers=[server],
mcp_config={"failure_error_function": default_tool_error_function},
)
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
function_tool = next(tool for tool in tools if tool.name == "crashing_tool")
assert isinstance(function_tool, FunctionTool)
tool_context = ToolContext(
context=None,
tool_name="crashing_tool",
tool_call_id="test_call_custom_3",
tool_arguments="{}",
)
with pytest.raises(AgentsException):
await function_tool.on_invoke_tool(tool_context, "{}")
@pytest.mark.asyncio
async def test_replaced_mcp_tool_normal_failure_uses_replaced_policy():
server = CrashingFakeMCPServer()
server.add_tool("crashing_tool", {})
agent = Agent(
name="test-agent",
mcp_servers=[server],
mcp_config={"failure_error_function": default_tool_error_function},
)
run_context = RunContextWrapper(context=None)
function_tools = await agent.get_mcp_tools(run_context)
original_tool = next(tool for tool in function_tools if tool.name == "crashing_tool")
assert isinstance(original_tool, FunctionTool)
replaced_tool = dataclasses.replace(
original_tool,
_failure_error_function=None,
_use_default_failure_error_function=False,
)
tool_context = ToolContext(
context=None,
tool_name=replaced_tool.name,
tool_call_id="test_call_custom_4",
tool_arguments="{}",
)
with pytest.raises(AgentsException):
await replaced_tool.on_invoke_tool(tool_context, "{}")
@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.
- 'foo' tool is already strict and remains strict.
- 'bar' tool is non-strict and becomes strict (additionalProperties set to False, etc).
"""
strict_schema = Foo.model_json_schema()
non_strict_schema = Baz.json_schema()
possible_to_convert_schema = _convertible_schema()
server = FakeMCPServer()
server.add_tool("foo", strict_schema)
server.add_tool("bar", non_strict_schema)
server.add_tool("baz", possible_to_convert_schema)
agent = Agent(
name="test_agent", mcp_servers=[server], mcp_config={"convert_schemas_to_strict": True}
)
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
foo_tool = next(tool for tool in tools if tool.name == "foo")
assert isinstance(foo_tool, FunctionTool)
bar_tool = next(tool for tool in tools if tool.name == "bar")
assert isinstance(bar_tool, FunctionTool)
baz_tool = next(tool for tool in tools if tool.name == "baz")
assert isinstance(baz_tool, FunctionTool)
# Checks that additionalProperties is set to False
assert foo_tool.params_json_schema == snapshot(
{
"properties": {
"bar": {"title": "Bar", "type": "string"},
"baz": {"title": "Baz", "type": "integer"},
},
"required": ["bar", "baz"],
"title": "Foo",
"type": "object",
"additionalProperties": False,
}
)
assert foo_tool.strict_json_schema is True, "foo_tool should be strict"
# Checks that additionalProperties is set to False
assert bar_tool.params_json_schema == snapshot(
{"type": "object", "additionalProperties": {"type": "string"}, "properties": {}}
)
assert bar_tool.strict_json_schema is False, "bar_tool should not be strict"
# Checks that additionalProperties is set to False
assert baz_tool.params_json_schema == snapshot(
{
"properties": {
"bar": {"title": "Bar", "type": "string"},
"baz": {"title": "Baz", "type": "integer"},
},
"required": ["bar", "baz"],
"title": "Foo",
"type": "object",
"additionalProperties": False,
}
)
assert baz_tool.strict_json_schema is True, "baz_tool should be strict"
@pytest.mark.asyncio
async def test_agent_convert_schemas_false():
"""Test that setting convert_schemas_to_strict to False leaves tool schemas as non-strict.
- 'foo' tool remains strict.
- 'bar' tool remains non-strict (additionalProperties remains True).
"""
strict_schema = Foo.model_json_schema()
non_strict_schema = Baz.json_schema()
possible_to_convert_schema = _convertible_schema()
server = FakeMCPServer()
server.add_tool("foo", strict_schema)
server.add_tool("bar", non_strict_schema)
server.add_tool("baz", possible_to_convert_schema)
agent = Agent(
name="test_agent", mcp_servers=[server], mcp_config={"convert_schemas_to_strict": False}
)
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
foo_tool = next(tool for tool in tools if tool.name == "foo")
assert isinstance(foo_tool, FunctionTool)
bar_tool = next(tool for tool in tools if tool.name == "bar")
assert isinstance(bar_tool, FunctionTool)
baz_tool = next(tool for tool in tools if tool.name == "baz")
assert isinstance(baz_tool, FunctionTool)
assert foo_tool.params_json_schema == strict_schema
assert foo_tool.strict_json_schema is False, "Shouldn't be converted unless specified"
assert bar_tool.params_json_schema == snapshot(
{"type": "object", "additionalProperties": {"type": "string"}, "properties": {}}
)
assert bar_tool.strict_json_schema is False
assert baz_tool.params_json_schema == possible_to_convert_schema
assert baz_tool.strict_json_schema is False, "Shouldn't be converted unless specified"
@pytest.mark.asyncio
async def test_mcp_fastmcp_behavior_verification():
"""Test that verifies the exact FastMCP _convert_to_content behavior we observed.
Based on our testing, FastMCP's _convert_to_content function behaves as follows:
- None → content=[] → MCPUtil returns "[]"
- [] → content=[] → MCPUtil returns "[]"
- {} → content=[TextContent(text="{}")] → MCPUtil returns full JSON
- [{}] → content=[TextContent(text="{}")] → MCPUtil returns full JSON (flattened)
- [[]] → content=[] → MCPUtil returns "[]" (recursive empty)
"""
from mcp.types import TextContent
server = FakeMCPServer()
server.add_tool("test_tool", {})
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool", inputSchema={})
# Case 1: None -> [].
server._custom_content = []
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
assert result == [], f"None should return [], got {result}"
# Case 2: [] -> [].
server._custom_content = []
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
assert result == [], f"[] should return [], got {result}"
# Case 3: {} -> {"type": "text", "text": "{}"}.
server._custom_content = [TextContent(text="{}", type="text")]
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
expected = {"type": "text", "text": "{}"}
assert result == expected, f"{{}} should return {expected}, got {result}"
# Case 4: [{}] -> {"type": "text", "text": "{}"}.
server._custom_content = [TextContent(text="{}", type="text")]
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
expected = {"type": "text", "text": "{}"}
assert result == expected, f"[{{}}] should return {expected}, got {result}"
# Case 5: [[]] -> [].
server._custom_content = []
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
assert result == [], f"[[]] should return [], got {result}"
# Case 6: String values work normally.
server._custom_content = [TextContent(text="hello", type="text")]
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
expected = {"type": "text", "text": "hello"}
assert result == expected, f"String should return {expected}, got {result}"
# Case 7: Image content works normally.
server._custom_content = [ImageContent(data="AAAA", mimeType="image/png", type="image")]
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "")
expected = {"type": "image", "image_url": "data:image/png;base64,AAAA"}
assert result == expected, f"Image should return {expected}, got {result}"
@pytest.mark.asyncio
async def test_agent_convert_schemas_unset():
"""Test that leaving convert_schemas_to_strict unset (defaulting to False) leaves tool schemas
as non-strict.
- 'foo' tool remains strict.
- 'bar' tool remains non-strict.
"""
strict_schema = Foo.model_json_schema()
non_strict_schema = Baz.json_schema()
possible_to_convert_schema = _convertible_schema()
server = FakeMCPServer()
server.add_tool("foo", strict_schema)
server.add_tool("bar", non_strict_schema)
server.add_tool("baz", possible_to_convert_schema)
agent = Agent(name="test_agent", mcp_servers=[server])
run_context = RunContextWrapper(context=None)
tools = await agent.get_mcp_tools(run_context)
foo_tool = next(tool for tool in tools if tool.name == "foo")
assert isinstance(foo_tool, FunctionTool)
bar_tool = next(tool for tool in tools if tool.name == "bar")
assert isinstance(bar_tool, FunctionTool)
baz_tool = next(tool for tool in tools if tool.name == "baz")
assert isinstance(baz_tool, FunctionTool)
assert foo_tool.params_json_schema == strict_schema
assert foo_tool.strict_json_schema is False, "Shouldn't be converted unless specified"
assert bar_tool.params_json_schema == snapshot(
{"type": "object", "additionalProperties": {"type": "string"}, "properties": {}}
)
assert bar_tool.strict_json_schema is False
assert baz_tool.params_json_schema == possible_to_convert_schema
assert baz_tool.strict_json_schema is False, "Shouldn't be converted unless specified"
@pytest.mark.asyncio
async def test_util_adds_properties():
"""The MCP spec doesn't require the inputSchema to have `properties`, so we need to add it
if it's missing.
"""
schema = {
"type": "object",
"description": "Test tool",
}
server = FakeMCPServer()
server.add_tool("test_tool", schema)
run_context = RunContextWrapper(context=None)
agent = Agent(name="test_agent", instructions="Test agent")
tools = await MCPUtil.get_all_function_tools([server], False, run_context, agent)
tool = next(tool for tool in tools if tool.name == "test_tool")
assert isinstance(tool, FunctionTool)
assert "properties" in tool.params_json_schema
assert tool.params_json_schema["properties"] == {}
assert tool.params_json_schema == snapshot(
{"type": "object", "description": "Test tool", "properties": {}}
)
def test_to_function_tool_does_not_mutate_mcp_input_schema():
schema = {"type": "object", "description": "Test tool"}
tool = MCPTool(name="test_tool", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=False)
assert function_tool.params_json_schema == {
"type": "object",
"description": "Test tool",
"properties": {},
}
assert schema == {"type": "object", "description": "Test tool"}
assert tool.inputSchema == {"type": "object", "description": "Test tool"}
def test_to_function_tool_failed_strict_conversion_keeps_original_schema():
# ``ensure_strict_json_schema`` mutates the schema in place. Until this is
# isolated, a partially-mutated schema would be served as non-strict, leaking
# strict-mode artifacts (e.g. ``required`` and ``additionalProperties: false``)
# on a tool that is_strict=False.
schema = {
"type": "object",
"properties": {
"x": {"type": "object", "additionalProperties": True},
},
}
tool = MCPTool(name="test_tool", inputSchema=schema)
function_tool = MCPUtil.to_function_tool(tool, FakeMCPServer(), convert_schemas_to_strict=True)
assert function_tool.strict_json_schema is False
assert function_tool.params_json_schema == {
"type": "object",
"properties": {
"x": {"type": "object", "additionalProperties": True},
},
}
class StructuredContentTestServer(FakeMCPServer):
"""Test server that allows setting both content and structured content for testing."""
def __init__(self, use_structured_content: bool = False, **kwargs):
super().__init__(**kwargs)
self.use_structured_content = use_structured_content
self._test_content: list[Any] = []
self._test_structured_content: dict[str, Any] | None = None
def set_test_result(self, content: list[Any], structured_content: dict[str, Any] | None = None):
"""Set the content and structured content that will be returned by call_tool."""
self._test_content = content
self._test_structured_content = structured_content
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any] | None,
meta: dict[str, Any] | None = None,
) -> CallToolResult:
"""Return test result with specified content and structured content."""
self.tool_calls.append(tool_name)
return CallToolResult(
content=self._test_content, structuredContent=self._test_structured_content
)
@pytest.mark.parametrize(
"use_structured_content,content,structured_content,expected_output",
[
# Scenario 1: use_structured_content=True with structured content available
# Should return only structured content
(
True,
[TextContent(text="text content", type="text")],
{"data": "structured_value", "type": "structured"},
'{"data": "structured_value", "type": "structured"}',
),
# Scenario 2: use_structured_content=False with structured content available
# Should return text content only (structured content ignored)
(
False,
[TextContent(text="text content", type="text")],
{"data": "structured_value", "type": "structured"},
{"type": "text", "text": "text content"},
),
# Scenario 3: use_structured_content=True but no structured content
# Should fall back to text content
(
True,
[TextContent(text="fallback text", type="text")],
None,
{"type": "text", "text": "fallback text"},
),
# Scenario 4: use_structured_content=True with empty structured content (falsy)
# Should fall back to text content
(
True,
[TextContent(text="fallback text", type="text")],
{},
{"type": "text", "text": "fallback text"},
),
# Scenario 5: use_structured_content=True, structured content available, empty text content
# Should return structured content
(True, [], {"message": "only structured"}, '{"message": "only structured"}'),
# Scenario 6: use_structured_content=False, multiple text content items
# Should return JSON array of text content
(
False,
[TextContent(text="first", type="text"), TextContent(text="second", type="text")],
{"ignored": "structured"},
[{"type": "text", "text": "first"}, {"type": "text", "text": "second"}],
),
# Scenario 7: use_structured_content=True, multiple text content, with structured content
# Should return only structured content (text content ignored)
(
True,
[
TextContent(text="ignored first", type="text"),
TextContent(text="ignored second", type="text"),
],
{"priority": "structured"},
'{"priority": "structured"}',
),
# Scenario 8: use_structured_content=False, empty content
# Should return empty array
(False, [], None, []),
# Scenario 9: use_structured_content=True, empty content, no structured content
# Should return empty array
(True, [], None, []),
],
)
@pytest.mark.asyncio
async def test_structured_content_handling(
use_structured_content: bool,
content: list[Any],
structured_content: dict[str, Any] | None,
expected_output: str,
):
"""Test that structured content handling works correctly with various scenarios.
This test verifies the fix for the MCP tool output logic where:
- When use_structured_content=True and structured content exists, it's used exclusively
- When use_structured_content=False or no structured content, falls back to text content
- The old unreachable code path has been fixed
"""
server = StructuredContentTestServer(use_structured_content=use_structured_content)
server.add_tool("test_tool", {})
server.set_test_result(content, structured_content)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="test_tool", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
assert result == expected_output
@pytest.mark.asyncio
async def test_structured_content_priority_over_text():
"""Test that when use_structured_content=True, structured content takes priority.
This verifies the core fix: structured content should be used exclusively when available
and requested, not concatenated with text content.
"""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("priority_test", {})
# Set both text and structured content
text_content = [TextContent(text="This should be ignored", type="text")]
structured_content = {"important": "This should be returned", "value": 42}
server.set_test_result(text_content, structured_content)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="priority_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should return only structured content
import json
assert isinstance(result, str)
parsed_result = json.loads(result)
assert parsed_result == structured_content
assert "This should be ignored" not in result
@pytest.mark.asyncio
async def test_structured_content_fallback_behavior():
"""Test fallback behavior when structured content is requested but not available.
This verifies that the logic properly falls back to text content processing
when use_structured_content=True but no structured content is provided.
"""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("fallback_test", {})
# Set only text content, no structured content
text_content = [TextContent(text="Fallback content", type="text")]
server.set_test_result(text_content, None)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="fallback_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should fall back to text content
assert isinstance(result, dict)
assert result["type"] == "text"
assert result["text"] == "Fallback content"
@pytest.mark.asyncio
async def test_backwards_compatibility_unchanged():
"""Test that default behavior (use_structured_content=False) remains unchanged.
This ensures the fix doesn't break existing behavior for servers that don't use
structured content or have it disabled.
"""
server = StructuredContentTestServer(use_structured_content=False)
server.add_tool("compat_test", {})
# Set both text and structured content
text_content = [TextContent(text="Traditional text output", type="text")]
structured_content = {"modern": "structured output"}
server.set_test_result(text_content, structured_content)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="compat_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should return only text content (structured content ignored)
assert isinstance(result, dict)
assert result["type"] == "text"
assert result["text"] == "Traditional text output"
assert "modern" not in result
@pytest.mark.asyncio
async def test_empty_structured_content_fallback():
"""Test that empty structured content (falsy values) falls back to text content.
This tests the condition: if server.use_structured_content and result.structuredContent
where empty dict {} should be falsy and trigger fallback.
"""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("empty_structured_test", {})
# Set text content and empty structured content
text_content = [TextContent(text="Should use this text", type="text")]
empty_structured: dict[str, Any] = {} # This should be falsy
server.set_test_result(text_content, empty_structured)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="empty_structured_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should fall back to text content because empty dict is falsy
assert isinstance(result, dict)
assert result["type"] == "text"
assert result["text"] == "Should use this text"
@pytest.mark.asyncio
async def test_complex_structured_content():
"""Test handling of complex structured content with nested objects and arrays."""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("complex_test", {})
# Set complex structured content
complex_structured = {
"results": [
{"id": 1, "name": "Item 1", "metadata": {"tags": ["a", "b"]}},
{"id": 2, "name": "Item 2", "metadata": {"tags": ["c", "d"]}},
],
"pagination": {"page": 1, "total": 2},
"status": "success",
}
server.set_test_result([], complex_structured)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="complex_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should return the complex structured content as-is
import json
assert isinstance(result, str)
parsed_result = json.loads(result)
assert parsed_result == complex_structured
assert len(parsed_result["results"]) == 2
assert parsed_result["pagination"]["total"] == 2
@pytest.mark.asyncio
async def test_multiple_content_items_with_structured():
"""Test that multiple text content items are ignored when structured content is available.
This verifies that the new logic prioritizes structured content over multiple text items,
which was one of the scenarios that had unclear behavior in the old implementation.
"""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("multi_content_test", {})
# Set multiple text content items and structured content
text_content = [
TextContent(text="First text item", type="text"),
TextContent(text="Second text item", type="text"),
TextContent(text="Third text item", type="text"),
]
structured_content = {"chosen": "structured over multiple text items"}
server.set_test_result(text_content, structured_content)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="multi_content_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should return only structured content, ignoring all text items
import json
assert isinstance(result, str)
parsed_result = json.loads(result)
assert parsed_result == structured_content
assert "First text item" not in result
assert "Second text item" not in result
assert "Third text item" not in result
@pytest.mark.asyncio
async def test_multiple_content_items_without_structured():
"""Test that multiple text content items are properly handled when no structured content."""
server = StructuredContentTestServer(use_structured_content=True)
server.add_tool("multi_text_test", {})
# Set multiple text content items without structured content
text_content = [TextContent(text="First", type="text"), TextContent(text="Second", type="text")]
server.set_test_result(text_content, None)
ctx = RunContextWrapper(context=None)
tool = MCPTool(name="multi_text_test", inputSchema={})
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
# Should return JSON array of text content items
assert isinstance(result, list)
assert len(result) == 2
assert result[0]["type"] == "text"
assert result[0]["text"] == "First"
assert result[1]["type"] == "text"
assert result[1]["text"] == "Second"
def test_to_function_tool_preserves_mcp_title_metadata():
server = FakeMCPServer()
tool = MCPTool(
name="search_docs",
inputSchema={},
description="Search the docs.",
title="Search Docs",
)
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
assert function_tool.description == "Search the docs."
assert function_tool._mcp_title == "Search Docs"
def test_to_function_tool_description_falls_back_to_mcp_title():
server = FakeMCPServer()
tool = MCPTool(
name="search_docs",
inputSchema={},
description=None,
title="Search Docs",
)
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
assert function_tool.description == "Search Docs"
assert function_tool._mcp_title == "Search Docs"