Skip to content

Commit 3ea2738

Browse files
committed
Add support and tests for ToolAnnotations in FastMCP and lowlevel servers
1 parent c4beb3e commit 3ea2738

File tree

6 files changed

+242
-4
lines changed

6 files changed

+242
-4
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,17 @@ def _setup_handlers(self) -> None:
172172

173173
async def list_tools(self) -> list[MCPTool]:
174174
"""List all available tools."""
175+
from mcp.types import ToolAnnotations
176+
175177
tools = self._tool_manager.list_tools()
176178
return [
177179
MCPTool(
178180
name=info.name,
179181
description=info.description,
180182
inputSchema=info.parameters,
183+
annotations=ToolAnnotations.model_validate(info.annotations)
184+
if info.annotations
185+
else None,
181186
)
182187
for info in tools
183188
]
@@ -246,6 +251,7 @@ def add_tool(
246251
fn: AnyFunction,
247252
name: str | None = None,
248253
description: str | None = None,
254+
annotations: dict[str, Any] | None = None,
249255
) -> None:
250256
"""Add a tool to the server.
251257
@@ -256,11 +262,17 @@ def add_tool(
256262
fn: The function to register as a tool
257263
name: Optional name for the tool (defaults to function name)
258264
description: Optional description of what the tool does
265+
annotations: Optional annotations providing additional tool information
259266
"""
260-
self._tool_manager.add_tool(fn, name=name, description=description)
267+
self._tool_manager.add_tool(
268+
fn, name=name, description=description, annotations=annotations
269+
)
261270

