Add ToolAnnotations support in FastMCP and lowlevel servers (#482)

This commit is contained in:
bhosmer-ant
2025-04-30 09:52:56 -04:00
committed by GitHub
parent 017135434e
commit 1a330ac672
6 changed files with 229 additions and 5 deletions

View File

@@ -41,6 +41,7 @@ from mcp.types import (
GetPromptResult,
ImageContent,
TextContent,
ToolAnnotations,
)
from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
@@ -176,6 +177,7 @@ class FastMCP:
name=info.name,
description=info.description,
inputSchema=info.parameters,
annotations=info.annotations,
)
for info in tools
]
@@ -244,6 +246,7 @@ class FastMCP:
fn: AnyFunction,
name: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
) -> None:
"""Add a tool to the server.
@@ -254,11 +257,17 @@ class FastMCP:
fn: The function to register as a tool
name: Optional name for the tool (defaults to function name)
description: Optional description of what the tool does
annotations: Optional ToolAnnotations providing additional tool information
"""
self._tool_manager.add_tool(fn, name=name, description=description)
self._tool_manager.add_tool(
fn, name=name, description=description, annotations=annotations
)
def tool(
self, name: str | None = None, description: str | None = None
self,
name: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
) -> Callable[[AnyFunction], AnyFunction]:
"""Decorator to register a tool.
@@ -269,6 +278,7 @@ class FastMCP:
Args:
name: Optional name for the tool (defaults to function name)
description: Optional description of what the tool does
annotations: Optional ToolAnnotations providing additional tool information
Example:
@server.tool()
@@ -293,7 +303,9 @@ class FastMCP:
)
def decorator(fn: AnyFunction) -> AnyFunction:
self.add_tool(fn, name=name, description=description)
self.add_tool(
fn, name=name, description=description, annotations=annotations
)
return fn
return decorator

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
from mcp.types import ToolAnnotations
if TYPE_CHECKING:
from mcp.server.fastmcp.server import Context
@@ -30,6 +31,9 @@ class Tool(BaseModel):
context_kwarg: str | None = Field(
None, description="Name of the kwarg that should receive context"
)
annotations: ToolAnnotations | None = Field(
None, description="Optional annotations for the tool"
)
@classmethod
def from_function(
@@ -38,9 +42,10 @@ class Tool(BaseModel):
name: str | None = None,
description: str | None = None,
context_kwarg: str | None = None,
annotations: ToolAnnotations | None = None,
) -> Tool:
"""Create a Tool from a function."""
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.server import Context
func_name = name or fn.__name__
@@ -73,6 +78,7 @@ class Tool(BaseModel):
fn_metadata=func_arg_metadata,
is_async=is_async,
context_kwarg=context_kwarg,
annotations=annotations,
)
async def run(

View File

@@ -7,6 +7,7 @@ from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.fastmcp.tools.base import Tool
from mcp.server.fastmcp.utilities.logging import get_logger
from mcp.shared.context import LifespanContextT
from mcp.types import ToolAnnotations
if TYPE_CHECKING:
from mcp.server.fastmcp.server import Context
@@ -35,9 +36,12 @@ class ToolManager:
fn: Callable[..., Any],
name: str | None = None,
description: str | None = None,
annotations: ToolAnnotations | None = None,
) -> Tool:
"""Add a tool to the server."""
tool = Tool.from_function(fn, name=name, description=description)
tool = Tool.from_function(
fn, name=name, description=description, annotations=annotations
)
existing = self._tools.get(tool.name)
if existing:
if self.warn_on_duplicate_tools:

View File

@@ -705,6 +705,54 @@ class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/lis
params: RequestParams | None = None
class ToolAnnotations(BaseModel):
"""
Additional properties describing a Tool to clients.
NOTE: all properties in ToolAnnotations are **hints**.
They are not guaranteed to provide a faithful description of
tool behavior (including descriptive properties like `title`).
Clients should never make tool use decisions based on ToolAnnotations
received from untrusted servers.
"""
title: str | None = None
"""A human-readable title for the tool."""
readOnlyHint: bool | None = None
"""
If true, the tool does not modify its environment.
Default: false
"""
destructiveHint: bool | None = None
"""
If true, the tool may perform destructive updates to its environment.
If false, the tool performs only additive updates.
(This property is meaningful only when `readOnlyHint == false`)
Default: true
"""
idempotentHint: bool | None = None
"""
If true, calling the tool repeatedly with the same arguments
will have no additional effect on the its environment.
(This property is meaningful only when `readOnlyHint == false`)
Default: false
"""
openWorldHint: bool | None = None
"""
If true, this tool may interact with an "open world" of external
entities. If false, the tool's domain of interaction is closed.
For example, the world of a web search tool is open, whereas that
of a memory tool is not.
Default: true
"""
model_config = ConfigDict(extra="allow")
class Tool(BaseModel):
"""Definition for a tool the client can call."""
@@ -714,6 +762,8 @@ class Tool(BaseModel):
"""A human-readable description of the tool."""
inputSchema: dict[str, Any]
"""A JSON Schema object defining the expected parameters for the tool."""
annotations: ToolAnnotations | None = None
"""Optional additional tool information."""
model_config = ConfigDict(extra="allow")

View File

@@ -9,6 +9,7 @@ from mcp.server.fastmcp.exceptions import ToolError
from mcp.server.fastmcp.tools import ToolManager
from mcp.server.session import ServerSessionT
from mcp.shared.context import LifespanContextT
from mcp.types import ToolAnnotations
class TestAddTools:
@@ -321,3 +322,43 @@ class TestContextHandling:
ctx = mcp.get_context()
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
class TestToolAnnotations:
def test_tool_annotations(self):
"""Test that tool annotations are correctly added to tools."""
def read_data(path: str) -> str:
"""Read data from a file."""
return f"Data from {path}"
annotations = ToolAnnotations(
title="File Reader",
readOnlyHint=True,
openWorldHint=False,
)
manager = ToolManager()
tool = manager.add_tool(read_data, annotations=annotations)
assert tool.annotations is not None
assert tool.annotations.title == "File Reader"
assert tool.annotations.readOnlyHint is True
assert tool.annotations.openWorldHint is False
@pytest.mark.anyio
async def test_tool_annotations_in_fastmcp(self):
"""Test that tool annotations are included in MCPTool conversion."""
app = FastMCP()
@app.tool(annotations=ToolAnnotations(title="Echo Tool", readOnlyHint=True))
def echo(message: str) -> str:
"""Echo a message back."""
return message
tools = await app.list_tools()
assert len(tools) == 1
assert tools[0].annotations is not None
assert tools[0].annotations.title == "Echo Tool"
assert tools[0].annotations.readOnlyHint is True

View File

@@ -0,0 +1,111 @@
"""Tests for tool annotations in low-level server."""
import anyio
import pytest
from mcp.client.session import ClientSession
from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.session import RequestResponder
from mcp.types import (
ClientResult,
JSONRPCMessage,
ServerNotification,
ServerRequest,
Tool,
ToolAnnotations,
)
@pytest.mark.anyio
async def test_lowlevel_server_tool_annotations():
"""Test that tool annotations work in low-level server."""
server = Server("test")
# Create a tool with annotations
@server.list_tools()
async def list_tools():
return [
Tool(
name="echo",
description="Echo a message back",
inputSchema={
"type": "object",
"properties": {
"message": {"type": "string"},
},
"required": ["message"],
},
annotations=ToolAnnotations(
title="Echo Tool",
readOnlyHint=True,
),
)
]
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[
JSONRPCMessage
](10)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[
JSONRPCMessage
](10)
# Message handler for client
async def message_handler(
message: RequestResponder[ServerRequest, ClientResult]
| ServerNotification
| Exception,
) -> None:
if isinstance(message, Exception):
raise message
# Server task
async def run_server():
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test-server",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
) as server_session:
async with anyio.create_task_group() as tg:
async def handle_messages():
async for message in server_session.incoming_messages:
await server._handle_message(message, server_session, {}, False)
tg.start_soon(handle_messages)
await anyio.sleep_forever()
# Run the test
async with anyio.create_task_group() as tg:
tg.start_soon(run_server)
async with ClientSession(
server_to_client_receive,
client_to_server_send,
message_handler=message_handler,
) as client_session:
# Initialize the session
await client_session.initialize()
# List tools
tools_result = await client_session.list_tools()
# Cancel the server task
tg.cancel_scope.cancel()
# Verify results
assert tools_result is not None
assert len(tools_result.tools) == 1
assert tools_result.tools[0].name == "echo"
assert tools_result.tools[0].annotations is not None
assert tools_result.tools[0].annotations.title == "Echo Tool"
assert tools_result.tools[0].annotations.readOnlyHint is True