mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Decorators return functions instead of component objects (#2856)
This commit is contained in:
parent
766641a1a5
commit
1b723f302d
38 changed files with 2406 additions and 1606 deletions
121
tests/deprecated/test_function_component_imports.py
Normal file
121
tests/deprecated/test_function_component_imports.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Test that deprecated import paths for function components still work."""
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.utilities.tests import temporary_settings
|
||||
|
||||
|
||||
class TestDeprecatedFunctionToolImports:
|
||||
def test_function_tool_from_tool_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match="Import from fastmcp.tools.function_tool"
|
||||
):
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
|
||||
# Verify it's the real class
|
||||
from fastmcp.tools.function_tool import (
|
||||
FunctionTool as CanonicalFunctionTool,
|
||||
)
|
||||
|
||||
assert FunctionTool is CanonicalFunctionTool
|
||||
|
||||
def test_parsed_function_from_tool_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match="Import from fastmcp.tools.function_tool"
|
||||
):
|
||||
from fastmcp.tools.tool import ParsedFunction
|
||||
|
||||
from fastmcp.tools.function_tool import (
|
||||
ParsedFunction as CanonicalParsedFunction,
|
||||
)
|
||||
|
||||
assert ParsedFunction is CanonicalParsedFunction
|
||||
|
||||
def test_tool_decorator_from_tool_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match="Import from fastmcp.tools.function_tool"
|
||||
):
|
||||
from fastmcp.tools.tool import tool
|
||||
|
||||
from fastmcp.tools.function_tool import tool as canonical_tool
|
||||
|
||||
assert tool is canonical_tool
|
||||
|
||||
def test_no_warning_when_disabled(self):
|
||||
with temporary_settings(deprecation_warnings=False):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
from fastmcp.tools.tool import FunctionTool # noqa: F401
|
||||
|
||||
|
||||
class TestDeprecatedFunctionResourceImports:
|
||||
def test_function_resource_from_resource_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning,
|
||||
match="Import from fastmcp.resources.function_resource",
|
||||
):
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
|
||||
from fastmcp.resources.function_resource import (
|
||||
FunctionResource as CanonicalFunctionResource,
|
||||
)
|
||||
|
||||
assert FunctionResource is CanonicalFunctionResource
|
||||
|
||||
def test_resource_decorator_from_resource_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning,
|
||||
match="Import from fastmcp.resources.function_resource",
|
||||
):
|
||||
from fastmcp.resources.resource import resource
|
||||
|
||||
from fastmcp.resources.function_resource import (
|
||||
resource as canonical_resource,
|
||||
)
|
||||
|
||||
assert resource is canonical_resource
|
||||
|
||||
def test_no_warning_when_disabled(self):
|
||||
with temporary_settings(deprecation_warnings=False):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
from fastmcp.resources.resource import FunctionResource # noqa: F401
|
||||
|
||||
|
||||
class TestDeprecatedFunctionPromptImports:
|
||||
def test_function_prompt_from_prompt_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match="Import from fastmcp.prompts.function_prompt"
|
||||
):
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
|
||||
from fastmcp.prompts.function_prompt import (
|
||||
FunctionPrompt as CanonicalFunctionPrompt,
|
||||
)
|
||||
|
||||
assert FunctionPrompt is CanonicalFunctionPrompt
|
||||
|
||||
def test_prompt_decorator_from_prompt_module(self):
|
||||
with temporary_settings(deprecation_warnings=True):
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match="Import from fastmcp.prompts.function_prompt"
|
||||
):
|
||||
from fastmcp.prompts.prompt import prompt
|
||||
|
||||
from fastmcp.prompts.function_prompt import prompt as canonical_prompt
|
||||
|
||||
assert prompt is canonical_prompt
|
||||
|
||||
def test_no_warning_when_disabled(self):
|
||||
with temporary_settings(deprecation_warnings=False):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
from fastmcp.prompts.prompt import FunctionPrompt # noqa: F401
|
||||
|
|
@ -5,7 +5,8 @@ from mcp.types import TextContent, TextResourceContents
|
|||
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.tools.tool import FunctionTool, Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool import Tool
|
||||
from tests.conftest import get_fn_name
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,47 @@
|
|||
"""Tests for the standalone @prompt decorator.
|
||||
|
||||
The @prompt decorator creates FunctionPrompt objects without registering them
|
||||
to a server. Objects can be added explicitly via server.add_prompt() or
|
||||
The @prompt decorator attaches metadata to functions without registering them
|
||||
to a server. Functions can be added explicitly via server.add_prompt() or
|
||||
discovered by FileSystemProvider.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.prompts import FunctionPrompt, prompt
|
||||
from fastmcp.prompts import prompt
|
||||
from fastmcp.prompts.function_prompt import DecoratedPrompt, PromptMeta
|
||||
|
||||
|
||||
class TestPromptDecorator:
|
||||
"""Tests for the @prompt decorator."""
|
||||
|
||||
def test_prompt_without_parens(self):
|
||||
"""@prompt without parentheses should create a FunctionPrompt."""
|
||||
"""@prompt without parentheses should attach metadata."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "analyze"
|
||||
decorated = cast(DecoratedPrompt, analyze)
|
||||
assert callable(analyze)
|
||||
assert hasattr(analyze, "__fastmcp__")
|
||||
assert isinstance(decorated.__fastmcp__, PromptMeta)
|
||||
assert decorated.__fastmcp__.name is None # Uses function name by default
|
||||
|
||||
def test_prompt_with_empty_parens(self):
|
||||
"""@prompt() with empty parentheses should create a FunctionPrompt."""
|
||||
"""@prompt() with empty parentheses should attach metadata."""
|
||||
|
||||
@prompt()
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "analyze"
|
||||
decorated = cast(DecoratedPrompt, analyze)
|
||||
assert callable(analyze)
|
||||
assert hasattr(analyze, "__fastmcp__")
|
||||
assert isinstance(decorated.__fastmcp__, PromptMeta)
|
||||
|
||||
def test_prompt_with_name_arg(self):
|
||||
"""@prompt("name") with name as first arg should work."""
|
||||
|
|
@ -42,8 +50,10 @@ class TestPromptDecorator:
|
|||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
decorated = cast(DecoratedPrompt, analyze)
|
||||
assert callable(analyze)
|
||||
assert hasattr(analyze, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_name_kwarg(self):
|
||||
"""@prompt(name="name") with keyword arg should work."""
|
||||
|
|
@ -52,8 +62,10 @@ class TestPromptDecorator:
|
|||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
decorated = cast(DecoratedPrompt, analyze)
|
||||
assert callable(analyze)
|
||||
assert hasattr(analyze, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_all_metadata(self):
|
||||
"""@prompt with all metadata should store it all."""
|
||||
|
|
@ -62,29 +74,32 @@ class TestPromptDecorator:
|
|||
name="custom-analyze",
|
||||
title="Analysis Prompt",
|
||||
description="Analyzes topics",
|
||||
tags={"analysis"},
|
||||
tags={"analysis", "demo"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
assert analyze.title == "Analysis Prompt"
|
||||
assert analyze.description == "Analyzes topics"
|
||||
assert analyze.tags == {"analysis"}
|
||||
assert analyze.meta == {"custom": "value"}
|
||||
decorated = cast(DecoratedPrompt, analyze)
|
||||
assert callable(analyze)
|
||||
assert hasattr(analyze, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "custom-analyze"
|
||||
assert decorated.__fastmcp__.title == "Analysis Prompt"
|
||||
assert decorated.__fastmcp__.description == "Analyzes topics"
|
||||
assert decorated.__fastmcp__.tags == {"analysis", "demo"}
|
||||
assert decorated.__fastmcp__.meta == {"custom": "value"}
|
||||
|
||||
async def test_prompt_can_be_rendered(self):
|
||||
"""Prompt created by @prompt should be renderable."""
|
||||
async def test_prompt_function_still_callable(self):
|
||||
"""Decorated function should still be directly callable."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
"""Analyze a topic."""
|
||||
return f"Analyze: {topic}"
|
||||
return f"Please analyze: {topic}"
|
||||
|
||||
result = await analyze.render({"topic": "Python"})
|
||||
assert result.messages[0].content.text == "Analyze: Python" # type: ignore[union-attr]
|
||||
# The function is still callable even though it has metadata
|
||||
result = cast(DecoratedPrompt, analyze)("Python")
|
||||
assert result == "Please analyze: Python"
|
||||
|
||||
def test_prompt_rejects_classmethod_decorator(self):
|
||||
"""@prompt should reject classmethod-decorated functions."""
|
||||
|
|
@ -93,12 +108,12 @@ class TestPromptDecorator:
|
|||
class MyClass:
|
||||
@prompt # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def my_prompt(cls) -> str:
|
||||
return "hello"
|
||||
def my_prompt(cls, topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
def test_prompt_with_both_name_args_raises(self):
|
||||
"""@prompt should raise if both positional and keyword name are given."""
|
||||
with pytest.raises(TypeError, match="Cannot specify both"):
|
||||
with pytest.raises(TypeError, match="Cannot specify.*both.*argument.*keyword"):
|
||||
|
||||
@prompt("name1", name="name2") # type: ignore[call-overload]
|
||||
def my_prompt() -> str:
|
||||
|
|
@ -119,5 +134,5 @@ class TestPromptDecorator:
|
|||
prompts = await client.list_prompts()
|
||||
assert any(p.name == "analyze" for p in prompts)
|
||||
|
||||
result = await client.get_prompt("analyze", {"topic": "Python"})
|
||||
assert "Python" in str(result)
|
||||
result = await client.get_prompt("analyze", {"topic": "FastMCP"})
|
||||
assert "FastMCP" in str(result)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
|
||||
|
||||
class TestFunctionResource:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.resources import ResourceTemplate
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
from fastmcp.resources.template import match_uri_template
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from pydantic import AnyUrl, BaseModel
|
|||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.resources import Resource, ResourceContent, ResourceResult
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
|
||||
|
||||
class TestResourceValidation:
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
"""Tests for the standalone @resource decorator.
|
||||
|
||||
The @resource decorator creates Resource or ResourceTemplate objects without
|
||||
registering them to a server. Objects can be added explicitly via
|
||||
server.add_resource() / server.add_template() or discovered by FileSystemProvider.
|
||||
The @resource decorator attaches metadata to functions without registering them
|
||||
to a server. Functions can be added explicitly via server.add_resource() /
|
||||
server.add_template() or discovered by FileSystemProvider.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.resources import FunctionResource, resource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.resources import resource
|
||||
from fastmcp.resources.function_resource import DecoratedResource, ResourceMeta
|
||||
|
||||
|
||||
class TestResourceDecorator:
|
||||
|
|
@ -25,34 +27,41 @@ class TestResourceDecorator:
|
|||
return "{}"
|
||||
|
||||
def test_resource_with_uri(self):
|
||||
"""@resource("uri") should create a FunctionResource."""
|
||||
"""@resource("uri") should attach metadata."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
assert str(get_config.uri) == "config://app"
|
||||
decorated = cast(DecoratedResource, get_config)
|
||||
assert callable(get_config)
|
||||
assert hasattr(get_config, "__fastmcp__")
|
||||
assert isinstance(decorated.__fastmcp__, ResourceMeta)
|
||||
assert decorated.__fastmcp__.uri == "config://app"
|
||||
|
||||
def test_resource_with_template_uri(self):
|
||||
"""@resource with template URI should create a FunctionResourceTemplate."""
|
||||
"""@resource with template URI should attach metadata."""
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
assert isinstance(get_profile, FunctionResourceTemplate)
|
||||
assert get_profile.uri_template == "users://{user_id}/profile"
|
||||
decorated = cast(DecoratedResource, get_profile)
|
||||
assert callable(get_profile)
|
||||
assert hasattr(get_profile, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.uri == "users://{user_id}/profile"
|
||||
|
||||
def test_resource_with_function_params_becomes_template(self):
|
||||
"""@resource with function params and URI params should create a template."""
|
||||
"""@resource with function params should attach metadata."""
|
||||
|
||||
@resource("data://items/{category}")
|
||||
def get_items(category: str, limit: int = 10) -> list:
|
||||
return list(range(limit))
|
||||
|
||||
assert isinstance(get_items, FunctionResourceTemplate)
|
||||
assert get_items.uri_template == "data://items/{category}"
|
||||
decorated = cast(DecoratedResource, get_items)
|
||||
assert callable(get_items)
|
||||
assert hasattr(get_items, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.uri == "data://items/{category}"
|
||||
|
||||
def test_resource_with_all_metadata(self):
|
||||
"""@resource with all metadata should store it all."""
|
||||
|
|
@ -69,36 +78,39 @@ class TestResourceDecorator:
|
|||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
assert str(get_config.uri) == "config://app"
|
||||
assert get_config.name == "app-config"
|
||||
assert get_config.title == "Application Config"
|
||||
assert get_config.description == "Gets app configuration"
|
||||
assert get_config.mime_type == "application/json"
|
||||
assert get_config.tags == {"config"}
|
||||
assert get_config.meta == {"custom": "value"}
|
||||
decorated = cast(DecoratedResource, get_config)
|
||||
assert callable(get_config)
|
||||
assert hasattr(get_config, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.uri == "config://app"
|
||||
assert decorated.__fastmcp__.name == "app-config"
|
||||
assert decorated.__fastmcp__.title == "Application Config"
|
||||
assert decorated.__fastmcp__.description == "Gets app configuration"
|
||||
assert decorated.__fastmcp__.mime_type == "application/json"
|
||||
assert decorated.__fastmcp__.tags == {"config"}
|
||||
assert decorated.__fastmcp__.meta == {"custom": "value"}
|
||||
|
||||
async def test_resource_can_be_read(self):
|
||||
"""Resource created by @resource should be readable."""
|
||||
async def test_resource_function_still_callable(self):
|
||||
"""Decorated function should still be directly callable."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
"""Get config."""
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
result = await get_config.read()
|
||||
# The function is still callable even though it has metadata
|
||||
result = cast(DecoratedResource, get_config)()
|
||||
assert result == {"setting": "value"}
|
||||
|
||||
def test_resource_rejects_classmethod_decorator(self):
|
||||
"""@resource should reject classmethod-decorated functions."""
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@resource("config://app") # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def get_config(cls) -> str:
|
||||
return "{}"
|
||||
# Note: This now happens when added to server, not at decoration time
|
||||
@resource("config://app")
|
||||
def standalone() -> str:
|
||||
return "{}"
|
||||
|
||||
# Should not raise at decoration
|
||||
assert callable(standalone)
|
||||
|
||||
async def test_resource_added_to_server(self):
|
||||
"""Resource created by @resource should work when added to a server."""
|
||||
|
|
@ -108,7 +120,7 @@ class TestResourceDecorator:
|
|||
"""Get config."""
|
||||
return '{"version": "1.0"}'
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
assert callable(get_config)
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_resource(get_config)
|
||||
|
|
@ -128,10 +140,11 @@ class TestResourceDecorator:
|
|||
"""Get user profile."""
|
||||
return f'{{"id": "{user_id}"}}'
|
||||
|
||||
assert isinstance(get_profile, FunctionResourceTemplate)
|
||||
assert callable(get_profile)
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_template(get_profile)
|
||||
# add_resource handles both resources and templates based on metadata
|
||||
mcp.add_resource(get_profile)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
templates = await client.list_resource_templates()
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ from pydantic import AnyUrl, BaseModel
|
|||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.client.client import CallToolResult, Client
|
||||
from fastmcp.client.transports import FastMCPTransport
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Message, Prompt
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
from fastmcp.prompts.prompt import Message, Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.server.middleware.caching import (
|
||||
CachableToolResult,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ from fastmcp.server.middleware.tool_injection import (
|
|||
ResourceToolMiddleware,
|
||||
ToolInjectionMiddleware,
|
||||
)
|
||||
from fastmcp.tools.tool import FunctionTool, Tool
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
||||
def multiply_fn(a: int, b: int) -> int:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from mcp.types import TextContent
|
|||
|
||||
from fastmcp import Client, Context, FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
|
||||
|
||||
class TestPromptContext:
|
||||
|
|
@ -247,6 +247,10 @@ class TestPromptDecorator:
|
|||
|
||||
async def test_prompt_direct_function_call(self):
|
||||
"""Test that prompts can be registered via direct function call."""
|
||||
from typing import cast
|
||||
|
||||
from fastmcp.prompts.function_prompt import DecoratedPrompt
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
def standalone_function() -> str:
|
||||
|
|
@ -255,11 +259,16 @@ class TestPromptDecorator:
|
|||
|
||||
result_fn = mcp.prompt(standalone_function, name="direct_call_prompt")
|
||||
|
||||
assert isinstance(result_fn, FunctionPrompt)
|
||||
# In new decorator mode, returns the function with metadata
|
||||
decorated = cast(DecoratedPrompt, result_fn)
|
||||
assert hasattr(result_fn, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "direct_call_prompt"
|
||||
assert result_fn is standalone_function
|
||||
|
||||
prompts = await mcp.get_prompts()
|
||||
prompt = next(p for p in prompts if p.name == "direct_call_prompt")
|
||||
assert prompt is result_fn
|
||||
# Prompt is registered separately, not same object as decorated function
|
||||
assert prompt.name == "direct_call_prompt"
|
||||
|
||||
result = await mcp.render_prompt("direct_call_prompt")
|
||||
assert len(result.messages) == 1
|
||||
|
|
|
|||
|
|
@ -793,6 +793,10 @@ class TestToolOutputSchema:
|
|||
assert result.structured_content is None
|
||||
|
||||
async def test_manual_structured_content(self):
|
||||
from typing import cast
|
||||
|
||||
from fastmcp.tools.function_tool import DecoratedTool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
|
|
@ -801,7 +805,12 @@ class TestToolOutputSchema:
|
|||
content="Hello, world!", structured_content={"message": "Hello, world!"}
|
||||
)
|
||||
|
||||
assert f.output_schema is None
|
||||
# In new decorator mode, check metadata instead of attributes
|
||||
from fastmcp.utilities.types import NotSet
|
||||
|
||||
decorated = cast(DecoratedTool, f)
|
||||
assert hasattr(f, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.output_schema is NotSet
|
||||
|
||||
result = await mcp.call_tool("f", {})
|
||||
assert isinstance(result.content, list)
|
||||
|
|
@ -1312,7 +1321,9 @@ class TestToolDecorator:
|
|||
|
||||
async def test_tool_direct_function_call(self):
|
||||
"""Test that tools can be registered via direct function call."""
|
||||
from fastmcp.tools import FunctionTool
|
||||
from typing import cast
|
||||
|
||||
from fastmcp.tools.function_tool import DecoratedTool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
|
|
@ -1322,11 +1333,16 @@ class TestToolDecorator:
|
|||
|
||||
result_fn = mcp.tool(standalone_function, name="direct_call_tool")
|
||||
|
||||
assert isinstance(result_fn, FunctionTool)
|
||||
# In new decorator mode, returns the function with metadata
|
||||
decorated = cast(DecoratedTool, result_fn)
|
||||
assert hasattr(result_fn, "__fastmcp__")
|
||||
assert decorated.__fastmcp__.name == "direct_call_tool"
|
||||
assert result_fn is standalone_function
|
||||
|
||||
tools = await mcp.get_tools()
|
||||
tool = next(t for t in tools if t.name == "direct_call_tool")
|
||||
assert tool is result_fn
|
||||
# Tool is registered separately, not same object as decorated function
|
||||
assert tool.name == "direct_call_tool"
|
||||
|
||||
result = await mcp.call_tool("direct_call_tool", {"x": 5, "y": 3})
|
||||
assert result.structured_content == {"result": 8}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ ValueError when task=True is used with a sync function.
|
|||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.tools.tool import FunctionTool
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
|
||||
|
||||
async def test_sync_tool_with_explicit_task_true_raises():
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import pytest
|
|||
from mcp.types import AnyUrl, TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
|
||||
from fastmcp.resources.resource import FunctionResource, Resource
|
||||
from fastmcp.prompts.function_prompt import FunctionPrompt
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.function_resource import FunctionResource
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
|
|
|||
|
|
@ -1,39 +1,47 @@
|
|||
"""Tests for the standalone @tool decorator.
|
||||
|
||||
The @tool decorator creates FunctionTool objects without registering them
|
||||
to a server. Objects can be added explicitly via server.add_tool() or
|
||||
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 FunctionTool, tool
|
||||
from fastmcp.tools import tool
|
||||
from fastmcp.tools.function_tool import DecoratedTool, ToolMeta
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
"""Tests for the @tool decorator."""
|
||||
|
||||
def test_tool_without_parens(self):
|
||||
"""@tool without parentheses should create a FunctionTool."""
|
||||
"""@tool without parentheses should attach metadata."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "greet"
|
||||
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 create a FunctionTool."""
|
||||
"""@tool() with empty parentheses should attach metadata."""
|
||||
|
||||
@tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "greet"
|
||||
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."""
|
||||
|
|
@ -42,8 +50,10 @@ class TestToolDecorator:
|
|||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
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."""
|
||||
|
|
@ -52,8 +62,10 @@ class TestToolDecorator:
|
|||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
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."""
|
||||
|
|
@ -68,23 +80,26 @@ class TestToolDecorator:
|
|||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
assert greet.title == "Greeting Tool"
|
||||
assert greet.description == "Greets people"
|
||||
assert greet.tags == {"greeting", "demo"}
|
||||
assert greet.meta == {"custom": "value"}
|
||||
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"}
|
||||
|
||||
async def test_tool_can_be_run(self):
|
||||
"""Tool created by @tool should be runnable."""
|
||||
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}!"
|
||||
|
||||
result = await greet.run({"name": "World"})
|
||||
assert result.content[0].text == "Hello, World!" # type: ignore[union-attr]
|
||||
# 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."""
|
||||
|
|
@ -98,7 +113,7 @@ class TestToolDecorator:
|
|||
|
||||
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"):
|
||||
with pytest.raises(TypeError, match="Cannot specify.*both.*argument.*keyword"):
|
||||
|
||||
@tool("name1", name="name2") # type: ignore[call-overload]
|
||||
def my_tool() -> str:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ from fastmcp import FastMCP
|
|||
from fastmcp.client.client import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.tools import Tool, forward, forward_raw
|
||||
from fastmcp.tools.tool import FunctionTool, ToolResult
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
from fastmcp.tools.tool_transform import (
|
||||
ArgTransform,
|
||||
ToolTransformConfig,
|
||||
|
|
@ -1052,7 +1053,10 @@ class TestEnableDisable:
|
|||
def add(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
new_add = Tool.from_tool(add, name="new_add")
|
||||
# Get the registered Tool object from the server
|
||||
add_tool = await mcp._local_provider.get_component("tool:add")
|
||||
assert isinstance(add_tool, Tool)
|
||||
new_add = Tool.from_tool(add_tool, name="new_add")
|
||||
mcp.add_tool(new_add)
|
||||
|
||||
# Disable original tool, but new_add should still work
|
||||
|
|
@ -1076,7 +1080,10 @@ class TestEnableDisable:
|
|||
def add(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
new_add = Tool.from_tool(add, name="new_add")
|
||||
# Get the registered Tool object from the server
|
||||
add_tool = await mcp._local_provider.get_component("tool:add")
|
||||
assert isinstance(add_tool, Tool)
|
||||
new_add = Tool.from_tool(add_tool, name="new_add")
|
||||
mcp.add_tool(new_add)
|
||||
|
||||
# Disable both tools via server
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue