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
203 lines
6.7 KiB
Python
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!"
|