fastmcp/tests/tools/test_standalone_decorator.py
chaoliang yan 2ffe68cfa1
fix: preserve tool decorator metadata (#4072)
Generated with Codex.

Co-authored-by: lawrence3699 <lawrence3699@users.noreply.github.com>
2026-05-04 12:22:31 -04:00

166 lines
5.6 KiB
Python

"""Tests for the standalone @tool decorator.
The @tool decorator attaches metadata to functions without registering them
to a server. Functions can be added explicitly via server.add_tool() or
discovered by FileSystemProvider.
"""
from typing import cast
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.tools import tool
from fastmcp.tools.base import Tool
from fastmcp.tools.function_tool import DecoratedTool, FunctionTool, ToolMeta
class TestToolDecorator:
"""Tests for the @tool decorator."""
def test_tool_without_parens(self):
"""@tool without parentheses should attach metadata."""
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
decorated = cast(DecoratedTool, greet)
assert callable(greet)
assert hasattr(greet, "__fastmcp__")
assert isinstance(decorated.__fastmcp__, ToolMeta)
assert decorated.__fastmcp__.name is None # Uses function name by default
def test_tool_with_empty_parens(self):
"""@tool() with empty parentheses should attach metadata."""
@tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
decorated = cast(DecoratedTool, greet)
assert callable(greet)
assert hasattr(greet, "__fastmcp__")
assert isinstance(decorated.__fastmcp__, ToolMeta)
def test_tool_with_name_arg(self):
"""@tool("name") with name as first arg should work."""
@tool("custom-greet")
def greet(name: str) -> str:
return f"Hello, {name}!"
decorated = cast(DecoratedTool, greet)
assert callable(greet)
assert hasattr(greet, "__fastmcp__")
assert decorated.__fastmcp__.name == "custom-greet"
def test_tool_with_name_kwarg(self):
"""@tool(name="name") with keyword arg should work."""
@tool(name="custom-greet")
def greet(name: str) -> str:
return f"Hello, {name}!"
decorated = cast(DecoratedTool, greet)
assert callable(greet)
assert hasattr(greet, "__fastmcp__")
assert decorated.__fastmcp__.name == "custom-greet"
def test_tool_with_all_metadata(self):
"""@tool with all metadata should store it all."""
@tool(
name="custom-greet",
title="Greeting Tool",
description="Greets people",
tags={"greeting", "demo"},
meta={"custom": "value"},
)
def greet(name: str) -> str:
return f"Hello, {name}!"
decorated = cast(DecoratedTool, greet)
assert callable(greet)
assert hasattr(greet, "__fastmcp__")
assert decorated.__fastmcp__.name == "custom-greet"
assert decorated.__fastmcp__.title == "Greeting Tool"
assert decorated.__fastmcp__.description == "Greets people"
assert decorated.__fastmcp__.tags == {"greeting", "demo"}
assert decorated.__fastmcp__.meta == {"custom": "value"}
@pytest.mark.parametrize(
"factory", [Tool.from_function, FunctionTool.from_function]
)
def test_from_function_preserves_decorator_metadata(self, factory):
"""Direct from_function calls should respect @tool metadata."""
@tool(
name="custom-greet",
version="v1",
title="Greeting Tool",
description="Greets people",
tags={"greeting", "demo"},
meta={"custom": "value"},
)
def greet(name: str) -> str:
"""Fallback description."""
return f"Hello, {name}!"
created_tool = factory(greet)
assert created_tool.name == "custom-greet"
assert created_tool.version == "v1"
assert created_tool.title == "Greeting Tool"
assert created_tool.description == "Greets people"
assert created_tool.tags == {"greeting", "demo"}
assert created_tool.meta == {"custom": "value"}
async def test_tool_function_still_callable(self):
"""Decorated function should still be directly callable."""
@tool
def greet(name: str) -> str:
"""Greet someone."""
return f"Hello, {name}!"
# The function is still callable even though it has metadata
result = cast(DecoratedTool, greet)("World")
assert result == "Hello, World!"
def test_tool_rejects_classmethod_decorator(self):
"""@tool should reject classmethod-decorated functions."""
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@tool
@classmethod
def my_method(cls) -> str:
return "hello"
def test_tool_with_both_name_args_raises(self):
"""@tool should raise if both positional and keyword name are given."""
with pytest.raises(TypeError, match="Cannot specify.*both.*argument.*keyword"):
@tool("name1", name="name2") # type: ignore[call-overload] # ty:ignore[invalid-argument-type]
def my_tool() -> str:
return "hello"
async def test_tool_added_to_server(self):
"""Tool created by @tool should work when added to a server."""
@tool
def greet(name: str) -> str:
"""Greet someone."""
return f"Hello, {name}!"
mcp = FastMCP("Test")
mcp.add_tool(greet)
async with Client(mcp) as client:
tools = await client.list_tools()
assert any(t.name == "greet" for t in tools)
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"