Skip to content

Commit e6f4b36

Browse files
authored
fix: #2603 preserve MCP tool title metadata across tool call items (#2621)
1 parent 2ac2bbb commit e6f4b36

12 files changed

Lines changed: 427 additions & 23 deletions

src/agents/_mcp_tool_metadata.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable, Mapping
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
8+
@dataclass(frozen=True)
9+
class MCPToolMetadata:
10+
"""Resolved display metadata for an MCP tool."""
11+
12+
description: str | None = None
13+
title: str | None = None
14+
15+
16+
def _get_mapping_or_attr(value: Any, key: str) -> Any:
17+
if isinstance(value, Mapping):
18+
return value.get(key)
19+
return getattr(value, key, None)
20+
21+
22+
def _get_non_empty_string(value: Any) -> str | None:
23+
if isinstance(value, str) and value:
24+
return value
25+
return None
26+
27+
28+
def resolve_mcp_tool_title(tool: Any) -> str | None:
29+
"""Return the MCP display title, preferring explicit title over annotations.title."""
30+
explicit_title = _get_non_empty_string(_get_mapping_or_attr(tool, "title"))
31+
if explicit_title is not None:
32+
return explicit_title
33+
34+
annotations = _get_mapping_or_attr(tool, "annotations")
35+
return _get_non_empty_string(_get_mapping_or_attr(annotations, "title"))
36+
37+
38+
def resolve_mcp_tool_description(tool: Any) -> str | None:
39+
"""Return the MCP tool description when present."""
40+
return _get_non_empty_string(_get_mapping_or_attr(tool, "description"))
41+
42+
43+
def resolve_mcp_tool_description_for_model(tool: Any) -> str:
44+
"""Return the best model-facing description for an MCP tool.
45+
46+
MCP distinguishes between a long-form description and a short display title.
47+
When the description is absent, fall back to the title so local MCP tools do not
48+
become blank function definitions for the model.
49+
"""
50+
51+
return resolve_mcp_tool_description(tool) or resolve_mcp_tool_title(tool) or ""
52+
53+
54+
def extract_mcp_tool_metadata(tool: Any) -> MCPToolMetadata:
55+
"""Resolve display metadata from an MCP tool-like object."""
56+
return MCPToolMetadata(
57+
description=resolve_mcp_tool_description(tool),
58+
title=resolve_mcp_tool_title(tool),
59+
)
60+
61+
62+
def collect_mcp_list_tools_metadata(items: Iterable[Any]) -> dict[tuple[str, str], MCPToolMetadata]:
63+
"""Collect hosted MCP tool metadata from input/output items.
64+
65+
Accepts raw `mcp_list_tools` payloads, SDK models, or run items whose `raw_item`
66+
contains an `mcp_list_tools` payload.
67+
"""
68+
69+
metadata_map: dict[tuple[str, str], MCPToolMetadata] = {}
70+
71+
for item in items:
72+
raw_item = _get_mapping_or_attr(item, "raw_item") or item
73+
if _get_mapping_or_attr(raw_item, "type") != "mcp_list_tools":
74+
continue
75+
76+
server_label = _get_non_empty_string(_get_mapping_or_attr(raw_item, "server_label"))
77+
tools = _get_mapping_or_attr(raw_item, "tools")
78+
if server_label is None or not isinstance(tools, list):
79+
continue
80+
81+
for tool in tools:
82+
name = _get_non_empty_string(_get_mapping_or_attr(tool, "name"))
83+
if name is None:
84+
continue
85+
metadata_map[(server_label, name)] = extract_mcp_tool_metadata(tool)
86+
87+
return metadata_map

src/agents/items.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ class ToolCallItem(RunItemBase[Any]):
355355
description: str | None = None
356356
"""Optional tool description if known at item creation time."""
357357

358+
title: str | None = None
359+
"""Optional short display label if known at item creation time."""
360+
358361

