fastmcp/tests/tools/test_standalone_decorator.py
Jeremiah Lowin ea7fb8cb2e
Remove 3.x-era compatibility shims (#4661)
* Remove 3.x-era compatibility shims

* Require response_type in ctx.elicit()

* Name the utilities path for the two non-re-exported auth helpers

* Point sampling handler migration at its submodule
2026-07-27 14:59:43 -04:00

203 lines
6.7 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.
"""
import subprocess
import sys
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
@pytest.mark.parametrize(
"statement",
[
"from fastmcp.tools import tool",
"from fastmcp.resources import Resource, resource",
"from fastmcp.prompts import Prompt, prompt",
"import sys; import fastmcp.apps.config; assert 'fastmcp.tools.function_tool' not in sys.modules",
"from fastmcp.server.auth import AuthCheck",
"from fastmcp.server import Context, FastMCP, create_proxy",
],
)
@pytest.mark.subprocess_heavy
def test_component_import_works_in_fresh_interpreter(statement: str):
result = subprocess.run(
[sys.executable, "-c", statement],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
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_staticmethod_metadata_is_available_on_unwrapped_function(self):
"""@tool should attach metadata where staticmethod access can find it."""
class MyClass:
@tool(name="custom-static-tool")
@staticmethod
def my_method() -> str:
return "hello"
created_tool = FunctionTool.from_function(MyClass.my_method)
assert created_tool.name == "custom-static-tool"
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!"