Skip to content

Commit 1a330ac

Browse files
authored
Add ToolAnnotations support in FastMCP and lowlevel servers (#482)
1 parent 0171354 commit 1a330ac

File tree

6 files changed

+229
-5
lines changed

6 files changed

+229
-5
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
GetPromptResult,
4242
ImageContent,
4343
TextContent,
44+
ToolAnnotations,
4445
)
4546
from mcp.types import Prompt as MCPPrompt
4647
from mcp.types import PromptArgument as MCPPromptArgument
@@ -176,6 +177,7 @@ async def list_tools(self) -> list[MCPTool]:
176177
name=info.name,
177178
description=info.description,
178179
inputSchema=info.parameters,
180+
annotations=info.annotations,
179181
)
180182
for info in tools
181183
]
@@ -244,6 +246,7 @@ def add_tool(
244246
fn: AnyFunction,
245247
name: str | None = None,
246248
description: str | None = None,
249+
annotations: ToolAnnotations | None = None,
247250
) -> None:
248251
"""Add a tool to the server.
249252
@@ -254,11 +257,17 @@ def add_tool(
254257
fn: The function to register as a tool
255258
name: Optional name for the tool (defaults to function name)
256259
description: Optional description of what the tool does
260+
annotations: Optional ToolAnnotations providing additional tool information
257261
"""
258-
self._tool_manager.add_tool(fn, name=name, description=description)
262+
self._tool_manager.add_tool(
263+
fn, name=name, description=description, annotations=annotations
264+
)
259265

260266
def tool(
261-
self, name: str | None = None, description: str | None = None
267+
self,
268+
name: str | None = None,
269+
description: str | None = None,
270+
annotations: ToolAnnotations | None = None,
262271
) -> Callable[[AnyFunction], AnyFunction]:
263272
"""Decorator to register a tool.
264273
@@ -269,6 +278,7 @@ def tool(
269278
Args:
270279
name: Optional name for the tool (defaults to function name)
271280
description: Optional description of what the tool does
281+
annotations: Optional ToolAnnotations providing additional tool information
272282
273283
Example:
274284
@server.tool()
@@ -293,7 +303,9 @@ async def async_tool(x: int, context: Context) -> str:
293303
)
294304

295305
def decorator(fn: AnyFunction) -> AnyFunction:
296-
self.add_tool(fn, name=name, description=description)
306+
self.add_tool(
307+
fn, name=name, description=description, annotations=annotations
308+
)
297309
return fn
298310

299311
return decorator

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from mcp.server.fastmcp.exceptions import ToolError
1010
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
11+
from mcp.types import ToolAnnotations
1112

1213
if TYPE_CHECKING:
1314
from mcp.server.fastmcp.server import Context
@@ -30,6 +31,9 @@ class Tool(BaseModel):
3031
context_kwarg: str | None = Field(
3132
None, description="Name of the kwarg that should receive context"
3233
)
34+
annotations: ToolAnnotations | None = Field(
35+
None, description="Optional annotations for the tool"
36+
)
3337

3438
@classmethod
3539
def from_function(
@@ -38,9 +42,10 @@ def from_function(
3842
name: str | None = None,
3943
description: str | None = None,
4044
context_kwarg: str | None = None,
45+
annotations: ToolAnnotations | None = None,
4146
) -> Tool:
4247
"""Create a Tool from a function."""
43-
from mcp.server.fastmcp import Context
48+
from mcp.server.fastmcp.server import Context
4449

4550
func_name = name or fn.__name__
4651

@@ -73,6 +78,7 @@ def from_function(
7378
fn_metadata=func_arg_metadata,
7479
is_async=is_async,
7580
context_kwarg=context_kwarg,
81+
annotations=annotations,
7682
)
7783

7884
async def run(

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from mcp.server.fastmcp.tools.base import Tool
88
from mcp.server.fastmcp.utilities.logging import get_logger
99
from mcp.shared.context import LifespanContextT
10+
from mcp.types import ToolAnnotations
1011

1112
if TYPE_CHECKING:
1213
from mcp.server.fastmcp.server import Context
@@ -35,9 +36,12 @@ def add_tool(
3536
fn: Callable[..., Any],
3637
name: str | None = None,
3738
description: str | None = None,
39+
annotations: ToolAnnotations | None = None,
3840
) -> Tool:
3941
"""Add a tool to the server."""
40-
tool = Tool.from_function(fn, name=name, description=description)
42+
tool = Tool.from_function(
43+
fn, name=name, description=description, annotations=annotations
44+
)
4145
existing = self._tools.get(tool.name)
4246
if existing:
4347
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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from mcp.server.fastmcp.tools import ToolManager
1010
from mcp.server.session import ServerSessionT
1111
from mcp.shared.context import LifespanContextT
12+
from mcp.types import ToolAnnotations
1213

1314

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

0 commit comments

Comments
 (0)