Add standalone decorators and eliminate fastmcp.fs module (#2832)

This commit is contained in:
Jeremiah Lowin 2026-01-10 12:16:35 -05:00 committed by GitHub
commit daa2dace2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1280 additions and 1123 deletions

View file

@ -350,15 +350,6 @@
"python-sdk/fastmcp-client-transports"
]
},
{
"group": "fastmcp.fs",
"pages": [
"python-sdk/fastmcp-fs-__init__",
"python-sdk/fastmcp-fs-decorators",
"python-sdk/fastmcp-fs-discovery",
"python-sdk/fastmcp-fs-provider"
]
},
{
"group": "fastmcp.prompts",
"pages": [

View file

@ -28,16 +28,15 @@ Transformation is also powerful for **environment-aware tools**. You can dynamic
The primary way to create a transformed tool is with the `Tool.from_tool()` class method. At its simplest, you can use it to change a tool's top-level metadata like its `name`, `description`, or `tags`.
In the following simple example, we take a generic `search` tool and adjust its name and description to help an LLM client better understand its purpose.
In the following example, we take a generic `search` tool and adjust its name and description to help an LLM client better understand its purpose.
```python {13-21}
```python {1, 6, 11-19, 22}
from fastmcp.tools import tool, Tool
from fastmcp import FastMCP
from fastmcp.tools import Tool
mcp = FastMCP()
# The original, generic tool
@mcp.tool
# Create a tool without registering it using the standalone @tool decorator
# This creates a Tool object that can be transformed before registration
@tool
def search(query: str, category: str = "all") -> list[dict]:
"""Searches for items in the database."""
return database.search(query, category)
@ -47,39 +46,25 @@ product_search_tool = Tool.from_tool(
search,
name="find_products",
description="""
Search for products in the e-commerce catalog.
Use this when customers ask about finding specific items,
Search for products in the e-commerce catalog.
Use this when customers ask about finding specific items,
checking availability, or browsing product categories.
""",
)
# Only register the transformed version
mcp = FastMCP()
mcp.add_tool(product_search_tool)
```
<Tip>
When you transform a tool, the original tool remains registered on the server. To avoid confusing an LLM with two similar tools, you can disable the original one:
```python
from fastmcp import FastMCP
from fastmcp.tools import Tool
mcp = FastMCP()
# The original, generic tool
@mcp.tool
def search(query: str, category: str = "all") -> list[dict]:
...
# Create a more domain-specific version
product_search_tool = Tool.from_tool(search, ...)
mcp.add_tool(product_search_tool)
# Disable the original tool
search.disable()
```
The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This is the recommended approach for tool transformation because:
- You only register the tools you want exposed
- No need to disable or remove the original
- Cleaner separation between tool creation and registration
</Tip>
Now, clients see a tool named `find_products` with a clear, domain-specific purpose and relevant tags, even though it still uses the original generic `search` function's logic.
Now, clients see a tool named `find_products` with a clear, domain-specific purpose, even though it still uses the original generic `search` function's logic.
### Parameters

View file

@ -15,7 +15,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
Traditional FastMCP servers require coordination between files. Either your tool files import the server to call `@server.tool()`, or your server file imports all the tool modules. Both approaches create coupling that some developers prefer to avoid.
`FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses decorators from `fastmcp.fs` that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file.
`FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses standalone decorators (`@tool`, `@resource`, `@prompt`) that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file.
This is a convention some teams prefer, not necessarily better for all projects. The tradeoffs:
@ -25,20 +25,22 @@ This is a convention some teams prefer, not necessarily better for all projects.
## Quick Start
Create a provider pointing to your components directory, then pass it to your server.
Create a provider pointing to your components directory, then pass it to your server. Use `Path(__file__).parent` to make the path relative to your server file.
```python
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
from pathlib import Path
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
from fastmcp import FastMCP
from fastmcp.server.providers import FileSystemProvider
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
```
In your `mcp/` directory, create Python files with decorated functions.
```python
# mcp/tools/greet.py
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -50,14 +52,14 @@ When the server starts, `FileSystemProvider` scans the directory, imports all Py
## Decorators
The `fastmcp.fs` module provides three decorators that mark functions for discovery: `@tool`, `@resource`, and `@prompt`. These support the full syntax of standard FastMCP decorators—all the same parameters work identically.
FastMCP provides standalone decorators that mark functions for discovery: `@tool` from `fastmcp.tools`, `@resource` from `fastmcp.resources`, and `@prompt` from `fastmcp.prompts`. These support the full syntax of server-bound decorators—all the same parameters work identically.
### @tool
Mark a function as a tool. The function name becomes the tool name by default.
```python
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def calculate_sum(a: float, b: float) -> float:
@ -68,7 +70,7 @@ def calculate_sum(a: float, b: float) -> float:
Customize the tool with optional parameters.
```python
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool(
name="add-numbers",
@ -86,7 +88,7 @@ The decorator supports all standard tool options: `name`, `title`, `description`
Mark a function as a resource. Unlike `@tool`, the `@resource` decorator requires a URI argument.
```python
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("config://app")
def get_app_config() -> str:
@ -97,7 +99,7 @@ def get_app_config() -> str:
URIs with template parameters create resource templates. The provider automatically detects whether to register a static resource or a template based on whether the URI contains `{parameters}` or the function has arguments.
```python
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str:
@ -112,7 +114,7 @@ The decorator supports: `uri` (required), `name`, `title`, `description`, `icons
Mark a function as a prompt template.
```python
from fastmcp.fs import prompt
from fastmcp.prompts import prompt
@prompt
def code_review(code: str, language: str = "python") -> str:
@ -121,7 +123,7 @@ def code_review(code: str, language: str = "python") -> str:
```
```python
from fastmcp.fs import prompt
from fastmcp.prompts import prompt
@prompt(name="explain-concept", tags={"education"})
def explain(topic: str) -> str:
@ -190,9 +192,11 @@ Without `__init__.py`, files are imported directly using `importlib.util.spec_fr
During development, you may want changes to component files to take effect without restarting the server. Enable reload mode to re-scan the directory on every request.
```python
from fastmcp.fs import FileSystemProvider
from pathlib import Path
provider = FileSystemProvider("mcp/", reload=True)
from fastmcp.server.providers import FileSystemProvider
provider = FileSystemProvider(Path(__file__).parent / "mcp", reload=True)
```
With `reload=True`, the provider:
@ -238,7 +242,7 @@ The server entry point is minimal.
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
from fastmcp.server.providers import FileSystemProvider
provider = FileSystemProvider(
root=Path(__file__).parent / "mcp",

View file

@ -1,6 +1,6 @@
"""Assistant prompts."""
from fastmcp.fs import prompt
from fastmcp.prompts import prompt
@prompt

View file

@ -2,7 +2,7 @@
import json
from fastmcp.fs import resource
from fastmcp.resources import resource
# Static resource - no parameters in URI

View file

@ -1,6 +1,6 @@
"""Math tools with custom metadata."""
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool(

View file

@ -1,6 +1,6 @@
"""Greeting tools - multiple tools in one file."""
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool

View file

@ -16,7 +16,7 @@ Dev mode (re-scan files on every request):
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
from fastmcp.server.providers import FileSystemProvider
# The provider scans all .py files in the directory recursively.
# Functions decorated with @tool, @resource, or @prompt are registered.

View file

@ -1,45 +0,0 @@
"""Filesystem-based component discovery for FastMCP.
This module provides decorators and a provider for discovering MCP components
from the filesystem. Files are scanned for functions decorated with @tool,
@resource, or @prompt, and automatically registered with the server.
Example:
```python
# server.py
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
```
```python
# mcp/tools/greet.py
from fastmcp.fs import tool
@tool
def greet(name: str) -> str:
'''Greet someone by name.'''
return f"Hello, {name}!"
```
```python
# mcp/resources/config.py
from fastmcp.fs import resource
@resource("config://app")
def get_config() -> dict:
'''Get application configuration.'''
return {"version": "1.0"}
```
"""
from fastmcp.fs.decorators import prompt, resource, tool
from fastmcp.fs.provider import FileSystemProvider
__all__ = [
"FileSystemProvider",
"prompt",
"resource",
"tool",
]

View file

@ -1,394 +0,0 @@
"""Decorators for marking functions in filesystem-based discovery.
These decorators mark functions with metadata so that FileSystemProvider
can discover and register them. Unlike LocalProvider's decorators, these
do NOT register components immediately - they just store metadata on the
function for later discovery.
Example:
```python
# mcp/tools/greet.py
from fastmcp.fs import tool
@tool
def greet(name: str) -> str:
'''Greet someone by name.'''
return f"Hello, {name}!"
@tool(name="custom-greet", tags={"greeting"})
def my_greet(name: str) -> str:
return f"Hi, {name}!"
```
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, overload
from mcp.types import Annotations, AnyFunction, ToolAnnotations
if TYPE_CHECKING:
import mcp.types
# Attribute name used to store metadata on decorated functions
FS_META_ATTR = "_fastmcp_fs_meta"
@dataclass
class ToolMeta:
"""Metadata stored on functions decorated with @tool."""
type: Literal["tool"] = "tool"
name: str | None = None
title: str | None = None
description: str | None = None
icons: list[mcp.types.Icon] | None = None
tags: set[str] | None = None
output_schema: dict[str, Any] | None = None
annotations: ToolAnnotations | None = None
meta: dict[str, Any] | None = None
@dataclass
class ResourceMeta:
"""Metadata stored on functions decorated with @resource."""
type: Literal["resource"] = "resource"
uri: str = ""
name: str | None = None
title: str | None = None
description: str | None = None
icons: list[mcp.types.Icon] | None = None
mime_type: str | None = None
tags: set[str] | None = None
annotations: Annotations | None = None
meta: dict[str, Any] | None = None
@dataclass
class PromptMeta:
"""Metadata stored on functions decorated with @prompt."""
type: Literal["prompt"] = "prompt"
name: str | None = None
title: str | None = None
description: str | None = None
icons: list[mcp.types.Icon] | None = None
tags: set[str] | None = None
meta: dict[str, Any] | None = None
FSMeta = ToolMeta | ResourceMeta | PromptMeta
def get_fs_meta(fn: Any) -> FSMeta | None:
"""Get filesystem metadata from a function if it has been decorated."""
return getattr(fn, FS_META_ATTR, None)
def has_fs_meta(fn: Any) -> bool:
"""Check if a function has filesystem metadata."""
return hasattr(fn, FS_META_ATTR)
# =============================================================================
# @tool decorator
# =============================================================================
@overload
def tool(fn: AnyFunction) -> AnyFunction: ...
@overload
def tool(
fn: None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | None = None,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> Any: ...
@overload
def tool(
fn: str,
*,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | None = None,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> Any: ...
def tool(
fn: AnyFunction | str | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | None = None,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> Any:
"""Mark a function as a tool for filesystem-based discovery.
This decorator stores metadata on the function but does NOT register it.
FileSystemProvider discovers marked functions when scanning directories.
Supports multiple calling patterns:
- @tool (without parentheses)
- @tool() (with empty parentheses)
- @tool("custom_name") (with name as first argument)
- @tool(name="custom_name") (with keyword arguments)
Args:
fn: The function to decorate, or a name string, or None
name: Optional name for the tool (defaults to function name)
title: Optional title for display
description: Optional description (defaults to docstring)
icons: Optional icons for the tool
tags: Optional tags for categorization
output_schema: Optional JSON schema for output
annotations: Optional tool annotations
meta: Optional metadata dict
Example:
```python
@tool
def greet(name: str) -> str:
'''Greet someone.'''
return f"Hello, {name}!"
@tool(name="custom-greet", tags={"greeting"})
def my_greet(name: str) -> str:
return f"Hi, {name}!"
```
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
def decorator(func: AnyFunction) -> AnyFunction:
tool_meta = ToolMeta(
name=name,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
)
setattr(func, FS_META_ATTR, tool_meta)
return func
if inspect.isroutine(fn):
# @tool without parentheses
return decorator(fn)
elif isinstance(fn, str):
# @tool("custom_name")
return tool(
name=fn,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
)
else:
# @tool() or @tool(name="...") - return decorator
return decorator
# =============================================================================
# @resource decorator
# =============================================================================
def resource(
uri: str,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> Any:
"""Mark a function as a resource for filesystem-based discovery.
This decorator stores metadata on the function but does NOT register it.
FileSystemProvider discovers marked functions when scanning directories.
Unlike @tool and @prompt, @resource REQUIRES a URI argument.
Args:
uri: URI for the resource (e.g., "config://app" or "users://{user_id}")
name: Optional name for the resource
title: Optional title for display
description: Optional description (defaults to docstring)
icons: Optional icons for the resource
mime_type: Optional MIME type
tags: Optional tags for categorization
annotations: Optional resource annotations
meta: Optional metadata dict
Example:
```python
@resource("config://app")
def get_config() -> dict:
return {"setting": "value"}
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id, "name": "User"}
```
"""
if inspect.isroutine(uri):
raise TypeError(
"The @resource decorator requires a URI. "
"Use @resource('uri://...') instead of @resource"
)
if isinstance(annotations, dict):
annotations = Annotations(**annotations)
def decorator(func: AnyFunction) -> AnyFunction:
resource_meta = ResourceMeta(
uri=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
)
setattr(func, FS_META_ATTR, resource_meta)
return func
return decorator
# =============================================================================
# @prompt decorator
# =============================================================================
@overload
def prompt(fn: AnyFunction) -> AnyFunction: ...
@overload
def prompt(
fn: None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
) -> Any: ...
@overload
def prompt(
fn: str,
*,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
) -> Any: ...
def prompt(
fn: AnyFunction | str | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
) -> Any:
"""Mark a function as a prompt for filesystem-based discovery.
This decorator stores metadata on the function but does NOT register it.
FileSystemProvider discovers marked functions when scanning directories.
Supports multiple calling patterns:
- @prompt (without parentheses)
- @prompt() (with empty parentheses)
- @prompt("custom_name") (with name as first argument)
- @prompt(name="custom_name") (with keyword arguments)
Args:
fn: The function to decorate, or a name string, or None
name: Optional name for the prompt (defaults to function name)
title: Optional title for display
description: Optional description (defaults to docstring)
icons: Optional icons for the prompt
tags: Optional tags for categorization
meta: Optional metadata dict
Example:
```python
@prompt
def analyze(topic: str) -> list:
'''Analyze a topic.'''
return [{"role": "user", "content": f"Analyze: {topic}"}]
@prompt(name="custom-analyze")
def my_analyze(topic: str) -> list:
return [{"role": "user", "content": topic}]
```
"""
def decorator(func: AnyFunction) -> AnyFunction:
prompt_meta = PromptMeta(
name=name,
title=title,
description=description,
icons=icons,
tags=tags,
meta=meta,
)
setattr(func, FS_META_ATTR, prompt_meta)
return func
if inspect.isroutine(fn):
# @prompt without parentheses
return decorator(fn)
elif isinstance(fn, str):
# @prompt("custom_name")
return prompt(
name=fn,
title=title,
description=description,
icons=icons,
tags=tags,
meta=meta,
)
else:
# @prompt() or @prompt(name="...") - return decorator
return decorator

View file

@ -1,8 +1,10 @@
from .prompt import Message, Prompt, PromptMessage, PromptResult
from .prompt import FunctionPrompt, Message, Prompt, PromptMessage, PromptResult, prompt
__all__ = [
"FunctionPrompt",
"Message",
"Prompt",
"PromptMessage",
"PromptResult",
"prompt",
]

View file

@ -5,6 +5,7 @@ from __future__ import annotations as _annotations
import inspect
import json
from collections.abc import Callable
from functools import partial
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
import pydantic
@ -608,3 +609,165 @@ class FunctionPrompt(Prompt):
if task_key:
kwargs["key"] = task_key
return await docket.add(lookup_key, **kwargs)(**(arguments or {}))
# Type alias for any function that can be decorated
AnyFunction = Callable[..., Any]
@overload
def prompt(fn: AnyFunction) -> FunctionPrompt: ...
@overload
def prompt(
name_or_fn: str,
*,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
@overload
def prompt(
name_or_fn: None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt
| partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt]
):
"""Standalone decorator to create a prompt without registering it to a server.
This decorator creates a FunctionPrompt object from a function. Unlike
@server.prompt(), this does NOT register the prompt with any server - you must
explicitly add it using server.add_prompt().
This is useful for:
- Creating prompts that will be modified before registration
- Defining prompts in modules that are discovered by FileSystemProvider
- Creating reusable prompt definitions
This decorator supports multiple calling patterns:
- @prompt (without parentheses)
- @prompt() (with empty parentheses)
- @prompt("custom_name") (with name as first argument)
- @prompt(name="custom_name") (with name as keyword argument)
Args:
name_or_fn: Either a function (when used as @prompt), a string name, or None
name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
title: Optional title for the prompt
description: Optional description of what the prompt does
icons: Optional icons for the prompt
tags: Optional set of tags for categorizing the prompt
meta: Optional meta information about the prompt
task: Optional task configuration for background execution (default False)
Returns:
A FunctionPrompt when decorating a function, or a decorator function when
called with parameters.
Example:
```python
from fastmcp.prompts import prompt
from fastmcp import FastMCP
@prompt
def analyze(topic: str) -> str:
return f"Please analyze: {topic}"
@prompt("custom_prompt")
def my_prompt(data: str) -> str:
return f"Process this data: {data}"
# Prompts are not registered yet - add them explicitly
mcp = FastMCP()
mcp.add_prompt(analyze)
mcp.add_prompt(my_prompt)
```
"""
if isinstance(name_or_fn, classmethod):
raise TypeError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
prompt() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Determine the actual name and function based on the calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @prompt (without parens) - function passed directly
fn = name_or_fn
prompt_name = name # Use keyword name if provided, otherwise None
# Default to False for standalone usage (no server to inherit from)
supports_task: bool | TaskConfig = task if task is not None else False
# Create the prompt object without registration
return Prompt.from_function(
fn=fn,
name=prompt_name,
title=title,
description=description,
icons=icons,
tags=tags,
meta=meta,
task=supports_task,
)
elif isinstance(name_or_fn, str):
# Case 2: @prompt("custom_name") - name passed as first argument
if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both."
)
prompt_name = name_or_fn
elif name_or_fn is None:
# Case 3: @prompt() or @prompt(name="something") - use keyword name
prompt_name = name
else:
raise TypeError(
f"First argument to @prompt must be a function, string, or None, got {type(name_or_fn)}"
)
# Return partial for cases where we need to wait for the function
return partial(
prompt,
name=prompt_name,
title=title,
description=description,
icons=icons,
tags=tags,
meta=meta,
task=task,
)

View file

@ -1,4 +1,10 @@
from .resource import FunctionResource, Resource, ResourceContent, ResourceResult
from .resource import (
FunctionResource,
Resource,
ResourceContent,
ResourceResult,
resource,
)
from .template import ResourceTemplate
from .types import (
BinaryResource,
@ -19,4 +25,5 @@ __all__ = [
"ResourceResult",
"ResourceTemplate",
"TextResource",
"resource",
]

View file

@ -12,6 +12,8 @@ import mcp.types
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
from fastmcp.resources.template import ResourceTemplate
import pydantic
import pydantic_core
from mcp.types import Annotations, Icon
@ -484,3 +486,136 @@ class FunctionResource(Resource):
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
# Type alias for any function that can be decorated
AnyFunction = Callable[..., Any]
def resource(
uri: str,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
mime_type: str | None = None,
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
"""Standalone decorator to create a resource without registering it to a server.
This decorator creates a Resource or ResourceTemplate object from a function.
Unlike @server.resource(), this does NOT register the resource with any server -
you must explicitly add it using server.add_resource() or server.add_template().
If the URI contains parameters (e.g. "resource://{param}") or the function
has parameters, it will create a ResourceTemplate instead of a Resource.
This is useful for:
- Creating resources that will be modified before registration
- Defining resources in modules that are discovered by FileSystemProvider
- Creating reusable resource definitions
Args:
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
name: Optional name for the resource
title: Optional title for the resource
description: Optional description of the resource
icons: Optional icons for the resource
mime_type: Optional MIME type for the resource
tags: Optional set of tags for categorizing the resource
annotations: Optional annotations about the resource's behavior
meta: Optional meta information about the resource
task: Optional task configuration for background execution (default False)
Returns:
A decorator function that returns a Resource or ResourceTemplate.
Example:
```python
from fastmcp.resources import resource
from fastmcp import FastMCP
@resource("data://config")
def get_config() -> str:
return '{"setting": "value"}'
@resource("data://{city}/weather")
def get_weather(city: str) -> str:
return f"Weather for {city}"
# Resources are not registered yet - add them explicitly
mcp = FastMCP()
mcp.add_resource(get_config)
mcp.add_template(get_weather)
```
"""
if isinstance(annotations, dict):
annotations = Annotations(**annotations)
# Check if user passed function directly instead of calling decorator
if inspect.isroutine(uri):
raise TypeError(
"The @resource decorator was used incorrectly. "
"Did you forget to call it? Use @resource('uri') instead of @resource"
)
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
if isinstance(fn, classmethod):
raise TypeError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
resource() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Default to False for standalone usage (no server to inherit from)
supports_task: bool | TaskConfig = task if task is not None else False
# Check if this should be a template
has_uri_params = "{" in uri and "}" in uri
# Use wrapper to check for user-facing parameters
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(fn)
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
if has_uri_params or has_func_params:
from fastmcp.resources.template import ResourceTemplate
return ResourceTemplate.from_function(
fn=fn,
uri_template=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
task=supports_task,
)
else:
return Resource.from_function(
fn=fn,
uri=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
task=supports_task,
)
return decorator

View file

@ -29,6 +29,7 @@ from typing import TYPE_CHECKING
from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.filesystem import FileSystemProvider
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.server.providers.transforming import TransformingProvider
@ -38,6 +39,7 @@ if TYPE_CHECKING:
__all__ = [
"FastMCPProvider",
"FileSystemProvider",
"LocalProvider",
"OpenAPIProvider",
"Provider",

View file

@ -1,32 +1,43 @@
"""FileSystemProvider for filesystem-based component discovery.
FileSystemProvider scans a directory for Python files, imports them, and
registers any functions decorated with @tool, @resource, or @prompt.
registers any Tool, Resource, ResourceTemplate, or Prompt objects found.
Components are created using the standalone decorators from fastmcp.tools,
fastmcp.resources, and fastmcp.prompts:
Example:
```python
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
# In mcp/tools.py
from fastmcp.tools import tool
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
# In main.py
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers import FileSystemProvider
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
```
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from fastmcp.fs.decorators import PromptMeta, ResourceMeta, ToolMeta
from fastmcp.fs.discovery import discover_and_import
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.filesystem_discovery import discover_and_import
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
@ -35,8 +46,12 @@ logger = get_logger(__name__)
class FileSystemProvider(LocalProvider):
"""Provider that discovers components from the filesystem.
Scans a directory for Python files and registers functions decorated
with @tool, @resource, or @prompt from fastmcp.fs.
Scans a directory for Python files and registers any Tool, Resource,
ResourceTemplate, or Prompt objects found. Components are created using
the standalone decorators:
- @tool from fastmcp.tools
- @resource from fastmcp.resources
- @prompt from fastmcp.prompts
Args:
root: Root directory to scan. Defaults to current directory.
@ -45,14 +60,24 @@ class FileSystemProvider(LocalProvider):
Example:
```python
from fastmcp import FastMCP
from fastmcp.fs import FileSystemProvider
# In mcp/tools.py
from fastmcp.tools import tool
# Basic usage
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
# In main.py
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers import FileSystemProvider
# Path relative to this file
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
# Dev mode - re-scan on every request
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/", reload=True)])
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp", reload=True)])
```
"""
@ -97,16 +122,18 @@ class FileSystemProvider(LocalProvider):
self._warned_files[file_path] = current_mtime
# Clear warnings for files that now import successfully
successful_files = {fp for fp, _, _ in result.components}
successful_files = {fp for fp, _ in result.components}
for fp in successful_files:
self._warned_files.pop(fp, None)
for file_path, func, meta in result.components:
for file_path, component in result.components:
try:
self._register_component(func, meta)
except Exception as e:
logger.warning(
f"Failed to register {func.__name__} from {file_path}: {e}"
self._register_component(component)
except Exception:
logger.exception(
"Failed to register %s from %s",
getattr(component, "name", repr(component)),
file_path,
)
self._loaded = True
@ -114,88 +141,18 @@ class FileSystemProvider(LocalProvider):
f"FileSystemProvider loaded {len(self._components)} components from {self._root}"
)
def _register_component(
self, func: Any, meta: ToolMeta | ResourceMeta | PromptMeta
) -> None:
"""Register a single component based on its metadata type."""
if isinstance(meta, ToolMeta):
self._register_tool(func, meta)
elif isinstance(meta, ResourceMeta):
self._register_resource(func, meta)
elif isinstance(meta, PromptMeta):
self._register_prompt(func, meta)
def _register_tool(self, func: Any, meta: ToolMeta) -> None:
"""Register a tool from a decorated function."""
tool = Tool.from_function(
fn=func,
name=meta.name,
title=meta.title,
description=meta.description,
icons=meta.icons,
tags=meta.tags,
output_schema=meta.output_schema,
annotations=meta.annotations,
meta=meta.meta,
)
self.add_tool(tool)
def _register_resource(self, func: Any, meta: ResourceMeta) -> None:
"""Register a resource or resource template from a decorated function."""
uri = meta.uri
# Check if this should be a template
has_uri_params = "{" in uri and "}" in uri
# Check for function parameters (excluding injected ones)
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(func)
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
if has_uri_params or has_func_params:
# Register as template
template = ResourceTemplate.from_function(
fn=func,
uri_template=uri,
name=meta.name,
title=meta.title,
description=meta.description,
icons=meta.icons,
mime_type=meta.mime_type,
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
)
self.add_template(template)
def _register_component(self, component: FastMCPComponent) -> None:
"""Register a single component based on its type."""
if isinstance(component, Tool):
self.add_tool(component)
elif isinstance(component, ResourceTemplate):
self.add_template(component)
elif isinstance(component, Resource):
self.add_resource(component)
elif isinstance(component, Prompt):
self.add_prompt(component)
else:
# Register as static resource
resource = Resource.from_function(
fn=func,
uri=uri,
name=meta.name,
title=meta.title,
description=meta.description,
icons=meta.icons,
mime_type=meta.mime_type,
tags=meta.tags,
annotations=meta.annotations,
meta=meta.meta,
)
self.add_resource(resource)
def _register_prompt(self, func: Any, meta: PromptMeta) -> None:
"""Register a prompt from a decorated function."""
prompt = Prompt.from_function(
fn=func,
name=meta.name,
title=meta.title,
description=meta.description,
icons=meta.icons,
tags=meta.tags,
meta=meta.meta,
)
self.add_prompt(prompt)
logger.debug("Ignoring unknown component type: %r", type(component))
async def _ensure_loaded(self) -> None:
"""Ensure components are loaded, reloading if in reload mode.

View file

@ -3,7 +3,7 @@
This module provides functions to:
1. Discover Python files in a directory tree
2. Import modules (as packages if __init__.py exists, else directly)
3. Extract decorated functions from imported modules
3. Extract decorated components (Tool, Resource, Prompt objects) from imported modules
"""
from __future__ import annotations
@ -13,12 +13,8 @@ import sys
from dataclasses import dataclass, field
from pathlib import Path
from types import ModuleType
from typing import Any
from fastmcp.fs.decorators import (
FSMeta,
get_fs_meta,
)
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
@ -28,7 +24,8 @@ logger = get_logger(__name__)
class DiscoveryResult:
"""Result of filesystem discovery."""
components: list[tuple[Path, Any, FSMeta]] = field(default_factory=list)
# Components are real objects (Tool, Resource, ResourceTemplate, Prompt)
components: list[tuple[Path, FastMCPComponent]] = field(default_factory=list)
failed_files: dict[Path, str] = field(default_factory=dict) # path -> error message
@ -175,19 +172,26 @@ def import_module_from_file(file_path: Path) -> ModuleType:
return module
def extract_components(module: ModuleType) -> list[tuple[Any, FSMeta]]:
"""Extract all decorated functions from a module.
def extract_components(module: ModuleType) -> list[FastMCPComponent]:
"""Extract all MCP components from a module.
Scans all module attributes for functions that have been decorated
with @tool, @resource, or @prompt.
Scans all module attributes for instances of Tool, Resource,
ResourceTemplate, or Prompt objects created by standalone decorators.
Args:
module: The imported module to scan.
Returns:
List of (function, metadata) tuples for each decorated function.
List of component objects (Tool, Resource, ResourceTemplate, Prompt).
"""
components: list[tuple[Any, FSMeta]] = []
# Import here to avoid circular imports
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
component_types = (Tool, Resource, ResourceTemplate, Prompt)
components: list[FastMCPComponent] = []
for name in dir(module):
# Skip private/magic attributes
@ -199,10 +203,9 @@ def extract_components(module: ModuleType) -> list[tuple[Any, FSMeta]]:
except AttributeError:
continue
# Check if this object has our marker
meta = get_fs_meta(obj)
if meta is not None:
components.append((obj, meta))
# Check if this object is a component type
if isinstance(obj, component_types):
components.append(obj)
return components
@ -221,7 +224,7 @@ def discover_and_import(root: Path) -> DiscoveryResult:
Note:
Files that fail to import are tracked in failed_files, not logged.
The caller is responsible for logging/handling failures.
Files with no decorated functions are silently skipped.
Files with no components are silently skipped.
"""
result = DiscoveryResult()
@ -236,7 +239,7 @@ def discover_and_import(root: Path) -> DiscoveryResult:
continue
components = extract_components(module)
for func, meta in components:
result.components.append((file_path, func, meta))
for component in components:
result.components.append((file_path, component))
return result

View file

@ -35,7 +35,9 @@ from mcp.types import Annotations, AnyFunction, ToolAnnotations
import fastmcp
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.prompts.prompt import prompt as standalone_prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.resource import resource as standalone_resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.providers.base import Provider
from fastmcp.server.tasks.config import TaskConfig
@ -382,6 +384,10 @@ class LocalProvider(Provider):
serializer: ToolResultSerializerType | None = None, # Deprecated
) -> Callable[[AnyFunction], FunctionTool]: ...
# NOTE: This method mirrors fastmcp.tools.tool() but adds registration,
# the `enabled` param, and supports deprecated params (serializer, exclude_args).
# When deprecated params are removed, this should delegate to the standalone
# decorator to reduce duplication.
def tool(
self,
name_or_fn: str | AnyFunction | None = None,
@ -455,7 +461,7 @@ class LocalProvider(Provider):
annotations = ToolAnnotations(**annotations)
if isinstance(name_or_fn, classmethod):
raise ValueError(
raise TypeError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
@ -579,83 +585,35 @@ class LocalProvider(Provider):
return f"Weather for {city}"
```
"""
if isinstance(annotations, dict):
annotations = Annotations(**annotations)
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Check if user passed function directly instead of calling decorator
if inspect.isroutine(uri):
raise TypeError(
"The @resource decorator was used incorrectly. "
"Did you forget to call it? Use @resource('uri') instead of @resource"
)
# Get the standalone decorator
create_resource = standalone_resource(
uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
task=supports_task,
)
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
if isinstance(fn, classmethod):
raise ValueError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
resource() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Check if this should be a template
has_uri_params = "{" in uri and "}" in uri
# Use wrapper to check for user-facing parameters
from fastmcp.server.dependencies import without_injected_parameters
wrapper_fn = without_injected_parameters(fn)
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
if has_uri_params or has_func_params:
template = ResourceTemplate.from_function(
fn=fn,
uri_template=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
task=supports_task,
)
self.add_template(template)
# If disabled, add to blocklist
if not enabled:
self.disable(keys=[template.key])
return template
elif not has_uri_params and not has_func_params:
resource_obj = Resource.from_function(
fn=fn,
uri=uri,
name=name,
title=title,
description=description,
icons=icons,
mime_type=mime_type,
tags=tags,
annotations=annotations,
meta=meta,
task=supports_task,
)
self.add_resource(resource_obj)
# If disabled, add to blocklist
if not enabled:
self.disable(keys=[resource_obj.key])
return resource_obj
# Delegate to standalone decorator for object creation
obj = create_resource(fn)
# Register with this provider
if isinstance(obj, ResourceTemplate):
self.add_template(obj)
else:
raise ValueError(
"Invalid resource or template definition due to a "
"mismatch between URI parameters and function parameters."
)
self.add_resource(obj)
# Handle enabled flag
if not enabled:
self.disable(keys=[obj.key])
return obj
return decorator
@ -742,70 +700,39 @@ class LocalProvider(Provider):
return [{"role": "user", "content": data}]
```
"""
if isinstance(name_or_fn, classmethod):
raise ValueError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
prompt() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Determine the actual name and function based on the calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @prompt (without parens) - function passed directly
# Case 2: direct call like prompt(fn, name="something")
fn = name_or_fn
prompt_name = name # Use keyword name if provided, otherwise None
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Register the prompt immediately
prompt_obj = Prompt.from_function(
fn=fn,
name=prompt_name,
title=title,
description=description,
icons=icons,
tags=tags,
meta=meta,
task=supports_task,
)
def register(prompt_obj: FunctionPrompt) -> FunctionPrompt:
"""Register the prompt and handle enabled flag."""
self.add_prompt(prompt_obj)
# If disabled, add to blocklist
if not enabled:
self.disable(keys=[prompt_obj.key])
return prompt_obj
elif isinstance(name_or_fn, str):
# Case 3: @prompt("custom_name") - name passed as first argument
if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both."
)
prompt_name = name_or_fn
elif name_or_fn is None:
# Case 4: @prompt() or @prompt(name="something") - use keyword name
prompt_name = name
else:
raise TypeError(
f"First argument to @prompt must be a function, string, or None, got {type(name_or_fn)}"
)
# Resolve task parameter - default to False for standalone usage
supports_task: bool | TaskConfig = task if task is not None else False
# Return partial for cases where we need to wait for the function
return partial(
self.prompt,
name=prompt_name,
# Delegate to standalone decorator for object creation
# Type ignore: standalone_prompt has overloads for specific types, but we pass
# through the union type. Runtime behavior is correct.
result = standalone_prompt(
name_or_fn, # type: ignore[arg-type]
name=name,
title=title,
description=description,
icons=icons,
tags=tags,
enabled=enabled,
meta=meta,
task=task,
task=supports_task,
)
# If standalone returned a FunctionPrompt directly (@prompt without parens),
# register it and return
if isinstance(result, FunctionPrompt):
return register(result)
# Otherwise, standalone returned a decorator/partial - wrap it to register after creation
def decorator(fn: AnyFunction) -> FunctionPrompt:
prompt_obj = result(fn)
return register(prompt_obj)
return decorator

View file

@ -1,4 +1,4 @@
from .tool import FunctionTool, Tool
from .tool import FunctionTool, Tool, tool
from .tool_transform import forward, forward_raw
__all__ = ["FunctionTool", "Tool", "forward", "forward_raw"]
__all__ = ["FunctionTool", "Tool", "forward", "forward_raw", "tool"]

View file

@ -4,6 +4,7 @@ import inspect
import warnings
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import (
TYPE_CHECKING,
Annotated,
@ -764,3 +765,180 @@ def _convert_to_content(
]
# If none of the items are ContentBlocks, aggregate all items into a single TextContent
return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))]
# Type alias for any function that can be decorated
AnyFunction = Callable[..., Any]
@overload
def tool(fn: AnyFunction) -> FunctionTool: ...
@overload
def tool(
name_or_fn: str,
*,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
@overload
def tool(
name_or_fn: None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
def tool(
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
tags: set[str] | None = None,
output_schema: dict[str, Any] | NotSetT | None = NotSet,
annotations: ToolAnnotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
| partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
):
"""Standalone decorator to create a tool without registering it to a server.
This decorator creates a FunctionTool object from a function. Unlike
@server.tool(), this does NOT register the tool with any server - you must
explicitly add it using server.add_tool().
This is useful for:
- Creating tools that will be transformed before registration
- Defining tools in modules that are discovered by FileSystemProvider
- Creating reusable tool definitions
This decorator supports multiple calling patterns:
- @tool (without parentheses)
- @tool() (with empty parentheses)
- @tool("custom_name") (with name as first argument)
- @tool(name="custom_name") (with name as keyword argument)
Args:
name_or_fn: Either a function (when used as @tool), a string name, or None
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
title: Optional title for the tool
description: Optional description of what the tool does
icons: Optional icons for the tool
tags: Optional set of tags for categorizing the tool
output_schema: Optional JSON schema for the tool's output
annotations: Optional annotations about the tool's behavior
meta: Optional meta information about the tool
task: Optional task configuration for background execution (default False)
Returns:
A FunctionTool when decorating a function, or a decorator function when
called with parameters.
Example:
```python
from fastmcp.tools import tool
from fastmcp import FastMCP
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@tool("search_products")
def search(query: str) -> list[dict]:
return database.search(query)
# Tools are not registered yet - add them explicitly
mcp = FastMCP()
mcp.add_tool(greet)
mcp.add_tool(search)
```
"""
if isinstance(annotations, dict):
annotations = ToolAnnotations(**annotations)
if isinstance(name_or_fn, classmethod):
raise TypeError(
inspect.cleandoc(
"""
To decorate a classmethod, first define the method and then call
tool() directly on the method instead of using it as a
decorator. See https://gofastmcp.com/patterns/decorating-methods
for examples and more information.
"""
)
)
# Determine the actual name and function based on the calling pattern
if inspect.isroutine(name_or_fn):
# Case 1: @tool (without parens) - function passed directly
fn = name_or_fn
tool_name = name # Use keyword name if provided, otherwise None
# Default to False for standalone usage (no server to inherit from)
supports_task: bool | TaskConfig = task if task is not None else False
# Create the tool object without registration
return Tool.from_function(
fn,
name=tool_name,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
task=supports_task,
)
elif isinstance(name_or_fn, str):
# Case 2: @tool("custom_name") - name passed as first argument
if name is not None:
raise TypeError(
"Cannot specify both a name as first argument and as keyword argument. "
f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both."
)
tool_name = name_or_fn
elif name_or_fn is None:
# Case 3: @tool() or @tool(name="something") - use keyword name
tool_name = name
else:
raise TypeError(
f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}"
)
# Return partial for cases where we need to wait for the function
return partial(
tool,
name=tool_name,
title=title,
description=description,
icons=icons,
tags=tags,
output_schema=output_schema,
annotations=annotations,
meta=meta,
task=task,
)

View file

@ -1,277 +0,0 @@
"""Tests for fastmcp.fs decorators."""
import pytest
from fastmcp.fs.decorators import (
PromptMeta,
ResourceMeta,
ToolMeta,
get_fs_meta,
has_fs_meta,
prompt,
resource,
tool,
)
class TestToolDecorator:
"""Tests for the @tool decorator."""
def test_tool_without_parens(self):
"""@tool without parentheses should work."""
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
assert has_fs_meta(greet)
meta = get_fs_meta(greet)
assert isinstance(meta, ToolMeta)
assert meta.type == "tool"
assert meta.name is None # Will use function name
def test_tool_with_empty_parens(self):
"""@tool() with empty parentheses should work."""
@tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
assert has_fs_meta(greet)
meta = get_fs_meta(greet)
assert isinstance(meta, 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}!"
meta = get_fs_meta(greet)
assert meta is not None
assert meta.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}!"
meta = get_fs_meta(greet)
assert meta is not None
assert meta.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}!"
meta = get_fs_meta(greet)
assert meta is not None
assert meta.name == "custom-greet"
assert meta.title == "Greeting Tool"
assert meta.description == "Greets people"
assert meta.tags == {"greeting", "demo"}
assert meta.meta == {"custom": "value"}
def test_tool_preserves_function(self):
"""@tool should preserve the original function."""
@tool
def greet(name: str) -> str:
"""Greet someone."""
return f"Hello, {name}!"
# Function should still work
assert greet("World") == "Hello, World!"
assert greet.__name__ == "greet"
assert greet.__doc__ == "Greet someone."
class TestResourceDecorator:
"""Tests for the @resource decorator."""
def test_resource_requires_uri(self):
"""@resource should require a URI argument."""
with pytest.raises(TypeError, match="requires a URI"):
@resource # type: ignore[arg-type]
def get_config() -> str:
return "{}"
def test_resource_with_uri(self):
"""@resource("uri") should store the URI."""
@resource("config://app")
def get_config() -> dict:
return {"setting": "value"}
assert has_fs_meta(get_config)
meta = get_fs_meta(get_config)
assert isinstance(meta, ResourceMeta)
assert meta.type == "resource"
assert meta.uri == "config://app"
def test_resource_with_template_uri(self):
"""@resource with template URI should work."""
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id}
meta = get_fs_meta(get_profile)
assert isinstance(meta, ResourceMeta)
assert meta.uri == "users://{user_id}/profile"
def test_resource_with_all_metadata(self):
"""@resource with all metadata should store it all."""
@resource(
"config://app",
name="app-config",
title="Application Config",
description="Gets app configuration",
mime_type="application/json",
tags={"config"},
meta={"custom": "value"},
)
def get_config() -> dict:
return {"setting": "value"}
meta = get_fs_meta(get_config)
assert isinstance(meta, ResourceMeta)
assert meta.uri == "config://app"
assert meta.name == "app-config"
assert meta.title == "Application Config"
assert meta.description == "Gets app configuration"
assert meta.mime_type == "application/json"
assert meta.tags == {"config"}
assert meta.meta == {"custom": "value"}
def test_resource_preserves_function(self):
"""@resource should preserve the original function."""
@resource("config://app")
def get_config() -> dict:
"""Get config."""
return {"setting": "value"}
# Function should still work
assert get_config() == {"setting": "value"}
assert get_config.__name__ == "get_config"
assert get_config.__doc__ == "Get config."
class TestPromptDecorator:
"""Tests for the @prompt decorator."""
def test_prompt_without_parens(self):
"""@prompt without parentheses should work."""
@prompt
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
assert has_fs_meta(analyze)
meta = get_fs_meta(analyze)
assert isinstance(meta, PromptMeta)
assert meta.type == "prompt"
assert meta.name is None
def test_prompt_with_empty_parens(self):
"""@prompt() with empty parentheses should work."""
@prompt()
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
assert has_fs_meta(analyze)
meta = get_fs_meta(analyze)
assert isinstance(meta, PromptMeta)
def test_prompt_with_name_arg(self):
"""@prompt("name") with name as first arg should work."""
@prompt("custom-analyze")
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
meta = get_fs_meta(analyze)
assert meta is not None
assert meta.name == "custom-analyze"
def test_prompt_with_name_kwarg(self):
"""@prompt(name="name") with keyword arg should work."""
@prompt(name="custom-analyze")
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
meta = get_fs_meta(analyze)
assert meta is not None
assert meta.name == "custom-analyze"
def test_prompt_with_all_metadata(self):
"""@prompt with all metadata should store it all."""
@prompt(
name="custom-analyze",
title="Analysis Prompt",
description="Analyzes topics",
tags={"analysis"},
meta={"custom": "value"},
)
def analyze(topic: str) -> list:
return [{"role": "user", "content": f"Analyze: {topic}"}]
meta = get_fs_meta(analyze)
assert meta is not None
assert meta.name == "custom-analyze"
assert meta.title == "Analysis Prompt"
assert meta.description == "Analyzes topics"
assert meta.tags == {"analysis"}
assert meta.meta == {"custom": "value"}
def test_prompt_preserves_function(self):
"""@prompt should preserve the original function."""
@prompt
def analyze(topic: str) -> list:
"""Analyze a topic."""
return [{"role": "user", "content": f"Analyze: {topic}"}]
# Function should still work
result = analyze("Python")
assert result == [{"role": "user", "content": "Analyze: Python"}]
assert analyze.__name__ == "analyze"
assert analyze.__doc__ == "Analyze a topic."
class TestHelperFunctions:
"""Tests for helper functions."""
def test_has_fs_meta_false_for_undecorated(self):
"""has_fs_meta should return False for undecorated functions."""
def plain_function():
pass
assert not has_fs_meta(plain_function)
def test_get_fs_meta_none_for_undecorated(self):
"""get_fs_meta should return None for undecorated functions."""
def plain_function():
pass
assert get_fs_meta(plain_function) is None

