Skip to content

Commit 98f8248

Browse files
mat-octavemaxisbey
andauthored
feat: add tool metadata in FastMCP.tool decorator (modelcontextprotocol#1463)
Co-authored-by: Max Isbey <[email protected]>
1 parent dcc68ce commit 98f8248

File tree

4 files changed

+213
-8
lines changed

4 files changed

+213
-8
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44

55
import inspect
66
import re
7-
from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Iterable, Sequence
7+
from collections.abc import (
8+
AsyncIterator,
9+
Awaitable,
10+
Callable,
11+
Collection,
12+
Iterable,
13+
Sequence,
14+
)
815
from contextlib import AbstractAsyncContextManager, asynccontextmanager
916
from typing import Any, Generic, Literal
1017

@@ -22,10 +29,21 @@
2229
from starlette.types import Receive, Scope, Send
2330

2431
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
25-
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
26-
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
32+
from mcp.server.auth.middleware.bearer_auth import (
33+
BearerAuthBackend,
34+
RequireAuthMiddleware,
35+
)
36+
from mcp.server.auth.provider import (
37+
OAuthAuthorizationServerProvider,
38+
ProviderTokenVerifier,
39+
TokenVerifier,
40+
)
2741
from mcp.server.auth.settings import AuthSettings
28-
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation
42+
from mcp.server.elicitation import (
43+
ElicitationResult,
44+
ElicitSchemaModelT,
45+
elicit_with_validation,
46+
)
2947
from mcp.server.fastmcp.exceptions import ResourceError
3048
from mcp.server.fastmcp.prompts import Prompt, PromptManager
3149
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
@@ -112,7 +130,9 @@ def lifespan_wrapper(
112130
lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]],
113131
) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]:
114132
@asynccontextmanager
115-
async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[LifespanResultT]:
133+
async def wrap(
134+
_: MCPServer[LifespanResultT, Request],
135+
) -> AsyncIterator[LifespanResultT]:
116136
async with lifespan(app) as context:
117137
yield context
118138

