"""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!"