View file

@ -1,14 +1,15 @@
"""Tests for fastmcp.fs discovery module."""
"""Tests for filesystem discovery module."""
from pathlib import Path
from fastmcp.fs.decorators import ToolMeta
from fastmcp.fs.discovery import (
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.server.providers.filesystem_discovery import (
discover_and_import,
discover_files,
extract_components,
import_module_from_file,
)
from fastmcp.tools import FunctionTool
class TestDiscoverFiles:
@ -173,7 +174,7 @@ class TestExtractComponents:
"""Tests for extract_components function."""
def test_extract_no_components(self, tmp_path: Path):
"""Should return empty list for module with no decorated functions."""
"""Should return empty list for module with no components."""
py_file = tmp_path / "plain.py"
py_file.write_text(
"""\
@ -189,11 +190,11 @@ SOME_VAR = 42
assert components == []
def test_extract_tool_component(self, tmp_path: Path):
"""Should extract @tool decorated functions."""
"""Should extract Tool objects."""
py_file = tmp_path / "tools.py"
py_file.write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -205,16 +206,18 @@ def greet(name: str) -> str:
components = extract_components(module)
assert len(components) == 1
func, meta = components[0]
assert func.__name__ == "greet"
assert isinstance(meta, ToolMeta)
component = components[0]
assert isinstance(component, FunctionTool)
assert component.name == "greet"
def test_extract_multiple_components(self, tmp_path: Path):
"""Should extract multiple decorated functions."""
"""Should extract multiple component types."""
py_file = tmp_path / "multi.py"
py_file.write_text(
"""\
from fastmcp.fs import tool, resource, prompt
from fastmcp.tools import tool
from fastmcp.resources import resource
from fastmcp.prompts import prompt
@tool
def greet(name: str) -> str:
@ -225,8 +228,8 @@ def get_config() -> dict:
return {}
@prompt
def analyze(topic: str) -> list:
return []
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
"""
)
@ -234,21 +237,22 @@ def analyze(topic: str) -> list:
components = extract_components(module)
assert len(components) == 3
names = {func.__name__ for func, _ in components}
assert names == {"greet", "get_config", "analyze"}
types = {type(c).__name__ for c in components}
assert types == {"FunctionTool", "FunctionResource", "FunctionPrompt"}
def test_extract_skips_private_functions(self, tmp_path: Path):
"""Should skip private functions even if decorated."""
def test_extract_skips_private_components(self, tmp_path: Path):
"""Should skip private components (those starting with _)."""
py_file = tmp_path / "private.py"
py_file.write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def public_tool() -> str:
return "public"
@tool
# The module attribute starts with _, so it's skipped during discovery
@tool("private_tool_name")
def _private_tool() -> str:
return "private"
"""
@ -257,10 +261,31 @@ def _private_tool() -> str:
module = import_module_from_file(py_file)
components = extract_components(module)
# Only public tool should be found (private starts with _)
# Only public_tool should be found (_private_tool starts with _, so skipped)
assert len(components) == 1
func, _ = components[0]
assert func.__name__ == "public_tool"
component = components[0]
assert component.name == "public_tool"
def test_extract_resource_template(self, tmp_path: Path):
"""Should extract ResourceTemplate objects."""
py_file = tmp_path / "templates.py"
py_file.write_text(
"""\
from fastmcp.resources import resource
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id}
"""
)
module = import_module_from_file(py_file)
components = extract_components(module)
assert len(components) == 1
component = components[0]
assert isinstance(component, FunctionResourceTemplate)
assert component.uri_template == "users://{user_id}/profile"
class TestDiscoverAndImport:
@ -278,7 +303,7 @@ class TestDiscoverAndImport:
tools_dir.mkdir()
(tools_dir / "greet.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -289,16 +314,16 @@ def greet(name: str) -> str:
result = discover_and_import(tmp_path)
assert len(result.components) == 1
file_path, func, meta = result.components[0]
file_path, component = result.components[0]
assert file_path.name == "greet.py"
assert func.__name__ == "greet"
assert isinstance(meta, ToolMeta)
assert isinstance(component, FunctionTool)
assert component.name == "greet"
def test_discover_and_import_skips_bad_imports(self, tmp_path: Path):
"""Should skip files that fail to import and track them."""
(tmp_path / "good.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def good_tool() -> str:
@ -318,8 +343,8 @@ def bad_function():
# Only good.py should be imported
assert len(result.components) == 1
_, func, _ = result.components[0]
assert func.__name__ == "good_tool"
_, component = result.components[0]
assert component.name == "good_tool"
# bad.py should be in failed_files
assert len(result.failed_files) == 1

View file

@ -1,11 +1,11 @@
"""Tests for fastmcp.fs FileSystemProvider."""
"""Tests for FileSystemProvider."""
import time
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.fs import FileSystemProvider
from fastmcp.server.providers import FileSystemProvider
class TestFileSystemProvider:
@ -22,7 +22,7 @@ class TestFileSystemProvider:
tools_dir.mkdir()
(tools_dir / "greet.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -40,7 +40,7 @@ def greet(name: str) -> str:
"""Provider should discover @resource decorated functions."""
(tmp_path / "config.py").write_text(
"""\
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("config://app")
def get_config() -> dict:
@ -56,7 +56,7 @@ def get_config() -> dict:
"""Provider should discover resource templates."""
(tmp_path / "users.py").write_text(
"""\
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> dict:
@ -72,7 +72,7 @@ def get_profile(user_id: str) -> dict:
"""Provider should discover @prompt decorated functions."""
(tmp_path / "analyze.py").write_text(
"""\
from fastmcp.fs import prompt
from fastmcp.prompts import prompt
@prompt
def analyze(topic: str) -> list:
@ -88,7 +88,8 @@ def analyze(topic: str) -> list:
"""Provider should discover multiple components in one file."""
(tmp_path / "multi.py").write_text(
"""\
from fastmcp.fs import tool, resource, prompt
from fastmcp.tools import tool
from fastmcp.resources import resource
@tool
def tool1() -> str:
@ -119,7 +120,7 @@ SOME_CONSTANT = 42
)
(tmp_path / "tool.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def my_tool() -> str:
@ -139,7 +140,7 @@ class TestFileSystemProviderReloadMode:
"""With reload=False, components are cached at init."""
(tmp_path / "tool.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def original() -> str:
@ -153,7 +154,7 @@ def original() -> str:
# Add another file - should NOT be picked up
(tmp_path / "tool2.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def added() -> str:
@ -168,7 +169,7 @@ def added() -> str:
"""With reload=True, components are rescanned on each request."""
(tmp_path / "tool.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def original() -> str:
@ -185,7 +186,7 @@ def original() -> str:
# Add another file - should be picked up on next _ensure_loaded
(tmp_path / "tool2.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def added() -> str:
@ -252,7 +253,7 @@ def added() -> str:
time.sleep(0.01)
bad_file.write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def my_tool() -> str:
@ -284,7 +285,7 @@ class TestFileSystemProviderIntegration:
"""FileSystemProvider should work with FastMCP server."""
(tmp_path / "greet.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -310,7 +311,7 @@ def greet(name: str) -> str:
"""FileSystemProvider should work with resources."""
(tmp_path / "config.py").write_text(
"""\
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("config://app")
def get_config() -> str:
@ -336,7 +337,7 @@ def get_config() -> str:
"""FileSystemProvider should work with resource templates."""
(tmp_path / "users.py").write_text(
"""\
from fastmcp.fs import resource
from fastmcp.resources import resource
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> str:
@ -361,7 +362,7 @@ def get_profile(user_id: str) -> str:
"""FileSystemProvider should work with prompts."""
(tmp_path / "analyze.py").write_text(
"""\
from fastmcp.fs import prompt
from fastmcp.prompts import prompt
@prompt
def analyze(topic: str) -> str:
@ -390,7 +391,7 @@ def analyze(topic: str) -> str:
tools.mkdir()
(tools / "greet.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def greet(name: str) -> str:
@ -402,7 +403,7 @@ def greet(name: str) -> str:
payments.mkdir()
(payments / "charge.py").write_text(
"""\
from fastmcp.fs import tool
from fastmcp.tools import tool
@tool
def charge(amount: float) -> str:

View file

@ -0,0 +1,123 @@
"""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
discovered by FileSystemProvider.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.prompts import FunctionPrompt, prompt
class TestPromptDecorator:
"""Tests for the @prompt decorator."""
def test_prompt_without_parens(self):
"""@prompt without parentheses should create a FunctionPrompt."""
@prompt
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
assert isinstance(analyze, FunctionPrompt)
assert analyze.name == "analyze"
def test_prompt_with_empty_parens(self):
"""@prompt() with empty parentheses should create a FunctionPrompt."""
@prompt()
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
assert isinstance(analyze, FunctionPrompt)
assert analyze.name == "analyze"
def test_prompt_with_name_arg(self):
"""@prompt("name") with name as first arg should work."""
@prompt("custom-analyze")
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
assert isinstance(analyze, FunctionPrompt)
assert analyze.name == "custom-analyze"
def test_prompt_with_name_kwarg(self):
"""@prompt(name="name") with keyword arg should work."""
@prompt(name="custom-analyze")
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
assert isinstance(analyze, FunctionPrompt)
assert analyze.name == "custom-analyze"
def test_prompt_with_all_metadata(self):
"""@prompt with all metadata should store it all."""
@prompt(
name="custom-analyze",
title="Analysis Prompt",
description="Analyzes topics",
tags={"analysis"},
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"}
async def test_prompt_can_be_rendered(self):
"""Prompt created by @prompt should be renderable."""
@prompt
def analyze(topic: str) -> str:
"""Analyze a topic."""
return f"Analyze: {topic}"
result = await analyze.render({"topic": "Python"})
assert result.messages[0].content.text == "Analyze: Python" # type: ignore[union-attr]
def test_prompt_rejects_classmethod_decorator(self):
"""@prompt should reject classmethod-decorated functions."""
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@prompt # type: ignore[arg-type]
@classmethod
def my_prompt(cls) -> str:
return "hello"
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"):
@prompt("name1", name="name2") # type: ignore[call-overload]
def my_prompt() -> str:
return "hello"
async def test_prompt_added_to_server(self):
"""Prompt created by @prompt should work when added to a server."""
@prompt
def analyze(topic: str) -> str:
"""Analyze a topic."""
return f"Please analyze: {topic}"
mcp = FastMCP("Test")
mcp.add_prompt(analyze)
async with Client(mcp) as client:
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)

View file

@ -0,0 +1,141 @@
"""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.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.resources import FunctionResource, resource
from fastmcp.resources.template import FunctionResourceTemplate
class TestResourceDecorator:
"""Tests for the @resource decorator."""
def test_resource_requires_uri(self):
"""@resource should require a URI argument."""
with pytest.raises(TypeError, match="requires a URI|was used incorrectly"):
@resource # type: ignore[arg-type]
def get_config() -> str:
return "{}"
def test_resource_with_uri(self):
"""@resource("uri") should create a FunctionResource."""
@resource("config://app")
def get_config() -> dict:
return {"setting": "value"}
assert isinstance(get_config, FunctionResource)
assert str(get_config.uri) == "config://app"
def test_resource_with_template_uri(self):
"""@resource with template URI should create a FunctionResourceTemplate."""
@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"
def test_resource_with_function_params_becomes_template(self):
"""@resource with function params and URI params should create a template."""
@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}"
def test_resource_with_all_metadata(self):
"""@resource with all metadata should store it all."""
@resource(
"config://app",
name="app-config",
title="Application Config",
description="Gets app configuration",
mime_type="application/json",
tags={"config"},
meta={"custom": "value"},
)
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"}
async def test_resource_can_be_read(self):
"""Resource created by @resource should be readable."""
@resource("config://app")
def get_config() -> dict:
"""Get config."""
return {"setting": "value"}
assert isinstance(get_config, FunctionResource)
result = await get_config.read()
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 "{}"
async def test_resource_added_to_server(self):
"""Resource created by @resource should work when added to a server."""
@resource("config://app")
def get_config() -> str:
"""Get config."""
return '{"version": "1.0"}'
assert isinstance(get_config, FunctionResource)
mcp = FastMCP("Test")
mcp.add_resource(get_config)
async with Client(mcp) as client:
resources = await client.list_resources()
assert any(str(r.uri) == "config://app" for r in resources)
result = await client.read_resource("config://app")
assert "1.0" in str(result)
async def test_template_added_to_server(self):
"""Template created by @resource should work when added to a server."""
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> str:
"""Get user profile."""
return f'{{"id": "{user_id}"}}'
assert isinstance(get_profile, FunctionResourceTemplate)
mcp = FastMCP("Test")
mcp.add_template(get_profile)
async with Client(mcp) as client:
templates = await client.list_resource_templates()
assert any(t.uriTemplate == "users://{user_id}/profile" for t in templates)
result = await client.read_resource("users://123/profile")
assert "123" in str(result)

View file

@ -294,10 +294,16 @@ class TestLocalProviderInterface:
class TestLocalProviderDecorators:
"""Tests for LocalProvider decorator methods."""
"""Tests for LocalProvider decorator registration.
def test_tool_decorator_bare(self):
"""Test @provider.tool without parentheses."""
Note: Decorator calling patterns and metadata are tested in the standalone
decorator tests (tests/tools/test_standalone_decorator.py, etc.). These tests
focus on LocalProvider-specific behavior: registration into _components,
the enabled flag, and round-trip execution via Client.
"""
def test_tool_decorator_registers(self):
"""Tool decorator should register in _components."""
provider = LocalProvider()
@provider.tool
@ -307,18 +313,8 @@ class TestLocalProviderDecorators:
assert "tool:my_tool" in provider._components
assert provider._components["tool:my_tool"].name == "my_tool"
def test_tool_decorator_with_parens(self):
"""Test @provider.tool() with empty parentheses."""
provider = LocalProvider()
@provider.tool()
def my_tool(x: int) -> int:
return x * 2
assert "tool:my_tool" in provider._components
def test_tool_decorator_with_name_kwarg(self):
"""Test @provider.tool(name='custom')."""
def test_tool_decorator_with_custom_name_registers(self):
"""Tool with custom name should register under that name."""
provider = LocalProvider()
@provider.tool(name="custom_name")
@ -328,18 +324,8 @@ class TestLocalProviderDecorators:
assert "tool:custom_name" in provider._components
assert "tool:my_tool" not in provider._components
def test_tool_decorator_with_description(self):
"""Test @provider.tool(description='...')."""
provider = LocalProvider()
@provider.tool(description="Custom description")
def my_tool(x: int) -> int:
return x * 2
assert provider._components["tool:my_tool"].description == "Custom description"
def test_tool_direct_call(self):
"""Test provider.tool(fn, name='...')."""
"""provider.tool(fn) should register the function."""
provider = LocalProvider()
def my_tool(x: int) -> int:
@ -349,8 +335,37 @@ class TestLocalProviderDecorators:
assert "tool:direct_tool" in provider._components
async def test_tool_decorator_execution(self):
"""Test that decorated tools execute correctly."""
def test_tool_enabled_false(self):
"""Tool with enabled=False should be disabled."""
provider = LocalProvider()
@provider.tool(enabled=False)
def disabled_tool() -> str:
return "should be disabled"
assert "tool:disabled_tool" in provider._components
tool = provider._components["tool:disabled_tool"]
assert not provider._is_component_enabled(tool)
async def test_tool_enabled_false_not_listed(self):
"""Disabled tool should not appear in list_tools."""
provider = LocalProvider()
@provider.tool(enabled=False)
def disabled_tool() -> str:
return "should be disabled"
@provider.tool
def enabled_tool() -> str:
return "should be enabled"
tools = await provider.list_tools()
names = {t.name for t in tools}
assert "enabled_tool" in names
assert "disabled_tool" not in names
async def test_tool_roundtrip(self):
"""Tool should execute correctly via Client."""
provider = LocalProvider()
@provider.tool
@ -363,8 +378,8 @@ class TestLocalProviderDecorators:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.data == 5
def test_resource_decorator(self):
"""Test @provider.resource decorator."""
def test_resource_decorator_registers(self):
"""Resource decorator should register in _components."""
provider = LocalProvider()
@provider.resource("resource://test")
@ -373,8 +388,8 @@ class TestLocalProviderDecorators:
assert "resource:resource://test" in provider._components
def test_resource_decorator_with_name(self):
"""Test @provider.resource with custom name."""
def test_resource_with_custom_name_registers(self):
"""Resource with custom name should register with that name."""
provider = LocalProvider()
@provider.resource("resource://test", name="custom_name")
@ -383,8 +398,66 @@ class TestLocalProviderDecorators:
assert provider._components["resource:resource://test"].name == "custom_name"
async def test_resource_decorator_execution(self):
"""Test that decorated resources execute correctly."""
def test_resource_enabled_false(self):
"""Resource with enabled=False should be disabled."""
provider = LocalProvider()
@provider.resource("resource://test", enabled=False)
def disabled_resource() -> str:
return "should be disabled"
assert "resource:resource://test" in provider._components
resource = provider._components["resource:resource://test"]
assert not provider._is_component_enabled(resource)
async def test_resource_enabled_false_not_listed(self):
"""Disabled resource should not appear in list_resources."""
provider = LocalProvider()
@provider.resource("resource://disabled", enabled=False)
def disabled_resource() -> str:
return "should be disabled"
@provider.resource("resource://enabled")
def enabled_resource() -> str:
return "should be enabled"
resources = await provider.list_resources()
uris = {str(r.uri) for r in resources}
assert "resource://enabled" in uris
assert "resource://disabled" not in uris
def test_template_enabled_false(self):
"""Template with enabled=False should be disabled."""
provider = LocalProvider()
@provider.resource("data://{id}", enabled=False)
def disabled_template(id: str) -> str:
return f"Data {id}"
assert "template:data://{id}" in provider._components
template = provider._components["template:data://{id}"]
assert not provider._is_component_enabled(template)
async def test_template_enabled_false_not_listed(self):
"""Disabled template should not appear in list_resource_templates."""
provider = LocalProvider()
@provider.resource("data://{id}", enabled=False)
def disabled_template(id: str) -> str:
return f"Data {id}"
@provider.resource("items://{id}")
def enabled_template(id: str) -> str:
return f"Item {id}"
templates = await provider.list_resource_templates()
uris = {t.uri_template for t in templates}
assert "items://{id}" in uris
assert "data://{id}" not in uris
async def test_resource_roundtrip(self):
"""Resource should execute correctly via Client."""
provider = LocalProvider()
@provider.resource("resource://greeting")
@ -397,8 +470,8 @@ class TestLocalProviderDecorators:
result = await client.read_resource("resource://greeting")
assert "Hello, World!" in str(result)
def test_prompt_decorator_bare(self):
"""Test @provider.prompt without parentheses."""
def test_prompt_decorator_registers(self):
"""Prompt decorator should register in _components."""
provider = LocalProvider()
@provider.prompt
@ -407,18 +480,8 @@ class TestLocalProviderDecorators:
assert "prompt:my_prompt" in provider._components
def test_prompt_decorator_with_parens(self):
"""Test @provider.prompt() with empty parentheses."""
provider = LocalProvider()
@provider.prompt()
def my_prompt() -> str:
return "A prompt"
assert "prompt:my_prompt" in provider._components
def test_prompt_decorator_with_name(self):
"""Test @provider.prompt(name='custom')."""
def test_prompt_with_custom_name_registers(self):
"""Prompt with custom name should register under that name."""
provider = LocalProvider()
@provider.prompt(name="custom_prompt")
@ -428,6 +491,49 @@ class TestLocalProviderDecorators:
assert "prompt:custom_prompt" in provider._components
assert "prompt:my_prompt" not in provider._components
def test_prompt_enabled_false(self):
"""Prompt with enabled=False should be disabled."""
provider = LocalProvider()
@provider.prompt(enabled=False)
def disabled_prompt() -> str:
return "should be disabled"
assert "prompt:disabled_prompt" in provider._components
prompt = provider._components["prompt:disabled_prompt"]
assert not provider._is_component_enabled(prompt)
async def test_prompt_enabled_false_not_listed(self):
"""Disabled prompt should not appear in list_prompts."""
provider = LocalProvider()
@provider.prompt(enabled=False)
def disabled_prompt() -> str:
return "should be disabled"
@provider.prompt
def enabled_prompt() -> str:
return "should be enabled"
prompts = await provider.list_prompts()
names = {p.name for p in prompts}
assert "enabled_prompt" in names
assert "disabled_prompt" not in names
async def test_prompt_roundtrip(self):
"""Prompt should execute correctly via Client."""
provider = LocalProvider()
@provider.prompt
def greeting(name: str) -> str:
return f"Hello, {name}!"
server = FastMCP("Test", providers=[provider])
async with Client(server) as client:
result = await client.get_prompt("greeting", {"name": "World"})
assert "Hello, World!" in str(result)
class TestLocalProviderToolTransformations:
"""Tests for tool transformations in LocalProvider."""

View file

@ -178,7 +178,7 @@ class TestPromptDecorator:
async def test_prompt_decorator_classmethod_error(self):
mcp = FastMCP()
with pytest.raises(ValueError, match="To decorate a classmethod"):
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@mcp.prompt

View file

@ -403,7 +403,7 @@ class TestResourceDecorator:
async def test_resource_decorator_classmethod_error(self):
mcp = FastMCP()
with pytest.raises(ValueError, match="To decorate a classmethod"):
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@mcp.resource("resource://data")

View file

@ -1201,7 +1201,7 @@ class TestToolDecorator:
async def test_tool_decorator_classmethod_error(self):
mcp = FastMCP()
with pytest.raises(ValueError, match="To decorate a classmethod"):
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@mcp.tool

View file

@ -0,0 +1,123 @@
"""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
discovered by FileSystemProvider.
"""
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.tools import FunctionTool, tool
class TestToolDecorator:
"""Tests for the @tool decorator."""
def test_tool_without_parens(self):
"""@tool without parentheses should create a FunctionTool."""
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
assert isinstance(greet, FunctionTool)
assert greet.name == "greet"
def test_tool_with_empty_parens(self):
"""@tool() with empty parentheses should create a FunctionTool."""
@tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
assert isinstance(greet, FunctionTool)
assert greet.name == "greet"
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}!"
assert isinstance(greet, FunctionTool)
assert greet.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}!"
assert isinstance(greet, FunctionTool)
assert greet.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}!"
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"}
async def test_tool_can_be_run(self):
"""Tool created by @tool should be runnable."""
@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]
def test_tool_rejects_classmethod_decorator(self):
"""@tool should reject classmethod-decorated functions."""
with pytest.raises(TypeError, match="classmethod"):
class MyClass:
@tool # type: ignore[arg-type]
@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"):
@tool("name1", name="name2") # type: ignore[call-overload]
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!"