Add support for annotations

This commit is contained in:
Jeremiah Lowin 2025-05-02 14:44:03 -04:00
commit 7301f10b59
6 changed files with 299 additions and 7 deletions

View file

@ -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
<VersionBadge version="2.2.7" />
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`.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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