Jeremiah Lowin commited on
Commit
562e009
·
unverified ·
2 Parent(s): 41f245cc2a9892

Merge pull request #299 from jlowin/annotations

Browse files
docs/servers/tools.mdx CHANGED
@@ -5,6 +5,8 @@ description: Expose functions as executable capabilities for your MCP client.
5
  icon: wrench
6
  ---
7
 
 
 
8
  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.
9
 
10
  ## What Are Tools?
@@ -263,8 +265,46 @@ FastMCP automatically catches exceptions raised within your tool function:
263
 
264
  Using informative exceptions helps the LLM understand failures and react appropriately.
265
 
266
- ## MCP Context
 
 
 
 
 
 
 
 
 
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  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`.
270
 
 
5
  icon: wrench
6
  ---
7
 
8
+ import { VersionBadge } from '/snippets/version-badge.mdx'
9
+
10
  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.
11
 
12
  ## What Are Tools?
 
265
 
266
  Using informative exceptions helps the LLM understand failures and react appropriately.
267
 
268
+ ### Annotations
269
+
270
+ <VersionBadge version="2.2.7" />
271
+
272
+ 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.
273
+
274
+ Annotations serve several purposes in client applications:
275
+ - Adding user-friendly titles for display purposes
276
+ - Indicating whether tools modify data or systems
277
+ - Describing the safety profile of tools (destructive vs. non-destructive)
278
+ - Signaling if tools interact with external systems
279
 
280
+ You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool()` decorator:
281
+
282
+ ```python
283
+ @mcp.tool(
284
+ annotations={
285
+ "title": "Calculate Sum",
286
+ "readOnlyHint": True,
287
+ "openWorldHint": False
288
+ }
289
+ )
290
+ def calculate_sum(a: float, b: float) -> float:
291
+ """Add two numbers together."""
292
+ return a + b
293
+ ```
294
+
295
+ FastMCP supports these standard annotations:
296
+
297
+ | Annotation | Type | Default | Purpose |
298
+ | :--------- | :--- | :------ | :------ |
299
+ | `title` | string | - | Display name for user interfaces |
300
+ | `readOnlyHint` | boolean | false | Indicates if the tool only reads without making changes |
301
+ | `destructiveHint` | boolean | true | For non-readonly tools, signals if changes are destructive |
302
+ | `idempotentHint` | boolean | false | Indicates if repeated identical calls have the same effect as a single call |
303
+ | `openWorldHint` | boolean | true | Specifies if the tool interacts with external systems |
304
+
305
+ 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.
306
+
307
+ ## MCP Context
308
 
