Skip to content

Commit 77a3365

Browse files
author
Quratulain-bilal
committed
test: add direct unit tests for _mcp_tool_metadata helpers
The _mcp_tool_metadata module resolves MCP tool display metadata (title / description) from raw payloads or attribute-bearing objects and feeds hosted-MCP definitions into the model and traces, but had no direct test file. Adds 27 focused unit tests covering each helper's branches: explicit title preference, annotation fallback, description-vs-title precedence for the model-facing string, extract_mcp_tool_metadata, and collect_mcp_list_tools_metadata (including raw_item unwrapping, skipped shapes, and last-entry-wins).
1 parent 3854c12 commit 77a3365

1 file changed

Lines changed: 210 additions & 0 deletions

File tree

tests/test_mcp_tool_metadata.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Unit tests for src/agents/_mcp_tool_metadata.py pure helpers.
2+
3+
The module resolves MCP tool display metadata (title / description) from
4+
either dict payloads or attribute-bearing objects. It feeds hosted-MCP
5+
tool definitions into the model and into traces, but had no direct
6+
test file.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from typing import Any
13+
14+
from agents._mcp_tool_metadata import (
15+
MCPToolMetadata,
16+
collect_mcp_list_tools_metadata,
17+
extract_mcp_tool_metadata,
18+
resolve_mcp_tool_description,
19+
resolve_mcp_tool_description_for_model,
20+
resolve_mcp_tool_title,
21+
)
22+
23+
24+
@dataclass
25+
class _ToolObj:
26+
"""Tiny attribute-bearing stand-in for an MCP tool object."""
27+
28+
name: str | None = None
29+
title: str | None = None
30+
description: str | None = None
31+
annotations: Any = None
32+
33+
34+
@dataclass
35+
class _Annotations:
36+
title: str | None = None
37+
38+
39+
class TestResolveMCPToolTitle:
40+
def test_explicit_title_wins(self) -> None:
41+
tool = {"title": "Explicit", "annotations": {"title": "Annotated"}}
42+
assert resolve_mcp_tool_title(tool) == "Explicit"
43+
44+
def test_falls_back_to_annotations_title(self) -> None:
45+
tool = {"annotations": {"title": "Annotated"}}
46+
assert resolve_mcp_tool_title(tool) == "Annotated"
47+
48+
def test_returns_none_when_neither_present(self) -> None:
49+
assert resolve_mcp_tool_title({}) is None
50+
51+
def test_skips_empty_explicit_title(self) -> None:
52+
tool = {"title": "", "annotations": {"title": "Annotated"}}
53+
assert resolve_mcp_tool_title(tool) == "Annotated"
54+
55+
def test_skips_non_string_explicit_title(self) -> None:
56+
tool = {"title": 123, "annotations": {"title": "Annotated"}}
57+
assert resolve_mcp_tool_title(tool) == "Annotated"
58+
59+
def test_works_with_attribute_objects(self) -> None:
60+
tool = _ToolObj(title="Explicit")
61+
assert resolve_mcp_tool_title(tool) == "Explicit"
62+
63+
def test_works_with_attribute_annotations(self) -> None:
64+
tool = _ToolObj(annotations=_Annotations(title="Annotated"))
65+
assert resolve_mcp_tool_title(tool) == "Annotated"
66+
67+
def test_handles_missing_annotations_attribute(self) -> None:
68+
tool = _ToolObj()
69+
assert resolve_mcp_tool_title(tool) is None
70+
71+
72+
class TestResolveMCPToolDescription:
73+
def test_returns_description(self) -> None:
74+
assert resolve_mcp_tool_description({"description": "Long form"}) == "Long form"
75+
76+
def test_returns_none_when_empty(self) -> None:
77+
assert resolve_mcp_tool_description({"description": ""}) is None
78+
79+
def test_returns_none_when_missing(self) -> None:
80+
assert resolve_mcp_tool_description({}) is None
81+
82+
def test_returns_none_when_non_string(self) -> None:
83+
assert resolve_mcp_tool_description({"description": 123}) is None
84+
85+
def test_works_with_attribute_object(self) -> None:
86+
assert resolve_mcp_tool_description(_ToolObj(description="Long form")) == "Long form"
87+
88+
89+
class TestResolveMCPToolDescriptionForModel:
90+
def test_uses_description_when_present(self) -> None:
91+
tool = {"description": "Long form", "title": "Short"}
92+
assert resolve_mcp_tool_description_for_model(tool) == "Long form"
93+
94+
def test_falls_back_to_title_when_description_missing(self) -> None:
95+
assert resolve_mcp_tool_description_for_model({"title": "Short"}) == "Short"
96+
97+
def test_falls_back_to_annotations_title(self) -> None:
98+
tool = {"annotations": {"title": "Annotated"}}
99+
assert resolve_mcp_tool_description_for_model(tool) == "Annotated"
100+
101+
def test_returns_empty_string_when_nothing_resolvable(self) -> None:
102+
assert resolve_mcp_tool_description_for_model({}) == ""
103+
104+
105+
class TestExtractMCPToolMetadata:
106+
def test_collects_both_fields(self) -> None:
107+
tool = {"description": "Long form", "title": "Short"}
108+
assert extract_mcp_tool_metadata(tool) == MCPToolMetadata(
109+
description="Long form", title="Short"
110+
)
111+
112+
def test_returns_empty_metadata_when_nothing_present(self) -> None:
113+
assert extract_mcp_tool_metadata({}) == MCPToolMetadata()
114+
115+
116+
class TestCollectMCPListToolsMetadata:
117+
def test_collects_from_raw_payload(self) -> None:
118+
items = [
119+
{
120+
"type": "mcp_list_tools",
121+
"server_label": "github",
122+
"tools": [
123+
{"name": "search", "description": "Search repos", "title": "Search"},
124+
{"name": "create", "description": "Create issue"},
125+
],
126+
}
127+
]
128+
result = collect_mcp_list_tools_metadata(items)
129+
assert result == {
130+
("github", "search"): MCPToolMetadata(description="Search repos", title="Search"),
131+
("github", "create"): MCPToolMetadata(description="Create issue"),
132+
}
133+
134+
def test_unwraps_run_item_with_raw_item(self) -> None:
135+
@dataclass
136+
class _RunItem:
137+
raw_item: Any
138+
139+
run_item = _RunItem(
140+
raw_item={
141+
"type": "mcp_list_tools",
142+
"server_label": "internal",
143+
"tools": [{"name": "ping", "description": "Ping"}],
144+
}
145+
)
146+
result = collect_mcp_list_tools_metadata([run_item])
147+
assert result == {("internal", "ping"): MCPToolMetadata(description="Ping")}
148+
149+
def test_skips_items_without_correct_type(self) -> None:
150+
items = [
151+
{
152+
"type": "mcp_call",
153+
"server_label": "github",
154+
"tools": [{"name": "ignored"}],
155+
}
156+
]
157+
assert collect_mcp_list_tools_metadata(items) == {}
158+
159+
def test_skips_items_without_server_label(self) -> None:
160+
items = [
161+
{
162+
"type": "mcp_list_tools",
163+
"tools": [{"name": "search"}],
164+
}
165+
]
166+
assert collect_mcp_list_tools_metadata(items) == {}
167+
168+
def test_skips_items_with_non_list_tools(self) -> None:
169+
items = [
170+
{
171+
"type": "mcp_list_tools",
172+
"server_label": "github",
173+
"tools": "not-a-list",
174+
}
175+
]
176+
assert collect_mcp_list_tools_metadata(items) == {}
177+
178+
def test_skips_tools_without_name(self) -> None:
179+
items = [
180+
{
181+
"type": "mcp_list_tools",
182+
"server_label": "github",
183+
"tools": [
184+
{"description": "no name"},
185+
{"name": "", "description": "empty name"},
186+
{"name": "good", "description": "kept"},
187+
],
188+
}
189+
]
190+
result = collect_mcp_list_tools_metadata(items)
191+
assert result == {("github", "good"): MCPToolMetadata(description="kept")}
192+
193+
def test_returns_empty_for_empty_input(self) -> None:
194+
assert collect_mcp_list_tools_metadata([]) == {}
195+
196+
def test_later_entry_for_same_key_wins(self) -> None:
197+
items = [
198+
{
199+
"type": "mcp_list_tools",
200+
"server_label": "github",
201+
"tools": [{"name": "search", "description": "first"}],
202+
},
203+
{
204+
"type": "mcp_list_tools",
205+
"server_label": "github",
206+
"tools": [{"name": "search", "description": "second"}],
207+
},
208+
]
209+
result = collect_mcp_list_tools_metadata(items)
210+
assert result == {("github", "search"): MCPToolMetadata(description="second")}

0 commit comments

Comments
 (0)