359362
ToolCallOutputTypes: TypeAlias = Union[
360363
FunctionCallOutput,

src/agents/mcp/util.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing_extensions import NotRequired, TypedDict
1313

1414
from .. import _debug
15+
from .._mcp_tool_metadata import resolve_mcp_tool_description_for_model, resolve_mcp_tool_title
1516
from ..exceptions import AgentsException, ModelBehaviorError, UserError
1617

1718
try:
@@ -272,7 +273,7 @@ def to_function_tool(
272273

273274
function_tool = _build_wrapped_function_tool(
274275
name=tool.name,
275-
description=tool.description or "",
276+
description=resolve_mcp_tool_description_for_model(tool),
276277
params_json_schema=schema,
277278
invoke_tool_impl=invoke_func_impl,
278279
on_handled_error=_build_handled_function_tool_error_handler(
@@ -282,6 +283,7 @@ def to_function_tool(
282283
failure_error_function=effective_failure_error_function,
283284
strict_json_schema=is_strict,
284285
needs_approval=needs_approval,
286+
mcp_title=resolve_mcp_tool_title(tool),
285287
)
286288
return function_tool
287289

src/agents/run_internal/run_loop.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
from typing import Any, TypeVar, cast
1313

1414
from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputItemDoneEvent
15+
from openai.types.responses.response_output_item import McpCall, McpListTools
1516
from openai.types.responses.response_prompt_param import ResponsePromptParam
1617
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
1718

19+
from .._mcp_tool_metadata import collect_mcp_list_tools_metadata
1820
from .._tool_identity import (
1921
NamedToolLookupKey,
2022
build_function_tool_lookup_map,
@@ -1171,6 +1173,9 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
11711173
)
11721174
if isinstance(filtered.input, list):
11731175
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
1176+
hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(streamed_result._model_input_items)
1177+
if isinstance(filtered.input, list):
1178+
hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(filtered.input))
11741179
if server_conversation_tracker is not None:
11751180
logger.debug(
11761181
"filtered.input has %s items; ids=%s",
@@ -1295,6 +1300,9 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
12951300
)
12961301
)
12971302

1303+
elif isinstance(output_item, McpListTools):
1304+
hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata([output_item]))
1305+
12981306
elif isinstance(output_item, TOOL_CALL_TYPES):
12991307
output_call_id: str | None = getattr(
13001308
output_item, "call_id", getattr(output_item, "id", None)
@@ -1314,13 +1322,23 @@ def _tool_search_fingerprint(raw_item: Any) -> str:
13141322
tool_map.get(tool_lookup_key) if tool_lookup_key is not None else None
13151323
)
13161324
tool_description: str | None = None
1317-
if matched_tool is not None:
1325+
tool_title: str | None = None
1326+
if isinstance(output_item, McpCall):
1327+
metadata = hosted_mcp_tool_metadata.get(
1328+
(output_item.server_label, output_item.name)
1329+
)
1330+
if metadata is not None:
1331+
tool_description = metadata.description
1332+
tool_title = metadata.title
1333+
elif matched_tool is not None:
13181334
tool_description = getattr(matched_tool, "description", None)
1335+
tool_title = getattr(matched_tool, "_mcp_title", None)
13191336

13201337
tool_item = ToolCallItem(
13211338
raw_item=cast(ToolCallItemTypes, output_item),
13221339
agent=agent,
13231340
description=tool_description,
1341+
title=tool_title,
13241342
)
13251343
streamed_result._event_queue.put_nowait(
13261344
RunItemStreamEvent(item=tool_item, name="tool_called")

src/agents/run_internal/turn_resolution.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
)
2828
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
2929

30+
from .._mcp_tool_metadata import collect_mcp_list_tools_metadata
3031
from .._tool_identity import (
3132
build_function_tool_lookup_map,
3233
get_function_tool_lookup_key,
@@ -1271,6 +1272,7 @@ def process_model_response(
12711272
response: ModelResponse,
12721273
output_schema: AgentOutputSchemaBase | None,
12731274
handoffs: list[Handoff],
1275+
existing_items: Sequence[RunItem] | None = None,
12741276
) -> ProcessedResponse:
12751277
items: list[RunItem] = []
12761278

@@ -1295,6 +1297,8 @@ def process_model_response(
12951297
for tool in all_tools
12961298
if isinstance(tool, HostedMCPTool)
12971299
}
1300+
hosted_mcp_tool_metadata = collect_mcp_list_tools_metadata(existing_items or ())
1301+
hosted_mcp_tool_metadata.update(collect_mcp_list_tools_metadata(response.output))
12981302

12991303
def _dump_output_item(raw_item: Any) -> dict[str, Any]:
13001304
if isinstance(raw_item, dict):
@@ -1506,19 +1510,15 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]:
15061510
elif isinstance(output, McpListTools):
15071511
items.append(MCPListToolsItem(raw_item=output, agent=agent))
15081512
elif isinstance(output, McpCall):
1509-
# Look up MCP tool description from the server's cached tools list.
1510-
# Tool discovery is async I/O, but this function is sync, so this is best-effort and
1511-
# only works if the server has cached tool metadata (e.g., when cache_tools_list is
1512-
# enabled).
1513-
_mcp_description: str | None = None
1514-
for _server in agent.mcp_servers:
1515-
if _server.name == output.server_label:
1516-
for _tool in _server.cached_tools or []:
1517-
if _tool.name == output.name:
1518-
_mcp_description = _tool.description
1519-
break
1520-
break
1521-
items.append(ToolCallItem(raw_item=output, agent=agent, description=_mcp_description))
1513+
metadata = hosted_mcp_tool_metadata.get((output.server_label, output.name))
1514+
items.append(
1515+
ToolCallItem(
1516+
raw_item=output,
1517+
agent=agent,
1518+
description=metadata.description if metadata is not None else None,
1519+
title=metadata.title if metadata is not None else None,
1520+
)
1521+
)
15221522
tools_used.append("mcp")
15231523
elif isinstance(output, ImageGenerationCall):
15241524
items.append(ToolCallItem(raw_item=output, agent=agent))
@@ -1652,6 +1652,7 @@ def _dump_output_item(raw_item: Any) -> dict[str, Any]:
16521652
raw_item=output,
16531653
agent=agent,
16541654
description=func_tool.description,
1655+
title=func_tool._mcp_title,
16551656
)
16561657
)
16571658
functions.append(
@@ -1696,6 +1697,7 @@ async def get_single_step_result_from_response(
16961697
response=new_response,
16971698
output_schema=output_schema,
16981699
handoffs=handoffs,
1700+
existing_items=pre_step_items,
16991701
)
17001702

17011703
tool_use_tracker.record_processed_response(agent, processed_response)

src/agents/run_state.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,9 @@
116116
# 2. Keep older readable versions in SUPPORTED_SCHEMA_VERSIONS for backward reads.
117117
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
118118
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer versions).
119-
CURRENT_SCHEMA_VERSION = "1.6"
119+
CURRENT_SCHEMA_VERSION = "1.7"
120120
SUPPORTED_SCHEMA_VERSIONS = frozenset(
121-
{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", CURRENT_SCHEMA_VERSION}
121+
{"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", CURRENT_SCHEMA_VERSION}
122122
)
123123

124124
_FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput)
@@ -759,6 +759,8 @@ def _serialize_item(self, item: RunItem) -> dict[str, Any]:
759759
result["allow_bare_name_alias"] = True
760760
if hasattr(item, "description") and item.description is not None:
761761
result["description"] = item.description
762+
if hasattr(item, "title") and item.title is not None:
763+
result["title"] = item.title
762764