309
  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`.
310
 
src/fastmcp/server/openapi.py CHANGED
@@ -10,7 +10,7 @@ from re import Pattern
10
  from typing import TYPE_CHECKING, Any, Literal
11
 
12
  import httpx
13
- from mcp.types import EmbeddedResource, ImageContent, TextContent
14
  from pydantic.networks import AnyUrl
15
 
16
  from fastmcp.resources import Resource, ResourceTemplate
@@ -126,6 +126,7 @@ class OpenAPITool(Tool):
126
  is_async: bool = True,
127
  tags: set[str] = set(),
128
  timeout: float | None = None,
 
129
  ):
130
  super().__init__(
131
  name=name,
@@ -136,6 +137,7 @@ class OpenAPITool(Tool):
136
  is_async=is_async,
137
  context_kwarg="context", # Default context keyword argument
138
  tags=tags,
 
139
  )
140
  self._client = client
141
  self._route = route
 
10
  from typing import TYPE_CHECKING, Any, Literal
11
 
12
  import httpx
13
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
14
  from pydantic.networks import AnyUrl
15
 
16
  from fastmcp.resources import Resource, ResourceTemplate
 
126
  is_async: bool = True,
127
  tags: set[str] = set(),
128
  timeout: float | None = None,
129
+ annotations: ToolAnnotations | None = None,
130
  ):
131
  super().__init__(
132
  name=name,
 
137
  is_async=is_async,
138
  context_kwarg="context", # Default context keyword argument
139
  tags=tags,
140
+ annotations=annotations,
141
  )
142
  self._client = client
143
  self._route = route
src/fastmcp/server/server.py CHANGED
@@ -28,6 +28,7 @@ from mcp.types import (
28
  ImageContent,
29
  PromptMessage,
30
  TextContent,
 
31
  )
32
  from mcp.types import Prompt as MCPPrompt
33
  from mcp.types import Resource as MCPResource
@@ -455,6 +456,7 @@ class FastMCP(Generic[LifespanResultT]):
455
  name: str | None = None,
456
  description: str | None = None,
457
  tags: set[str] | None = None,
 
458
  ) -> None:
459
  """Add a tool to the server.
460
 
@@ -466,9 +468,17 @@ class FastMCP(Generic[LifespanResultT]):
466
  name: Optional name for the tool (defaults to function name)
467
  description: Optional description of what the tool does
468
  tags: Optional set of tags for categorizing the tool
 
469
  """
 
 
 
470
  self._tool_manager.add_tool_from_fn(
471
- fn, name=name, description=description, tags=tags
 
 
 
 
472
  )
473
  self._cache.clear()
474
 
@@ -477,6 +487,7 @@ class FastMCP(Generic[LifespanResultT]):
477
  name: str | None = None,
478
  description: str | None = None,
479
  tags: set[str] | None = None,
 
480
  ) -> Callable[[AnyFunction], AnyFunction]:
481
  """Decorator to register a tool.
482
 
@@ -488,6 +499,7 @@ class FastMCP(Generic[LifespanResultT]):
488
  name: Optional name for the tool (defaults to function name)
489
  description: Optional description of what the tool does
490
  tags: Optional set of tags for categorizing the tool
 
491
 
492
  Example:
493
  @server.tool()
@@ -513,7 +525,13 @@ class FastMCP(Generic[LifespanResultT]):
513
  )
514
 
515
  def decorator(fn: AnyFunction) -> AnyFunction:
516
- self.add_tool(fn, name=name, description=description, tags=tags)
 
 
 
 
 
 
517
  return fn
518
 
519
  return decorator
 
28
  ImageContent,
29
  PromptMessage,
30
  TextContent,
31
+ ToolAnnotations,
32
  )
33
  from mcp.types import Prompt as MCPPrompt
34
  from mcp.types import Resource as MCPResource
 
456
  name: str | None = None,
457
  description: str | None = None,
458
  tags: set[str] | None = None,
459
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
460
  ) -> None:
461
  """Add a tool to the server.
462
 
 
468
  name: Optional name for the tool (defaults to function name)
469
  description: Optional description of what the tool does
470
  tags: Optional set of tags for categorizing the tool
471
+ annotations: Optional annotations about the tool's behavior
472
  """
473
+ if isinstance(annotations, dict):
474
+ annotations = ToolAnnotations(**annotations)
475
+
476
  self._tool_manager.add_tool_from_fn(
477
+ fn,
478
+ name=name,
479
+ description=description,
480
+ tags=tags,
481
+ annotations=annotations,
482
  )
483
  self._cache.clear()
484
 
 
487
  name: str | None = None,
488
  description: str | None = None,
489
  tags: set[str] | None = None,
490
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
491
  ) -> Callable[[AnyFunction], AnyFunction]:
492
  """Decorator to register a tool.
493
 
 
499
  name: Optional name for the tool (defaults to function name)
500
  description: Optional description of what the tool does
501
  tags: Optional set of tags for categorizing the tool
502
+ annotations: Optional annotations about the tool's behavior
503
 
504
  Example:
505
  @server.tool()
 
525
  )
526
 
527
  def decorator(fn: AnyFunction) -> AnyFunction:
528
+ self.add_tool(
529
+ fn,
530
+ name=name,
531
+ description=description,
532
+ tags=tags,
533
+ annotations=annotations,
534
+ )
535
  return fn
536
 
537
  return decorator
src/fastmcp/tools/tool.py CHANGED
@@ -5,7 +5,7 @@ from collections.abc import Callable
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  import pydantic_core
8
- from mcp.types import EmbeddedResource, ImageContent, TextContent
9
  from mcp.types import Tool as MCPTool
10
  from pydantic import BaseModel, BeforeValidator, Field
11
 
@@ -42,6 +42,9 @@ class Tool(BaseModel):
42
  tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
43
  default_factory=set, description="Tags for the tool"
44
  )
 
 
 
45
 
46
  @classmethod
47
  def from_function(
@@ -51,6 +54,7 @@ class Tool(BaseModel):
51
  description: str | None = None,
52
  context_kwarg: str | None = None,
53
  tags: set[str] | None = None,
 
54
  ) -> Tool:
55
  """Create a Tool from a function."""
56
  from fastmcp import Context
@@ -95,6 +99,7 @@ class Tool(BaseModel):
95
  is_async=is_async,
96
  context_kwarg=context_kwarg,
97
  tags=tags or set(),
 
98
  )
99
 
100
  async def run(
@@ -124,6 +129,7 @@ class Tool(BaseModel):
124
  "name": self.name,
125
  "description": self.description,
126
  "inputSchema": self.parameters,
 
127
  }
128
  return MCPTool(**kwargs | overrides)
129
 
 
5
  from typing import TYPE_CHECKING, Annotated, Any
6
 
7
  import pydantic_core
8
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
9
  from mcp.types import Tool as MCPTool
10
  from pydantic import BaseModel, BeforeValidator, Field
11
 
 
42
  tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
43
  default_factory=set, description="Tags for the tool"
44
  )
45
+ annotations: ToolAnnotations | None = Field(
46
+ None, description="Additional annotations about the tool"
47
+ )
48
 
49
  @classmethod
50
  def from_function(
 
54
  description: str | None = None,
55
  context_kwarg: str | None = None,
56
  tags: set[str] | None = None,
57
+ annotations: ToolAnnotations | None = None,
58
  ) -> Tool:
59
  """Create a Tool from a function."""
60
  from fastmcp import Context
 
99
  is_async=is_async,
100
  context_kwarg=context_kwarg,
101
  tags=tags or set(),
102
+ annotations=annotations,
103
  )
104
 
105
  async def run(
 
129
  "name": self.name,
130
  "description": self.description,
131
  "inputSchema": self.parameters,
132
+ "annotations": self.annotations,
133
  }
134
  return MCPTool(**kwargs | overrides)
135
 
src/fastmcp/tools/tool_manager.py CHANGED
@@ -4,7 +4,7 @@ from collections.abc import Callable
4
  from typing import TYPE_CHECKING, Any
5
 
6
  from mcp.shared.context import LifespanContextT
7
- from mcp.types import EmbeddedResource, ImageContent, TextContent
8
 
9
  from fastmcp.exceptions import NotFoundError
10
  from fastmcp.settings import DuplicateBehavior
@@ -61,9 +61,16 @@ class ToolManager:
61
  name: str | None = None,
62
  description: str | None = None,
63
  tags: set[str] | None = None,
 
64
  ) -> Tool:
65
  """Add a tool to the server."""
66
- tool = Tool.from_function(fn, name=name, description=description, tags=tags)
 
 
 
 
 
 
67
  return self.add_tool(tool)
68
 
69
  def add_tool(self, tool: Tool, key: str | None = None) -> Tool:
 
4
  from typing import TYPE_CHECKING, Any
5
 
6
  from mcp.shared.context import LifespanContextT
7
+ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
8
 
9
  from fastmcp.exceptions import NotFoundError
10
  from fastmcp.settings import DuplicateBehavior
 
61
  name: str | None = None,
62
  description: str | None = None,
63
  tags: set[str] | None = None,
64
+ annotations: ToolAnnotations | None = None,
65
  ) -> Tool:
66
  """Add a tool to the server."""
67
+ tool = Tool.from_function(
68
+ fn,
69
+ name=name,
70
+ description=description,
71
+ tags=tags,
72
+ annotations=annotations,
73
+ )
74
  return self.add_tool(tool)
75
 
76
  def add_tool(self, tool: Tool, key: str | None = None) -> Tool:
tests/server/test_tool_annotations.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from mcp.types import TextContent, ToolAnnotations
4
+
5
+ from fastmcp import Client, FastMCP
6
+
7
+
8
+ async def test_tool_annotations_in_tool_manager():
9
+ """Test that tool annotations are correctly stored in the tool manager."""
10
+ mcp = FastMCP("Test Server")
11
+
12
+ @mcp.tool(
13
+ annotations=ToolAnnotations(
14
+ title="Echo Tool",
15
+ readOnlyHint=True,
16
+ openWorldHint=False,
17
+ )
18
+ )
19
+ def echo(message: str) -> str:
20
+ """Echo back the message provided."""
21
+ return message
22
+
23
+ # Check internal tool objects directly
24
+ tools = mcp._tool_manager.list_tools()
25
+ assert len(tools) == 1
26
+ assert tools[0].annotations is not None
27
+ assert tools[0].annotations.title == "Echo Tool"
28
+ assert tools[0].annotations.readOnlyHint is True
29
+ assert tools[0].annotations.openWorldHint is False
30
+
31
+
32
+ async def test_tool_annotations_in_mcp_protocol():
33
+ """Test that tool annotations are correctly propagated to MCP tools list."""
34
+ mcp = FastMCP("Test Server")
35
+
36
+ @mcp.tool(
37
+ annotations=ToolAnnotations(
38
+ title="Echo Tool",
39
+ readOnlyHint=True,
40
+ openWorldHint=False,
41
+ )
42
+ )
43
+ def echo(message: str) -> str:
44
+ """Echo back the message provided."""
45
+ return message
46
+
47
+ # Check via MCP protocol
48
+ mcp_tools = await mcp._mcp_list_tools()
49
+ assert len(mcp_tools) == 1
50
+ assert mcp_tools[0].annotations is not None
51
+ assert mcp_tools[0].annotations.title == "Echo Tool"
52
+ assert mcp_tools[0].annotations.readOnlyHint is True
53
+ assert mcp_tools[0].annotations.openWorldHint is False
54
+
55
+
56
+ async def test_tool_annotations_in_client_api():
57
+ """Test that tool annotations are correctly accessible via client API."""
58
+ mcp = FastMCP("Test Server")
59
+
60
+ @mcp.tool(
61
+ annotations=ToolAnnotations(
62
+ title="Echo Tool",
63
+ readOnlyHint=True,
64
+ openWorldHint=False,
65
+ )
66
+ )
67
+ def echo(message: str) -> str:
68
+ """Echo back the message provided."""
69
+ return message
70
+
71
+ # Check via client API
72
+ async with Client(mcp) as client:
73
+ tools_result = await client.list_tools()
74
+ assert len(tools_result) == 1
75
+ assert tools_result[0].name == "echo"
76
+ assert tools_result[0].annotations is not None
77
+ assert tools_result[0].annotations.title == "Echo Tool"
78
+ assert tools_result[0].annotations.readOnlyHint is True
79
+ assert tools_result[0].annotations.openWorldHint is False
80
+
81
+
82
+ async def test_provide_tool_annotations_as_dict_to_decorator():
83
+ """Test that tool annotations are correctly accessible via client API."""
84
+ mcp = FastMCP("Test Server")
85
+
86
+ @mcp.tool(
87
+ annotations={
88
+ "title": "Echo Tool",
89
+ "readOnlyHint": True,
90
+ "openWorldHint": False,
91
+ }
92
+ )
93
+ def echo(message: str) -> str:
94
+ """Echo back the message provided."""
95
+ return message
96
+
97
+ # Check via client API
98
+ async with Client(mcp) as client:
99
+ tools_result = await client.list_tools()
100
+ assert len(tools_result) == 1
101
+ assert tools_result[0].name == "echo"
102
+ assert tools_result[0].annotations is not None
103
+ assert tools_result[0].annotations.title == "Echo Tool"
104
+ assert tools_result[0].annotations.readOnlyHint is True
105
+ assert tools_result[0].annotations.openWorldHint is False
106
+
107
+
108
+ async def test_direct_tool_annotations_in_tool_manager():
109
+ """Test direct ToolAnnotations object is correctly stored in tool manager."""
110
+ mcp = FastMCP("Test Server")
111
+
112
+ annotations = ToolAnnotations(
113
+ title="Direct Tool",
114
+ readOnlyHint=False,
115
+ destructiveHint=True,
116
+ idempotentHint=False,
117
+ openWorldHint=True,
118
+ )
119
+
120
+ @mcp.tool(annotations=annotations)
121
+ def modify(data: dict[str, Any]) -> dict[str, Any]:
122
+ """Modify the data provided."""
123
+ return {"modified": True, **data}
124
+
125
+ # Check internal tool objects directly
126
+ tools = mcp._tool_manager.list_tools()
127
+ assert len(tools) == 1
128
+ assert tools[0].annotations is not None
129
+ assert tools[0].annotations.title == "Direct Tool"
130
+ assert tools[0].annotations.readOnlyHint is False
131
+ assert tools[0].annotations.destructiveHint is True
132
+ assert tools[0].annotations.idempotentHint is False
133
+ assert tools[0].annotations.openWorldHint is True
134
+
135
+
136
+ async def test_direct_tool_annotations_in_client_api():
137
+ """Test direct ToolAnnotations object is correctly accessible via client API."""
138
+ mcp = FastMCP("Test Server")
139
+
140
+ annotations = ToolAnnotations(
141
+ title="Direct Tool",
142
+ readOnlyHint=False,
143
+ destructiveHint=True,
144
+ idempotentHint=False,
145
+ openWorldHint=True,
146
+ )
147
+
148
+ @mcp.tool(annotations=annotations)
149
+ def modify(data: dict[str, Any]) -> dict[str, Any]:
150
+ """Modify the data provided."""
151
+ return {"modified": True, **data}
152
+
153
+ # Check via client API
154
+ async with Client(mcp) as client:
155
+ tools_result = await client.list_tools()
156
+ assert len(tools_result) == 1
157
+ assert tools_result[0].name == "modify"
158
+ assert tools_result[0].annotations is not None
159
+ assert tools_result[0].annotations.title == "Direct Tool"
160
+ assert tools_result[0].annotations.readOnlyHint is False
161
+ assert tools_result[0].annotations.destructiveHint is True
162
+
163
+
164
+ async def test_add_tool_method_annotations():
165
+ """Test that tool annotations work with add_tool method."""
166
+ mcp = FastMCP("Test Server")
167
+
168
+ def create_item(name: str, value: int) -> dict[str, Any]:
169
+ """Create a new item."""
170
+ return {"name": name, "value": value}
171
+
172
+ mcp.add_tool(
173
+ create_item,
174
+ name="create_item",
175
+ annotations=ToolAnnotations(
176
+ title="Create Item",
177
+ readOnlyHint=False,
178
+ destructiveHint=False,
179
+ ),
180
+ )
181
+
182
+ # Check internal tool objects directly
183
+ tools = mcp._tool_manager.list_tools()
184
+ assert len(tools) == 1
185
+ assert tools[0].annotations is not None
186
+ assert tools[0].annotations.title == "Create Item"
187
+ assert tools[0].annotations.readOnlyHint is False
188
+ assert tools[0].annotations.destructiveHint is False
189
+
190
+
191
+ async def test_tool_functionality_with_annotations():
192
+ """Test that tool functionality is preserved when using annotations."""
193
+ mcp = FastMCP("Test Server")
194
+
195
+ def create_item(name: str, value: int) -> dict[str, Any]:
196
+ """Create a new item."""
197
+ return {"name": name, "value": value}
198
+
199
+ mcp.add_tool(
200
+ create_item,
201
+ name="create_item",
202
+ annotations=ToolAnnotations(
203
+ title="Create Item",
204
+ readOnlyHint=False,
205
+ destructiveHint=False,
206
+ ),
207
+ )
208
+
209
+ # Use the tool to verify functionality is preserved
210
+ async with Client(mcp) as client:
211
+ result = await client.call_tool(
212
+ "create_item", {"name": "test_item", "value": 42}
213
+ )
214
+ assert len(result) == 1
215
+ assert isinstance(result[0], TextContent)
216
+
217
+ # The result should contain the expected JSON
218
+ assert '"name": "test_item"' in result[0].text
219
+ assert '"value": 42' in result[0].text