diff --git a/docs/docs.json b/docs/docs.json index e833496c64..d29c0d39f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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": [ diff --git a/docs/patterns/tool-transformation.mdx b/docs/patterns/tool-transformation.mdx index 7780cc466a..b0ed9f1d13 100644 --- a/docs/patterns/tool-transformation.mdx +++ b/docs/patterns/tool-transformation.mdx @@ -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) ``` -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 -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 diff --git a/docs/servers/providers/filesystem.mdx b/docs/servers/providers/filesystem.mdx index 671d76777b..92c3187731 100644 --- a/docs/servers/providers/filesystem.mdx +++ b/docs/servers/providers/filesystem.mdx @@ -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 pathlib import Path + from fastmcp import FastMCP -from fastmcp.fs import FileSystemProvider +from fastmcp.server.providers import FileSystemProvider -mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")]) +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 + +from fastmcp.server.providers import FileSystemProvider -provider = FileSystemProvider("mcp/", reload=True) +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", diff --git a/examples/filesystem-provider/mcp/prompts/assistant.py b/examples/filesystem-provider/mcp/prompts/assistant.py index 2181a5599e..0950e3423b 100644 --- a/examples/filesystem-provider/mcp/prompts/assistant.py +++ b/examples/filesystem-provider/mcp/prompts/assistant.py @@ -1,6 +1,6 @@ """Assistant prompts.""" -from fastmcp.fs import prompt +from fastmcp.prompts import prompt @prompt diff --git a/examples/filesystem-provider/mcp/resources/config.py b/examples/filesystem-provider/mcp/resources/config.py index d53bd83853..03a3f7b116 100644 --- a/examples/filesystem-provider/mcp/resources/config.py +++ b/examples/filesystem-provider/mcp/resources/config.py @@ -2,7 +2,7 @@ import json -from fastmcp.fs import resource +from fastmcp.resources import resource # Static resource - no parameters in URI diff --git a/examples/filesystem-provider/mcp/tools/calculator.py b/examples/filesystem-provider/mcp/tools/calculator.py index ac9079f73a..f8d9010672 100644 --- a/examples/filesystem-provider/mcp/tools/calculator.py +++ b/examples/filesystem-provider/mcp/tools/calculator.py @@ -1,6 +1,6 @@ """Math tools with custom metadata.""" -from fastmcp.fs import tool +from fastmcp.tools import tool @tool( diff --git a/examples/filesystem-provider/mcp/tools/greeting.py b/examples/filesystem-provider/mcp/tools/greeting.py index 3dcea84d49..f124902a14 100644 --- a/examples/filesystem-provider/mcp/tools/greeting.py +++ b/examples/filesystem-provider/mcp/tools/greeting.py @@ -1,6 +1,6 @@ """Greeting tools - multiple tools in one file.""" -from fastmcp.fs import tool +from fastmcp.tools import tool @tool diff --git a/examples/filesystem-provider/server.py b/examples/filesystem-provider/server.py index 9a7267c31c..2f3cf47a6e 100644 --- a/examples/filesystem-provider/server.py +++ b/examples/filesystem-provider/server.py @@ -16,7 +16,7 @@ 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. diff --git a/src/fastmcp/fs/__init__.py b/src/fastmcp/fs/__init__.py deleted file mode 100644 index 09a6bca97b..0000000000 --- a/src/fastmcp/fs/__init__.py +++ /dev/null @@ -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", -] diff --git a/src/fastmcp/fs/decorators.py b/src/fastmcp/fs/decorators.py deleted file mode 100644 index 8ccb3a3ecf..0000000000 --- a/src/fastmcp/fs/decorators.py +++ /dev/null @@ -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 diff --git a/src/fastmcp/prompts/__init__.py b/src/fastmcp/prompts/__init__.py index 0b6cf50ee2..a154c02aba 100644 --- a/src/fastmcp/prompts/__init__.py +++ b/src/fastmcp/prompts/__init__.py @@ -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", ] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 98597233cf..cf7660f6ce 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -5,6 +5,7 @@ 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 @@ async def add_to_docket( # type: ignore[override] 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, + ) diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py index 5048e95caa..c051569de5 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -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 @@ "ResourceResult", "ResourceTemplate", "TextResource", + "resource", ] diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 3c44382c45..b28ee5e10e 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -12,6 +12,8 @@ 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 @@ def register_with_docket(self, docket: Docket) -> None: 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 diff --git a/src/fastmcp/server/providers/__init__.py b/src/fastmcp/server/providers/__init__.py index 25af32a55d..2875ab47ae 100644 --- a/src/fastmcp/server/providers/__init__.py +++ b/src/fastmcp/server/providers/__init__.py @@ -29,6 +29,7 @@ async def get_tool(self, name: str) -> Tool | None: 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 @@ async def get_tool(self, name: str) -> Tool | None: __all__ = [ "FastMCPProvider", + "FileSystemProvider", "LocalProvider", "OpenAPIProvider", "Provider", diff --git a/src/fastmcp/fs/provider.py b/src/fastmcp/server/providers/filesystem.py similarity index 59% rename from src/fastmcp/fs/provider.py rename to src/fastmcp/server/providers/filesystem.py index 0cec6c50f6..3c634367ef 100644 --- a/src/fastmcp/fs/provider.py +++ b/src/fastmcp/server/providers/filesystem.py @@ -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 + # In mcp/tools.py + from fastmcp.tools import tool + + @tool + def greet(name: str) -> str: + return f"Hello, {name}!" + + # In main.py + from pathlib import Path + from fastmcp import FastMCP - from fastmcp.fs import FileSystemProvider + from fastmcp.server.providers import FileSystemProvider - mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")]) + 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 @@ 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 + # In mcp/tools.py + from fastmcp.tools import tool + + @tool + def greet(name: str) -> str: + return f"Hello, {name}!" + + # In main.py + from pathlib import Path + from fastmcp import FastMCP - from fastmcp.fs import FileSystemProvider + from fastmcp.server.providers import FileSystemProvider - # Basic usage - mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")]) + # 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 @@ def _load_components(self) -> None: 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 @@ def _load_components(self) -> None: 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. diff --git a/src/fastmcp/fs/discovery.py b/src/fastmcp/server/providers/filesystem_discovery.py similarity index 83% rename from src/fastmcp/fs/discovery.py rename to src/fastmcp/server/providers/filesystem_discovery.py index 311aee94e4..22cebd05ec 100644 --- a/src/fastmcp/fs/discovery.py +++ b/src/fastmcp/server/providers/filesystem_discovery.py @@ -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 @@ 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 @@ 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 diff --git a/src/fastmcp/server/providers/local_provider.py b/src/fastmcp/server/providers/local_provider.py index 8d22158a38..645d17543c 100644 --- a/src/fastmcp/server/providers/local_provider.py +++ b/src/fastmcp/server/providers/local_provider.py @@ -35,7 +35,9 @@ def greet(name: str) -> str: 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 @@ def tool( 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 @@ def my_tool(x: int) -> str: 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 @@ def get_weather(city: str) -> str: 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 @@ def my_prompt(data: str) -> list: 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 diff --git a/src/fastmcp/tools/__init__.py b/src/fastmcp/tools/__init__.py index 460396e828..5657bd8469 100644 --- a/src/fastmcp/tools/__init__.py +++ b/src/fastmcp/tools/__init__.py @@ -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"] diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 88207221f9..c243968b51 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -4,6 +4,7 @@ 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, + ) diff --git a/tests/fs/test_decorators.py b/tests/fs/test_decorators.py deleted file mode 100644 index f12b311e2b..0000000000 --- a/tests/fs/test_decorators.py +++ /dev/null @@ -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 diff --git a/tests/fs/test_discovery.py b/tests/fs/test_discovery.py index 575ea1404d..261b934019 100644 --- a/tests/fs/test_discovery.py +++ b/tests/fs/test_discovery.py @@ -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 @@ def plain_function(): 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 + 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 - func, _ = components[0] - assert func.__name__ == "public_tool" + component = components[0] + assert isinstance(component, FunctionResourceTemplate) + assert component.uri_template == "users://{user_id}/profile" class TestDiscoverAndImport: @@ -278,7 +303,7 @@ def test_discover_and_import_with_tools(self, tmp_path: Path): 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 diff --git a/tests/fs/test_provider.py b/tests/fs/test_provider.py index 4e1281fb0d..03518e7954 100644 --- a/tests/fs/test_provider.py +++ b/tests/fs/test_provider.py @@ -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 @@ def test_provider_discovers_tools(self, tmp_path: Path): 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 test_provider_discovers_resources(self, tmp_path: Path): """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 test_provider_discovers_resource_templates(self, tmp_path: Path): """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 test_provider_discovers_prompts(self, tmp_path: Path): """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 test_provider_discovers_multiple_in_one_file(self, tmp_path: Path): """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 @@ def helper_function(): ) (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 @@ def test_reload_false_caches_at_init(self, tmp_path: Path): """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 @@ async def test_reload_true_rescans(self, tmp_path: Path): """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 @@ async def test_warning_cleared_when_fixed(self, tmp_path: Path, capsys): 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 @@ async def test_provider_with_fastmcp_server(self, tmp_path: Path): """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 @@ async def test_provider_with_resources(self, tmp_path: Path): """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 @@ async def test_provider_with_resource_templates(self, tmp_path: Path): """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 @@ async def test_provider_with_prompts(self, tmp_path: Path): """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 @@ async def test_nested_directory_structure(self, tmp_path: Path): 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: diff --git a/tests/prompts/test_standalone_decorator.py b/tests/prompts/test_standalone_decorator.py new file mode 100644 index 0000000000..8bf83a353d --- /dev/null +++ b/tests/prompts/test_standalone_decorator.py @@ -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) diff --git a/tests/resources/test_standalone_decorator.py b/tests/resources/test_standalone_decorator.py new file mode 100644 index 0000000000..025a4763f2 --- /dev/null +++ b/tests/resources/test_standalone_decorator.py @@ -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) diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py index f87a217941..932a55f2ab 100644 --- a/tests/server/providers/test_local_provider.py +++ b/tests/server/providers/test_local_provider.py @@ -294,10 +294,16 @@ async def test_get_prompt_not_found(self): 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,50 +313,59 @@ def my_tool(x: int) -> int: 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.""" + def test_tool_decorator_with_custom_name_registers(self): + """Tool with custom name should register under that name.""" provider = LocalProvider() - @provider.tool() + @provider.tool(name="custom_name") def my_tool(x: int) -> int: return x * 2 - assert "tool:my_tool" in provider._components + assert "tool:custom_name" in provider._components + assert "tool:my_tool" not in provider._components - def test_tool_decorator_with_name_kwarg(self): - """Test @provider.tool(name='custom').""" + def test_tool_direct_call(self): + """provider.tool(fn) should register the function.""" provider = LocalProvider() - @provider.tool(name="custom_name") def my_tool(x: int) -> int: return x * 2 - assert "tool:custom_name" in provider._components - assert "tool:my_tool" not in provider._components + provider.tool(my_tool, name="direct_tool") + + assert "tool:direct_tool" in provider._components - def test_tool_decorator_with_description(self): - """Test @provider.tool(description='...').""" + def test_tool_enabled_false(self): + """Tool with enabled=False should be disabled.""" provider = LocalProvider() - @provider.tool(description="Custom description") - def my_tool(x: int) -> int: - return x * 2 + @provider.tool(enabled=False) + def disabled_tool() -> str: + return "should be disabled" - assert provider._components["tool:my_tool"].description == "Custom description" + assert "tool:disabled_tool" in provider._components + tool = provider._components["tool:disabled_tool"] + assert not provider._is_component_enabled(tool) - def test_tool_direct_call(self): - """Test provider.tool(fn, name='...').""" + async def test_tool_enabled_false_not_listed(self): + """Disabled tool should not appear in list_tools.""" provider = LocalProvider() - def my_tool(x: int) -> int: - return x * 2 + @provider.tool(enabled=False) + def disabled_tool() -> str: + return "should be disabled" - provider.tool(my_tool, name="direct_tool") + @provider.tool + def enabled_tool() -> str: + return "should be enabled" - assert "tool:direct_tool" in provider._components + 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_decorator_execution(self): - """Test that decorated tools execute correctly.""" + async def test_tool_roundtrip(self): + """Tool should execute correctly via Client.""" provider = LocalProvider() @provider.tool @@ -363,8 +378,8 @@ def add(a: int, b: int) -> int: 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 @@ def my_resource() -> str: 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 @@ def my_resource() -> str: 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 @@ def greeting() -> str: 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,26 +480,59 @@ def my_prompt() -> str: assert "prompt:my_prompt" in provider._components - def test_prompt_decorator_with_parens(self): - """Test @provider.prompt() with empty parentheses.""" + def test_prompt_with_custom_name_registers(self): + """Prompt with custom name should register under that name.""" provider = LocalProvider() - @provider.prompt() + @provider.prompt(name="custom_prompt") def my_prompt() -> str: return "A prompt" - assert "prompt:my_prompt" in provider._components + assert "prompt:custom_prompt" in provider._components + assert "prompt:my_prompt" not in provider._components - def test_prompt_decorator_with_name(self): - """Test @provider.prompt(name='custom').""" + def test_prompt_enabled_false(self): + """Prompt with enabled=False should be disabled.""" provider = LocalProvider() - @provider.prompt(name="custom_prompt") - def my_prompt() -> str: - return "A prompt" + @provider.prompt(enabled=False) + def disabled_prompt() -> str: + return "should be disabled" - assert "prompt:custom_prompt" in provider._components - assert "prompt:my_prompt" not in provider._components + 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: diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py index 5b8aa59951..69e875ed94 100644 --- a/tests/server/providers/test_local_provider_prompts.py +++ b/tests/server/providers/test_local_provider_prompts.py @@ -178,7 +178,7 @@ def test_prompt(cls) -> str: 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 diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py index e485586fcb..0ce8ff9cef 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -403,7 +403,7 @@ def get_data(cls) -> str: 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") diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py index 4ff72c72b8..324615c73c 100644 --- a/tests/server/providers/test_local_provider_tools.py +++ b/tests/server/providers/test_local_provider_tools.py @@ -1201,7 +1201,7 @@ async def add(x: int, y: int) -> int: 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 diff --git a/tests/tools/test_standalone_decorator.py b/tests/tools/test_standalone_decorator.py new file mode 100644 index 0000000000..0479c5e52e --- /dev/null +++ b/tests/tools/test_standalone_decorator.py @@ -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!"