@@ -126,7 +146,7 @@ def __init__( # noqa: PLR0913
126146
instructions: str | None = None,
127147
website_url: str | None = None,
128148
icons: list[Icon] | None = None,
129-
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
149+
auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None,
130150
token_verifier: TokenVerifier | None = None,
131151
event_store: EventStore | None = None,
132152
*,
@@ -145,7 +165,7 @@ def __init__( # noqa: PLR0913
145165
warn_on_duplicate_tools: bool = True,
146166
warn_on_duplicate_prompts: bool = True,
147167
dependencies: Collection[str] = (),
148-
lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
168+
lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None,
149169
auth: AuthSettings | None = None,
150170
transport_security: TransportSecuritySettings | None = None,
151171
):
@@ -290,6 +310,7 @@ async def list_tools(self) -> list[MCPTool]:
290310
outputSchema=info.output_schema,
291311
annotations=info.annotations,
292312
icons=info.icons,
313+
_meta=info.meta,
293314
)
294315
for info in tools
295316
]
@@ -365,6 +386,7 @@ def add_tool(
365386
description: str | None = None,
366387
annotations: ToolAnnotations | None = None,
367388
icons: list[Icon] | None = None,
389+
meta: dict[str, Any] | None = None,
368390
structured_output: bool | None = None,
369391
) -> None:
370392
"""Add a tool to the server.
@@ -390,6 +412,7 @@ def add_tool(
390412
description=description,
391413
annotations=annotations,
392414
icons=icons,
415+
meta=meta,
393416
structured_output=structured_output,
394417
)
395418

@@ -411,6 +434,7 @@ def tool(
411434
description: str | None = None,
412435
annotations: ToolAnnotations | None = None,
413436
icons: list[Icon] | None = None,
437+
meta: dict[str, Any] | None = None,
414438
structured_output: bool | None = None,
415439
) -> Callable[[AnyFunction], AnyFunction]:
416440
"""Decorator to register a tool.
@@ -458,6 +482,7 @@ def decorator(fn: AnyFunction) -> AnyFunction:
458482
description=description,
459483
annotations=annotations,
460484
icons=icons,
485+
meta=meta,
461486
structured_output=structured_output,
462487
)
463488
return fn
@@ -1169,7 +1194,10 @@ async def elicit(
11691194
"""
11701195

11711196
return await elicit_with_validation(
1172-
session=self.request_context.session, message=message, schema=schema, related_request_id=self.request_id
1197+
session=self.request_context.session,
1198+
message=message,
1199+
schema=schema,
1200+
related_request_id=self.request_id,
11731201
)
11741202

11751203
async def log(

src/mcp/server/fastmcp/tools/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Tool(BaseModel):
3434
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
3535
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
3636
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
37+
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool")
3738

3839
@cached_property
3940
def output_schema(self) -> dict[str, Any] | None:
@@ -49,6 +50,7 @@ def from_function(
4950
context_kwarg: str | None = None,
5051
annotations: ToolAnnotations | None = None,
5152
icons: list[Icon] | None = None,
53+
meta: dict[str, Any] | None = None,
5254
structured_output: bool | None = None,
5355
) -> Tool:
5456
"""Create a Tool from a function."""
@@ -81,6 +83,7 @@ def from_function(
8183
context_kwarg=context_kwarg,
8284
annotations=annotations,
8385
icons=icons,
86+
meta=meta,
8487
)
8588

8689
async def run(

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def add_tool(
5050
description: str | None = None,
5151
annotations: ToolAnnotations | None = None,
5252
icons: list[Icon] | None = None,
53+
meta: dict[str, Any] | None = None,
5354
structured_output: bool | None = None,
5455
) -> Tool:
5556
"""Add a tool to the server."""
@@ -60,6 +61,7 @@ def add_tool(
6061
description=description,
6162
annotations=annotations,
6263
icons=icons,
64+
meta=meta,
6365
structured_output=structured_output,
6466
)
6567
existing = self._tools.get(tool.name)

tests/server/fastmcp/test_tool_manager.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,178 @@ def get_scores() -> dict[str, int]:
635635
assert result == expected
636636

637637

638+
class TestToolMetadata:
639+
"""Test tool metadata functionality."""
640+
641+
def test_add_tool_with_metadata(self):
642+
"""Test adding a tool with metadata via ToolManager."""
643+
644+
def process_data(input_data: str) -> str:
645+
"""Process some data."""
646+
return f"Processed: {input_data}"
647+
648+
metadata = {"ui": {"type": "form", "fields": ["input"]}, "version": "1.0"}
649+
650+
manager = ToolManager()
651+
tool = manager.add_tool(process_data, meta=metadata)
652+
653+
assert tool.meta is not None
654+
assert tool.meta == metadata
655+
assert tool.meta["ui"]["type"] == "form"
656+
assert tool.meta["version"] == "1.0"
657+
658+
def test_add_tool_without_metadata(self):
659+
"""Test that tools without metadata have None as meta value."""
660+
661+
def simple_tool(x: int) -> int:
662+
"""Simple tool."""
663+
return x * 2
664+
665+
manager = ToolManager()
666+
tool = manager.add_tool(simple_tool)
667+
668+
assert tool.meta is None
669+
670+
@pytest.mark.anyio
671+
async def test_metadata_in_fastmcp_decorator(self):
672+
"""Test that metadata is correctly added via FastMCP.tool decorator."""
673+
674+
app = FastMCP()
675+
676+
metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"}
677+
678+
@app.tool(meta=metadata)
679+
def upload_file(filename: str) -> str:
680+
"""Upload a file."""
681+
return f"Uploaded: {filename}"
682+
683+
# Get the tool from the tool manager
684+
tool = app._tool_manager.get_tool("upload_file")
685+
assert tool is not None
686+
assert tool.meta is not None
687+
assert tool.meta == metadata
688+
assert tool.meta["client"]["ui_component"] == "file_picker"
689+
assert tool.meta["priority"] == "high"
690+
691+
@pytest.mark.anyio
692+
async def test_metadata_in_list_tools(self):
693+
"""Test that metadata is included in MCPTool when listing tools."""
694+
695+
app = FastMCP()
696+
697+
metadata = {
698+
"ui": {"input_type": "textarea", "rows": 5},
699+
"tags": ["text", "processing"],
700+
}
701+
702+
@app.tool(meta=metadata)
703+
def analyze_text(text: str) -> dict[str, Any]:
704+
"""Analyze text content."""
705+
return {"length": len(text), "words": len(text.split())}
706+
707+
tools = await app.list_tools()
708+
assert len(tools) == 1
709+
assert tools[0].meta is not None
710+
assert tools[0].meta == metadata
711+
712+
@pytest.mark.anyio
713+
async def test_multiple_tools_with_different_metadata(self):
714+
"""Test multiple tools with different metadata values."""
715+
716+
app = FastMCP()
717+
718+
metadata1 = {"ui": "form", "version": 1}
719+
metadata2 = {"ui": "picker", "experimental": True}
720+
721+
@app.tool(meta=metadata1)
722+
def tool1(x: int) -> int:
723+
"""First tool."""
724+
return x
725+
726+
@app.tool(meta=metadata2)
727+
def tool2(y: str) -> str:
728+
"""Second tool."""
729+
return y
730+
731+
@app.tool()
732+
def tool3(z: bool) -> bool:
733+
"""Third tool without metadata."""
734+
return z
735+
736+
tools = await app.list_tools()
737+
assert len(tools) == 3
738+
739+
# Find tools by name and check metadata
740+
tools_by_name = {t.name: t for t in tools}
741+
742+
assert tools_by_name["tool1"].meta == metadata1
743+
assert tools_by_name["tool2"].meta == metadata2
744+
assert tools_by_name["tool3"].meta is None
745+
746+
def test_metadata_with_complex_structure(self):
747+
"""Test metadata with complex nested structures."""
748+
749+
def complex_tool(data: str) -> str:
750+
"""Tool with complex metadata."""
751+
return data
752+
753+
metadata = {
754+
"ui": {
755+
"components": [
756+
{"type": "input", "name": "field1", "validation": {"required": True, "minLength": 5}},
757+
{"type": "select", "name": "field2", "options": ["a", "b", "c"]},
758+
],
759+
"layout": {"columns": 2, "responsive": True},
760+
},
761+
"permissions": ["read", "write"],
762+
"tags": ["data-processing", "user-input"],
763+
"version": 2,
764+
}
765+
766+
manager = ToolManager()
767+
tool = manager.add_tool(complex_tool, meta=metadata)
768+
769+
assert tool.meta is not None
770+
assert tool.meta["ui"]["components"][0]["validation"]["minLength"] == 5
771+
assert tool.meta["ui"]["layout"]["columns"] == 2
772+
assert "read" in tool.meta["permissions"]
773+
assert "data-processing" in tool.meta["tags"]
774+
775+
def test_metadata_empty_dict(self):
776+
"""Test that empty dict metadata is preserved."""
777+
778+
def tool_with_empty_meta(x: int) -> int:
779+
"""Tool with empty metadata."""
780+
return x
781+
782+
manager = ToolManager()
783+
tool = manager.add_tool(tool_with_empty_meta, meta={})
784+
785+
assert tool.meta is not None
786+
assert tool.meta == {}
787+
788+
@pytest.mark.anyio
789+
async def test_metadata_with_annotations(self):
790+
"""Test that metadata and annotations can coexist."""
791+
792+
app = FastMCP()
793+
794+
metadata = {"custom": "value"}
795+
annotations = ToolAnnotations(title="Combined Tool", readOnlyHint=True)
796+
797+
@app.tool(meta=metadata, annotations=annotations)
798+
def combined_tool(data: str) -> str:
799+
"""Tool with both metadata and annotations."""
800+
return data
801+
802+
tools = await app.list_tools()
803+
assert len(tools) == 1
804+
assert tools[0].meta == metadata
805+
assert tools[0].annotations is not None
806+
assert tools[0].annotations.title == "Combined Tool"
807+
assert tools[0].annotations.readOnlyHint is True
808+
809+
638810
class TestRemoveTools:
639811
"""Test tool removal functionality in the tool manager."""
640812

0 commit comments

Comments
 (0)