Repository: jlowin/fastmcp (python)

Issue:
Add support for tool annotations
### Enhancement Description

The MCP spec includes [a section for tool annotations](https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations), allowing you to, among other things, provide human readable names to the consuming tools. Presently I don't see a way to pipe these annotations through the `@tool` decorator.

### Use Case

For my part I'd like to provide more human names for tools than the `name` parameter allows, but the uses cases are mostly provided in the spec itself.

### Proposed Implementation

Add an `Annotations` `@dataclass` for use in the tool decorator:

```python
@dataclass
class Annotations:
    title: str = None
    read_only_hint: bool = false
    destructive_hint: bool = true
    idempotent_hint: bool = false
    open_world_hint: bool = true
```

And expose the ability to apply this class from the top level decorator:

```python
@mcp.tool(annotations = Annotations(title="Start a MyCompany™ FooBar job")
def do_my_company_foo_bar:
    pass
```

Pull request description:
Add support for tool annotations
Closes #289

Patch:
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 34cb006..fb1847b 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
+
+<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`.
 
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
index 86bcc29..c7fb0c0 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 d838570..3563727 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 e357395..8e641c3 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 0675cfc..47a9889 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:


Test patch:
diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py
new file mode 100644
index 0000000..eee54de
--- /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