Files
prefecthq--fastmcp/tests/tools/test_standalone_decorator.py
wehub-resource-sync 60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:59 +08: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.authorization import AuthCheck",
"from fastmcp.server import Context, FastMCP, create_proxy",
],
)
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!"