763765
return result
764766

@@ -2470,10 +2472,16 @@ def _resolve_agent_info(
24702472
# Tool call items can be function calls, shell calls, apply_patch calls,
24712473
# MCP calls, etc. Check the type field to determine which type to deserialize as
24722474
raw_item_tool = _deserialize_tool_call_raw_item(normalized_raw_item)
2473-
# Preserve description if it was stored with the item
2475+
# Preserve display metadata if it was stored with the item.
24742476
description = item_data.get("description")
2477+
title = item_data.get("title")
24752478
result.append(
2476-
ToolCallItem(agent=agent, raw_item=raw_item_tool, description=description)
2479+
ToolCallItem(
2480+
agent=agent,
2481+
raw_item=raw_item_tool,
2482+
description=description,
2483+
title=title,
2484+
)
24772485
)
24782486

24792487
elif item_type == "tool_call_output_item":

src/agents/tool.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,9 @@ class FunctionTool:
317317
_tool_namespace_description: str | None = field(default=None, kw_only=True, repr=False)
318318
"""Internal namespace description used when serializing grouped function tools."""
319319

320+
_mcp_title: str | None = field(default=None, kw_only=True, repr=False)
321+
"""Internal MCP display title used for ToolCallItem metadata."""
322+
320323
@property
321324
def qualified_name(self) -> str:
322325
"""Return the public qualified name used to identify this function tool."""
@@ -418,6 +421,7 @@ def _build_wrapped_function_tool(
418421
timeout_error_function: ToolErrorFunction | None = None,
419422
defer_loading: bool = False,
420423
sync_invoker: bool = False,
424+
mcp_title: str | None = None,
421425
) -> FunctionTool:
422426
"""Create a FunctionTool with copied-tool-aware failure handling bound in one place."""
423427
on_invoke_tool = with_function_tool_failure_error_handler(
@@ -442,6 +446,7 @@ def _build_wrapped_function_tool(
442446
timeout_behavior=timeout_behavior,
443447
timeout_error_function=timeout_error_function,
444448
defer_loading=defer_loading,
449+
_mcp_title=mcp_title,
445450
),
446451
failure_error_function,
447452
)

tests/mcp/test_mcp_util.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,3 +1079,33 @@ async def test_multiple_content_items_without_structured():
10791079
assert result[0]["text"] == "First"
10801080
assert result[1]["type"] == "text"
10811081
assert result[1]["text"] == "Second"
1082+
1083+
1084+
def test_to_function_tool_preserves_mcp_title_metadata():
1085+
server = FakeMCPServer()
1086+
tool = MCPTool(
1087+
name="search_docs",
1088+
inputSchema={},
1089+
description="Search the docs.",
1090+
title="Search Docs",
1091+
)
1092+
1093+
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
1094+
1095+
assert function_tool.description == "Search the docs."
1096+
assert function_tool._mcp_title == "Search Docs"
1097+
1098+
1099+
def test_to_function_tool_description_falls_back_to_mcp_title():
1100+
server = FakeMCPServer()
1101+
tool = MCPTool(
1102+
name="search_docs",
1103+
inputSchema={},
1104+
description=None,
1105+
title="Search Docs",
1106+
)
1107+
1108+
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
1109+
1110+
assert function_tool.description == "Search Docs"
1111+
assert function_tool._mcp_title == "Search Docs"

0 commit comments

Comments
 (0)