From 7301f10b594da068c4597bdbd3bbf782b6bde66d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 2 May 2025 14:44:03 -0400 Subject: [PATCH] Add support for annotations --- docs/servers/tools.mdx | 42 ++++- src/fastmcp/server/openapi.py | 4 +- src/fastmcp/server/server.py | 22 ++- src/fastmcp/tools/tool.py | 8 +- src/fastmcp/tools/tool_manager.py | 11 +- tests/server/test_tool_annotations.py | 219 ++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 tests/server/test_tool_annotations.py diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 34cb00601..fb1847b68 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -5,6 +5,8 @@ description: Expose functions as executable capabilities for your MCP client. icon: wrench --- +import { VersionBadge } from '/snippets/version-badge.mdx' + Tools are the core building blocks that allow your LLM to interact with external systems, execute code, and access data that isn't in its training data. In FastMCP, tools are Python functions exposed to LLMs through the MCP protocol. ## What Are Tools? @@ -263,8 +265,46 @@ FastMCP automatically catches exceptions raised within your tool function: Using informative exceptions helps the LLM understand failures and react appropriately. -## MCP Context +### Annotations + + +FastMCP allows you to add specialized metadata to your tools through annotations. These annotations communicate how tools behave to client applications without consuming token context in LLM prompts. + +Annotations serve several purposes in client applications: +- Adding user-friendly titles for display purposes +- Indicating whether tools modify data or systems +- Describing the safety profile of tools (destructive vs. non-destructive) +- Signaling if tools interact with external systems + +You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool()` decorator: + +```python +@mcp.tool( + annotations={ + "title": "Calculate Sum", + "readOnlyHint": True, + "openWorldHint": False + } +) +def calculate_sum(a: float, b: float) -> float: + """Add two numbers together.""" + return a + b +``` + +FastMCP supports these standard annotations: + +| Annotation | Type | Default | Purpose | +| :--------- | :--- | :------ | :------ | +| `title` | string | - | Display name for user interfaces | +| `readOnlyHint` | boolean | false | Indicates if the tool only reads without making changes | +| `destructiveHint` | boolean | true | For non-readonly tools, signals if changes are destructive | +| `idempotentHint` | boolean | false | Indicates if repeated identical calls have the same effect as a single call | +| `openWorldHint` | boolean | true | Specifies if the tool interacts with external systems | + +Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does. + +## MCP Context Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`. diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 86bcc290e..c7fb0c0ca 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -10,7 +10,7 @@ from re import Pattern from typing import TYPE_CHECKING, Any, Literal import httpx -from mcp.types import EmbeddedResource, ImageContent, TextContent +from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations from pydantic.networks import AnyUrl from fastmcp.resources import Resource, ResourceTemplate @@ -126,6 +126,7 @@ class OpenAPITool(Tool): is_async: bool = True, tags: set[str] = set(), timeout: float | None = None, + annotations: ToolAnnotations | None = None, ): super().__init__( name=name, @@ -136,6 +137,7 @@ class OpenAPITool(Tool): is_async=is_async, context_kwarg="context", # Default context keyword argument tags=tags, + annotations=annotations, ) self._client = client self._route = route diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d838570ae..3563727f4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -28,6 +28,7 @@ from mcp.types import ( ImageContent, PromptMessage, TextContent, + ToolAnnotations, ) from mcp.types import Prompt as MCPPrompt from mcp.types import Resource as MCPResource @@ -455,6 +456,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + annotations: ToolAnnotations | dict[str, Any] | None = None, ) -> None: """Add a tool to the server. @@ -466,9 +468,17 @@ class FastMCP(Generic[LifespanResultT]): name: Optional name for the tool (defaults to function name) description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool + annotations: Optional annotations about the tool's behavior """ + if isinstance(annotations, dict): + annotations = ToolAnnotations(**annotations) + self._tool_manager.add_tool_from_fn( - fn, name=name, description=description, tags=tags + fn, + name=name, + description=description, + tags=tags, + annotations=annotations, ) self._cache.clear() @@ -477,6 +487,7 @@ class FastMCP(Generic[LifespanResultT]): name: str | None = None, description: str | None = None, tags: set[str] | None = None, + annotations: ToolAnnotations | dict[str, Any] | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -488,6 +499,7 @@ class FastMCP(Generic[LifespanResultT]): name: Optional name for the tool (defaults to function name) description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool + annotations: Optional annotations about the tool's behavior Example: @server.tool() @@ -513,7 +525,13 @@ class FastMCP(Generic[LifespanResultT]): ) def decorator(fn: AnyFunction) -> AnyFunction: - self.add_tool(fn, name=name, description=description, tags=tags) + self.add_tool( + fn, + name=name, + description=description, + tags=tags, + annotations=annotations, + ) return fn return decorator diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index e35739515..8e641c39f 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any import pydantic_core -from mcp.types import EmbeddedResource, ImageContent, TextContent +from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations from mcp.types import Tool as MCPTool from pydantic import BaseModel, BeforeValidator, Field @@ -42,6 +42,9 @@ class Tool(BaseModel): tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( default_factory=set, description="Tags for the tool" ) + annotations: ToolAnnotations | None = Field( + None, description="Additional annotations about the tool" + ) @classmethod def from_function( @@ -51,6 +54,7 @@ class Tool(BaseModel): description: str | None = None, context_kwarg: str | None = None, tags: set[str] | None = None, + annotations: ToolAnnotations | None = None, ) -> Tool: """Create a Tool from a function.""" from fastmcp import Context @@ -95,6 +99,7 @@ class Tool(BaseModel): is_async=is_async, context_kwarg=context_kwarg, tags=tags or set(), + annotations=annotations, ) async def run( @@ -124,6 +129,7 @@ class Tool(BaseModel): "name": self.name, "description": self.description, "inputSchema": self.parameters, + "annotations": self.annotations, } return MCPTool(**kwargs | overrides) diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 0675cfc65..47a988963 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -4,7 +4,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any from mcp.shared.context import LifespanContextT -from mcp.types import EmbeddedResource, ImageContent, TextContent +from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations from fastmcp.exceptions import NotFoundError from fastmcp.settings import DuplicateBehavior @@ -61,9 +61,16 @@ class ToolManager: name: str | None = None, description: str | None = None, tags: set[str] | None = None, + annotations: ToolAnnotations | None = None, ) -> Tool: """Add a tool to the server.""" - tool = Tool.from_function(fn, name=name, description=description, tags=tags) + tool = Tool.from_function( + fn, + name=name, + description=description, + tags=tags, + annotations=annotations, + ) return self.add_tool(tool) def add_tool(self, tool: Tool, key: str | None = None) -> Tool: diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py new file mode 100644 index 000000000..eee54de76 --- /dev/null +++ b/tests/server/test_tool_annotations.py @@ -0,0 +1,219 @@ +from typing import Any + +from mcp.types import TextContent, ToolAnnotations + +from fastmcp import Client, FastMCP + + +async def test_tool_annotations_in_tool_manager(): + """Test that tool annotations are correctly stored in the tool manager.""" + mcp = FastMCP("Test Server") + + @mcp.tool( + annotations=ToolAnnotations( + title="Echo Tool", + readOnlyHint=True, + openWorldHint=False, + ) + ) + def echo(message: str) -> str: + """Echo back the message provided.""" + return message + + # Check internal tool objects directly + tools = mcp._tool_manager.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 + assert tools[0].annotations.openWorldHint is False + + +async def test_tool_annotations_in_mcp_protocol(): + """Test that tool annotations are correctly propagated to MCP tools list.""" + mcp = FastMCP("Test Server") + + @mcp.tool( + annotations=ToolAnnotations( + title="Echo Tool", + readOnlyHint=True, + openWorldHint=False, + ) + ) + def echo(message: str) -> str: + """Echo back the message provided.""" + return message + + # Check via MCP protocol + mcp_tools = await mcp._mcp_list_tools() + assert len(mcp_tools) == 1 + assert mcp_tools[0].annotations is not None + assert mcp_tools[0].annotations.title == "Echo Tool" + assert mcp_tools[0].annotations.readOnlyHint is True + assert mcp_tools[0].annotations.openWorldHint is False + + +async def test_tool_annotations_in_client_api(): + """Test that tool annotations are correctly accessible via client API.""" + mcp = FastMCP("Test Server") + + @mcp.tool( + annotations=ToolAnnotations( + title="Echo Tool", + readOnlyHint=True, + openWorldHint=False, + ) + ) + def echo(message: str) -> str: + """Echo back the message provided.""" + return message + + # Check via client API + async with Client(mcp) as client: + tools_result = await client.list_tools() + assert len(tools_result) == 1 + assert tools_result[0].name == "echo" + assert tools_result[0].annotations is not None + assert tools_result[0].annotations.title == "Echo Tool" + assert tools_result[0].annotations.readOnlyHint is True + assert tools_result[0].annotations.openWorldHint is False + + +async def test_provide_tool_annotations_as_dict_to_decorator(): + """Test that tool annotations are correctly accessible via client API.""" + mcp = FastMCP("Test Server") + + @mcp.tool( + annotations={ + "title": "Echo Tool", + "readOnlyHint": True, + "openWorldHint": False, + } + ) + def echo(message: str) -> str: + """Echo back the message provided.""" + return message + + # Check via client API + async with Client(mcp) as client: + tools_result = await client.list_tools() + assert len(tools_result) == 1 + assert tools_result[0].name == "echo" + assert tools_result[0].annotations is not None + assert tools_result[0].annotations.title == "Echo Tool" + assert tools_result[0].annotations.readOnlyHint is True + assert tools_result[0].annotations.openWorldHint is False + + +async def test_direct_tool_annotations_in_tool_manager(): + """Test direct ToolAnnotations object is correctly stored in tool manager.""" + mcp = FastMCP("Test Server") + + annotations = ToolAnnotations( + title="Direct Tool", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ) + + @mcp.tool(annotations=annotations) + def modify(data: dict[str, Any]) -> dict[str, Any]: + """Modify the data provided.""" + return {"modified": True, **data} + + # Check internal tool objects directly + tools = mcp._tool_manager.list_tools() + assert len(tools) == 1 + assert tools[0].annotations is not None + assert tools[0].annotations.title == "Direct Tool" + assert tools[0].annotations.readOnlyHint is False + assert tools[0].annotations.destructiveHint is True + assert tools[0].annotations.idempotentHint is False + assert tools[0].annotations.openWorldHint is True + + +async def test_direct_tool_annotations_in_client_api(): + """Test direct ToolAnnotations object is correctly accessible via client API.""" + mcp = FastMCP("Test Server") + + annotations = ToolAnnotations( + title="Direct Tool", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ) + + @mcp.tool(annotations=annotations) + def modify(data: dict[str, Any]) -> dict[str, Any]: + """Modify the data provided.""" + return {"modified": True, **data} + + # Check via client API + async with Client(mcp) as client: + tools_result = await client.list_tools() + assert len(tools_result) == 1 + assert tools_result[0].name == "modify" + assert tools_result[0].annotations is not None + assert tools_result[0].annotations.title == "Direct Tool" + assert tools_result[0].annotations.readOnlyHint is False + assert tools_result[0].annotations.destructiveHint is True + + +async def test_add_tool_method_annotations(): + """Test that tool annotations work with add_tool method.""" + mcp = FastMCP("Test Server") + + def create_item(name: str, value: int) -> dict[str, Any]: + """Create a new item.""" + return {"name": name, "value": value} + + mcp.add_tool( + create_item, + name="create_item", + annotations=ToolAnnotations( + title="Create Item", + readOnlyHint=False, + destructiveHint=False, + ), + ) + + # Check internal tool objects directly + tools = mcp._tool_manager.list_tools() + assert len(tools) == 1 + assert tools[0].annotations is not None + assert tools[0].annotations.title == "Create Item" + assert tools[0].annotations.readOnlyHint is False + assert tools[0].annotations.destructiveHint is False + + +async def test_tool_functionality_with_annotations(): + """Test that tool functionality is preserved when using annotations.""" + mcp = FastMCP("Test Server") + + def create_item(name: str, value: int) -> dict[str, Any]: + """Create a new item.""" + return {"name": name, "value": value} + + mcp.add_tool( + create_item, + name="create_item", + annotations=ToolAnnotations( + title="Create Item", + readOnlyHint=False, + destructiveHint=False, + ), + ) + + # Use the tool to verify functionality is preserved + async with Client(mcp) as client: + result = await client.call_tool( + "create_item", {"name": "test_item", "value": 42} + ) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + + # The result should contain the expected JSON + assert '"name": "test_item"' in result[0].text + assert '"value": 42' in result[0].text