Files
openai--openai-agents-python/tests/test_mcp_tool_metadata.py
T
2026-07-13 12:39:17 +08:00

211 lines
7.3 KiB
Python

"""Unit tests for src/agents/_mcp_tool_metadata.py pure helpers.
The module resolves MCP tool display metadata (title / description) from
either dict payloads or attribute-bearing objects. It feeds hosted-MCP
tool definitions into the model and into traces, but had no direct
test file.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from agents._mcp_tool_metadata import (
MCPToolMetadata,
collect_mcp_list_tools_metadata,
extract_mcp_tool_metadata,
resolve_mcp_tool_description,
resolve_mcp_tool_description_for_model,
resolve_mcp_tool_title,
)
@dataclass
class _ToolObj:
"""Tiny attribute-bearing stand-in for an MCP tool object."""
name: str | None = None
title: str | None = None
description: str | None = None
annotations: Any = None
@dataclass
class _Annotations:
title: str | None = None
class TestResolveMCPToolTitle:
def test_explicit_title_wins(self) -> None:
tool = {"title": "Explicit", "annotations": {"title": "Annotated"}}
assert resolve_mcp_tool_title(tool) == "Explicit"
def test_falls_back_to_annotations_title(self) -> None:
tool = {"annotations": {"title": "Annotated"}}
assert resolve_mcp_tool_title(tool) == "Annotated"
def test_returns_none_when_neither_present(self) -> None:
assert resolve_mcp_tool_title({}) is None
def test_skips_empty_explicit_title(self) -> None:
tool = {"title": "", "annotations": {"title": "Annotated"}}
assert resolve_mcp_tool_title(tool) == "Annotated"
def test_skips_non_string_explicit_title(self) -> None:
tool = {"title": 123, "annotations": {"title": "Annotated"}}
assert resolve_mcp_tool_title(tool) == "Annotated"
def test_works_with_attribute_objects(self) -> None:
tool = _ToolObj(title="Explicit")
assert resolve_mcp_tool_title(tool) == "Explicit"
def test_works_with_attribute_annotations(self) -> None:
tool = _ToolObj(annotations=_Annotations(title="Annotated"))
assert resolve_mcp_tool_title(tool) == "Annotated"
def test_handles_missing_annotations_attribute(self) -> None:
tool = _ToolObj()
assert resolve_mcp_tool_title(tool) is None
class TestResolveMCPToolDescription:
def test_returns_description(self) -> None:
assert resolve_mcp_tool_description({"description": "Long form"}) == "Long form"
def test_returns_none_when_empty(self) -> None:
assert resolve_mcp_tool_description({"description": ""}) is None
def test_returns_none_when_missing(self) -> None:
assert resolve_mcp_tool_description({}) is None
def test_returns_none_when_non_string(self) -> None:
assert resolve_mcp_tool_description({"description": 123}) is None
def test_works_with_attribute_object(self) -> None:
assert resolve_mcp_tool_description(_ToolObj(description="Long form")) == "Long form"
class TestResolveMCPToolDescriptionForModel:
def test_uses_description_when_present(self) -> None:
tool = {"description": "Long form", "title": "Short"}
assert resolve_mcp_tool_description_for_model(tool) == "Long form"
def test_falls_back_to_title_when_description_missing(self) -> None:
assert resolve_mcp_tool_description_for_model({"title": "Short"}) == "Short"
def test_falls_back_to_annotations_title(self) -> None:
tool = {"annotations": {"title": "Annotated"}}
assert resolve_mcp_tool_description_for_model(tool) == "Annotated"
def test_returns_empty_string_when_nothing_resolvable(self) -> None:
assert resolve_mcp_tool_description_for_model({}) == ""
class TestExtractMCPToolMetadata:
def test_collects_both_fields(self) -> None:
tool = {"description": "Long form", "title": "Short"}
assert extract_mcp_tool_metadata(tool) == MCPToolMetadata(
description="Long form", title="Short"
)
def test_returns_empty_metadata_when_nothing_present(self) -> None:
assert extract_mcp_tool_metadata({}) == MCPToolMetadata()
class TestCollectMCPListToolsMetadata:
def test_collects_from_raw_payload(self) -> None:
items = [
{
"type": "mcp_list_tools",
"server_label": "github",
"tools": [
{"name": "search", "description": "Search repos", "title": "Search"},
{"name": "create", "description": "Create issue"},
],
}
]
result = collect_mcp_list_tools_metadata(items)
assert result == {
("github", "search"): MCPToolMetadata(description="Search repos", title="Search"),
("github", "create"): MCPToolMetadata(description="Create issue"),
}
def test_unwraps_run_item_with_raw_item(self) -> None:
@dataclass
class _RunItem:
raw_item: Any
run_item = _RunItem(
raw_item={
"type": "mcp_list_tools",
"server_label": "internal",
"tools": [{"name": "ping", "description": "Ping"}],
}
)
result = collect_mcp_list_tools_metadata([run_item])
assert result == {("internal", "ping"): MCPToolMetadata(description="Ping")}
def test_skips_items_without_correct_type(self) -> None:
items = [
{
"type": "mcp_call",
"server_label": "github",
"tools": [{"name": "ignored"}],
}
]
assert collect_mcp_list_tools_metadata(items) == {}
def test_skips_items_without_server_label(self) -> None:
items = [
{
"type": "mcp_list_tools",
"tools": [{"name": "search"}],
}
]
assert collect_mcp_list_tools_metadata(items) == {}
def test_skips_items_with_non_list_tools(self) -> None:
items = [
{
"type": "mcp_list_tools",
"server_label": "github",
"tools": "not-a-list",
}
]
assert collect_mcp_list_tools_metadata(items) == {}
def test_skips_tools_without_name(self) -> None:
items = [
{
"type": "mcp_list_tools",
"server_label": "github",
"tools": [
{"description": "no name"},
{"name": "", "description": "empty name"},
{"name": "good", "description": "kept"},
],
}
]
result = collect_mcp_list_tools_metadata(items)
assert result == {("github", "good"): MCPToolMetadata(description="kept")}
def test_returns_empty_for_empty_input(self) -> None:
assert collect_mcp_list_tools_metadata([]) == {}
def test_later_entry_for_same_key_wins(self) -> None:
items = [
{
"type": "mcp_list_tools",
"server_label": "github",
"tools": [{"name": "search", "description": "first"}],
},
{
"type": "mcp_list_tools",
"server_label": "github",
"tools": [{"name": "search", "description": "second"}],
},
]
result = collect_mcp_list_tools_metadata(items)
assert result == {("github", "search"): MCPToolMetadata(description="second")}