Skip to content

Commit 75813f8

Browse files
feat: expose MCP annotations on converted tools (#5356)
1 parent f093ced commit 75813f8

4 files changed

Lines changed: 115 additions & 0 deletions

File tree

‎src/agents/mcp/util.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ def to_function_tool(
601601
mcp_server_name=get_mcp_server_log_name(server.name),
602602
),
603603
)
604+
function_tool._mcp_annotations = copy.deepcopy(tool.annotations)
604605
function_tool._mcp_tool_binding = (get_mcp_server_log_name(server.name), tool.name, None)
605606
return function_tool
606607

‎src/agents/tool.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
from .util._types import MaybeAwaitable
8787

8888
if TYPE_CHECKING:
89+
from mcp.types import ToolAnnotations
8990
from typing_extensions import dataclass_transform
9091

9192
from .agent import Agent, AgentBase
@@ -594,6 +595,22 @@ class FunctionTool:
594595
)
595596
"""Original MCP server name, raw tool name, and configured server position for resume."""
596597

598+
_mcp_annotations: ToolAnnotations | None = field(default=None, kw_only=True, repr=False)
599+
600+
@property
601+
def mcp_annotations(self) -> ToolAnnotations | None:
602+
"""Return a copy of the MCP annotations captured when this tool was converted.
603+
604+
Returns None for non-MCP tools and MCP tools without annotations. The value uses
605+
the installed MCP SDK's ToolAnnotations type and attribute names. Omitted hints
606+
remain omitted; callers must apply MCP defaults when interpreting them.
607+
608+
These are server-provided hints, not proof of side effects, successful execution,
609+
or safe retries. Trust decisions belong to the application. This runtime metadata
610+
is not included in tool origin serialization.
611+
"""
612+
return copy.deepcopy(self._mcp_annotations)
613+
597614
@property
598615
def qualified_name(self) -> str:
599616
"""Return the public qualified name used to identify this function tool."""

‎tests/mcp/test_mcp_util.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3059,6 +3059,38 @@ def test_to_function_tool_preserves_mcp_title_metadata():
30593059
assert function_tool._mcp_title == "Search Docs"
30603060

30613061

3062+
def test_mcp_annotations_are_isolated_discovery_metadata():
3063+
server = FakeMCPServer()
3064+
tool = MCPTool(name="search", inputSchema={}, annotations={"readOnlyHint": True})
3065+
function_tool = MCPUtil.to_function_tool(tool, server, convert_schemas_to_strict=False)
3066+
annotations = function_tool.mcp_annotations
3067+
assert annotations is not None
3068+
assert annotations.model_dump(by_alias=True, exclude_none=True) == {"readOnlyHint": True}
3069+
assert annotations is not tool.annotations
3070+
3071+
# Consumers and later discovery updates cannot rewrite the converted tool's snapshot.
3072+
annotations.title = "consumer edit"
3073+
assert tool.annotations is not None
3074+
tool.annotations.title = "server edit"
3075+
snapshot = function_tool.mcp_annotations
3076+
assert snapshot is not None
3077+
assert snapshot.model_dump(by_alias=True, exclude_none=True) == {"readOnlyHint": True}
3078+
assert function_tool.needs_approval is False
3079+
assert function_tool._tool_origin is not None
3080+
assert function_tool._tool_origin.to_json_dict() == {
3081+
"type": "mcp",
3082+
"mcp_server_name": server.name,
3083+
}
3084+
3085+
3086+
def test_non_mcp_function_tool_has_no_mcp_annotations():
3087+
async def invoke(context, arguments):
3088+
return "done"
3089+
3090+
tool = FunctionTool("ordinary", "", {}, invoke)
3091+
assert tool.mcp_annotations is None
3092+
3093+
30623094
def test_to_function_tool_description_falls_back_to_mcp_title():
30633095
server = FakeMCPServer()
30643096
tool = MCPTool(

‎tests/mcp/test_runner_calls_mcp.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
FunctionTool,
1010
ModelBehaviorError,
1111
RunContextWrapper,
12+
RunHooks,
1213
Runner,
14+
Tool,
1315
ToolGuardrailFunctionOutput,
1416
ToolInputGuardrailData,
1517
ToolOutputGuardrailData,
@@ -23,6 +25,7 @@
2325

2426
from ..test_responses import get_function_tool_call, get_text_message
2527
from .helpers import FakeMCPServer
28+
from .model_compat import Tool as MCPTool
2629

2730

2831
def _model_tool_outputs(model: ScriptedModel) -> list[Any]:
@@ -36,6 +39,68 @@ def _model_tool_outputs(model: ScriptedModel) -> list[Any]:
3639
return values
3740

3841

42+
@pytest.mark.asyncio
43+
@pytest.mark.parametrize("streaming", [False, True])
44+
async def test_runner_hooks_can_read_mcp_annotations(streaming: bool):
45+
hints = {
46+
"readOnlyHint": False,
47+
"destructiveHint": True,
48+
"idempotentHint": False,
49+
"openWorldHint": False,
50+
}
51+
server = FakeMCPServer(
52+
tools=[
53+
MCPTool(name="write_record", inputSchema={}, annotations=hints),
54+
MCPTool(name="unclassified", inputSchema={}),
55+
]
56+
)
57+
observed: list[tuple[str, str, dict[str, Any] | None]] = []
58+
59+
class Hooks(RunHooks):
60+
def record(self, event: str, tool: Tool) -> None:
61+
assert isinstance(tool, FunctionTool)
62+
annotations = tool.mcp_annotations
63+
observed.append(
64+
(
65+
event,
66+
tool.name,
67+
annotations.model_dump(by_alias=True, exclude_none=True)
68+
if annotations is not None
69+
else None,
70+
)
71+
)
72+
73+
async def on_tool_start(self, context, agent, tool):
74+
self.record("start", tool)
75+
76+
async def on_tool_end(self, context, agent, tool, result):
77+
self.record("end", tool)
78+
79+
model = ScriptedModel(
80+
[
81+
[get_function_tool_call("write_record", "{}")],
82+
[get_function_tool_call("unclassified", "{}", call_id="unclassified_call")],
83+
[get_text_message("done")],
84+
]
85+
)
86+
agent = Agent(name="test", model=model, mcp_servers=[server])
87+
if streaming:
88+
result = Runner.run_streamed(agent, input="user_message", hooks=Hooks())
89+
async for _ in result.stream_events():
90+
pass
91+
else:
92+
result = await Runner.run(agent, input="user_message", hooks=Hooks())
93+
94+
assert result.final_output == "done"
95+
assert server.tool_calls == ["write_record", "unclassified"]
96+
assert observed == [
97+
("start", "write_record", hints),
98+
("end", "write_record", hints),
99+
("start", "unclassified", None),
100+
("end", "unclassified", None),
101+
]
102+
103+
39104
@pytest.mark.asyncio
40105
@pytest.mark.parametrize("streaming", [False, True])
41106
async def test_runner_calls_mcp_tool(streaming: bool):

0 commit comments

Comments
 (0)