262271
def tool(
263-
self, name: str | None = None, description: str | None = None
272+
self,
273+
name: str | None = None,
274+
description: str | None = None,
275+
annotations: dict[str, Any] | None = None,
264276
) -> Callable[[AnyFunction], AnyFunction]:
265277
"""Decorator to register a tool.
266278
@@ -271,6 +283,7 @@ def tool(
271283
Args:
272284
name: Optional name for the tool (defaults to function name)
273285
description: Optional description of what the tool does
286+
annotations: Optional annotations providing additional tool information
274287
275288
Example:
276289
@server.tool()
@@ -295,7 +308,9 @@ async def async_tool(x: int, context: Context) -> str:
295308
)
296309

297310
def decorator(fn: AnyFunction) -> AnyFunction:
298-
self.add_tool(fn, name=name, description=description)
311+
self.add_tool(
312+
fn, name=name, description=description, annotations=annotations
313+
)
299314
return fn
300315

301316
return decorator

src/mcp/server/fastmcp/tools/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class Tool(BaseModel):
3030
context_kwarg: str | None = Field(
3131
None, description="Name of the kwarg that should receive context"
3232
)
33+
annotations: dict[str, Any] | None = Field(
34+
None, description="Optional annotations for the tool"
35+
)
3336

3437
@classmethod
3538
def from_function(
@@ -38,6 +41,7 @@ def from_function(
3841
name: str | None = None,
3942
description: str | None = None,
4043
context_kwarg: str | None = None,
44+
annotations: dict[str, Any] | None = None,
4145
) -> Tool:
4246
"""Create a Tool from a function."""
4347
from mcp.server.fastmcp import Context
@@ -73,6 +77,7 @@ def from_function(
7377
fn_metadata=func_arg_metadata,
7478
is_async=is_async,
7579
context_kwarg=context_kwarg,
80+
annotations=annotations,
7681
)
7782

7883
async def run(

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ def add_tool(
3535
fn: Callable[..., Any],
3636
name: str | None = None,
3737
description: str | None = None,
38+
annotations: dict[str, Any] | None = None,
3839
) -> Tool:
3940
"""Add a tool to the server."""
40-
tool = Tool.from_function(fn, name=name, description=description)
41+
tool = Tool.from_function(
42+
fn, name=name, description=description, annotations=annotations
43+
)
4144
existing = self._tools.get(tool.name)
4245
if existing:
4346
if self.warn_on_duplicate_tools:

src/mcp/types.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,54 @@ class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/lis
705705
params: RequestParams | None = None
706706

707707

708+
class ToolAnnotations(BaseModel):
709+
"""
710+
Additional properties describing a Tool to clients.
711+
712+
NOTE: all properties in ToolAnnotations are **hints**.
713+
They are not guaranteed to provide a faithful description of
714+
tool behavior (including descriptive properties like `title`).
715+
716+
Clients should never make tool use decisions based on ToolAnnotations
717+
received from untrusted servers.
718+
"""
719+
720+
title: str | None = None
721+
"""A human-readable title for the tool."""
722+
723+
readOnlyHint: bool | None = None
724+
"""
725+
If true, the tool does not modify its environment.
726+
Default: false
727+
"""
728+
729+
destructiveHint: bool | None = None
730+
"""
731+
If true, the tool may perform destructive updates to its environment.
732+
If false, the tool performs only additive updates.
733+
(This property is meaningful only when `readOnlyHint == false`)
734+
Default: true
735+
"""
736+
737+
idempotentHint: bool | None = None
738+
"""
739+
If true, calling the tool repeatedly with the same arguments
740+
will have no additional effect on the its environment.
741+
(This property is meaningful only when `readOnlyHint == false`)
742+
Default: false
743+
"""
744+
745+
openWorldHint: bool | None = None
746+
"""
747+
If true, this tool may interact with an "open world" of external
748+
entities. If false, the tool's domain of interaction is closed.
749+
For example, the world of a web search tool is open, whereas that
750+
of a memory tool is not.
751+
Default: true
752+
"""
753+
model_config = ConfigDict(extra="allow")
754+
755+
708756
class Tool(BaseModel):
709757
"""Definition for a tool the client can call."""
710758

@@ -714,6 +762,8 @@ class Tool(BaseModel):
714762
"""A human-readable description of the tool."""
715763
inputSchema: dict[str, Any]
716764
"""A JSON Schema object defining the expected parameters for the tool."""
765+
annotations: ToolAnnotations | None = None
766+
"""Optional additional tool information."""
717767
model_config = ConfigDict(extra="allow")
718768

719769

tests/server/fastmcp/test_tool_manager.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,43 @@ def tool_with_context(x: int, ctx: Context) -> str:
321321
ctx = mcp.get_context()
322322
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
323323
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
324+
325+
326+
class TestToolAnnotations:
327+
def test_tool_annotations(self):
328+
"""Test that tool annotations are correctly added to tools."""
329+
330+
def read_data(path: str) -> str:
331+
"""Read data from a file."""
332+
return f"Data from {path}"
333+
334+
annotations = {
335+
"title": "File Reader",
336+
"readOnlyHint": True,
337+
"openWorldHint": False,
338+
}
339+
340+
manager = ToolManager()
341+
tool = manager.add_tool(read_data, annotations=annotations)
342+
343+
assert tool.annotations is not None
344+
assert tool.annotations["title"] == "File Reader"
345+
assert tool.annotations["readOnlyHint"] is True
346+
assert tool.annotations["openWorldHint"] is False
347+
348+
@pytest.mark.anyio
349+
async def test_tool_annotations_in_fastmcp(self):
350+
"""Test that tool annotations are included in MCPTool conversion."""
351+
352+
app = FastMCP()
353+
354+
@app.tool(annotations={"title": "Echo Tool", "readOnlyHint": True})
355+
def echo(message: str) -> str:
356+
"""Echo a message back."""
357+
return message
358+
359+
tools = await app.list_tools()
360+
assert len(tools) == 1
361+
assert tools[0].annotations is not None
362+
assert tools[0].annotations.title == "Echo Tool"
363+
assert tools[0].annotations.readOnlyHint is True
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Tests for tool annotations in low-level server."""
2+
3+
import anyio
4+
import pytest
5+
6+
from mcp.client.session import ClientSession
7+
from mcp.server import Server
8+
from mcp.server.lowlevel import NotificationOptions
9+
from mcp.server.models import InitializationOptions
10+
from mcp.server.session import ServerSession
11+
from mcp.shared.session import RequestResponder
12+
from mcp.types import (
13+
Tool,
14+
ToolAnnotations,
15+
JSONRPCMessage,
16+
ListToolsRequest,
17+
ListToolsResult,
18+
InitializeRequestParams,
19+
Implementation,
20+
ClientCapabilities,
21+
ServerRequest,
22+
ClientResult,
23+
ServerNotification,
24+
)
25+
26+
27+
@pytest.mark.anyio
28+
async def test_lowlevel_server_tool_annotations():
29+
"""Test that tool annotations work in low-level server."""
30+
server = Server("test")
31+
32+
# Create a tool with annotations
33+
@server.list_tools()
34+
async def list_tools():
35+
return [
36+
Tool(
37+
name="echo",
38+
description="Echo a message back",
39+
inputSchema={
40+
"type": "object",
41+
"properties": {
42+
"message": {"type": "string"},
43+
},
44+
"required": ["message"],
45+
},
46+
annotations=ToolAnnotations(
47+
title="Echo Tool",
48+
readOnlyHint=True,
49+
),
50+
)
51+
]
52+
53+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
54+
JSONRPCMessage
55+
](10)
56+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
57+
JSONRPCMessage
58+
](10)
59+
60+
# Track results for assertion
61+
tools_result = None
62+
63+
# Message handler for client
64+
async def message_handler(
65+
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
66+
) -> None:
67+
nonlocal tools_result
68+
if isinstance(message, Exception):
69+
raise message
70+
if isinstance(message, RequestResponder):
71+
result = message.message.result
72+
if isinstance(result, dict) and "tools" in result:
73+
tools_result = ListToolsResult.model_validate(result)
74+
75+
# Server task
76+
async def run_server():
77+
async with ServerSession(
78+
client_to_server_receive,
79+
server_to_client_send,
80+
InitializationOptions(
81+
server_name="test-server",
82+
server_version="1.0.0",
83+
capabilities=server.get_capabilities(
84+
notification_options=NotificationOptions(),
85+
experimental_capabilities={},
86+
),
87+
),
88+
) as server_session:
89+
async with anyio.create_task_group() as tg:
90+
91+
async def handle_messages():
92+
async for message in server_session.incoming_messages:
93+
await server._handle_message(
94+
message, server_session, {}, False
95+
)
96+
97+
tg.start_soon(handle_messages)
98+
await anyio.sleep_forever()
99+
100+
# Run the test
101+
async with anyio.create_task_group() as tg:
102+
tg.start_soon(run_server)
103+
104+
async with ClientSession(
105+
server_to_client_receive,
106+
client_to_server_send,
107+
message_handler=message_handler,
108+
) as client_session:
109+
# Initialize the session
110+
await client_session.initialize()
111+
112+
# List tools
113+
tools_result = await client_session.list_tools()
114+
115+
116+
# Cancel the server task
117+
tg.cancel_scope.cancel()
118+
119+
# Verify results
120+
assert tools_result is not None
121+
assert len(tools_result.tools) == 1
122+
assert tools_result.tools[0].name == "echo"
123+
assert tools_result.tools[0].annotations is not None
124+
assert tools_result.tools[0].annotations.title == "Echo Tool"
125+
assert tools_result.tools[0].annotations.readOnlyHint is True

0 commit comments

Comments
 (0)