mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add standalone decorators and eliminate fastmcp.fs module (#2832)
This commit is contained in:
parent
24d500d384
commit
daa2dace2f
30 changed files with 1280 additions and 1123 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -28,16 +28,15 @@ Transformation is also powerful for **environment-aware tools**. You can dynamic
|
|||
|
||||
The primary way to create a transformed tool is with the `Tool.from_tool()` class method. At its simplest, you can use it to change a tool's top-level metadata like its `name`, `description`, or `tags`.
|
||||
|
||||
In the following simple example, we take a generic `search` tool and adjust its name and description to help an LLM client better understand its purpose.
|
||||
In the following example, we take a generic `search` tool and adjust its name and description to help an LLM client better understand its purpose.
|
||||
|
||||
```python {13-21}
|
||||
```python {1, 6, 11-19, 22}
|
||||
from fastmcp.tools import tool, Tool
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
# The original, generic tool
|
||||
@mcp.tool
|
||||
# Create a tool without registering it using the standalone @tool decorator
|
||||
# This creates a Tool object that can be transformed before registration
|
||||
@tool
|
||||
def search(query: str, category: str = "all") -> list[dict]:
|
||||
"""Searches for items in the database."""
|
||||
return database.search(query, category)
|
||||
|
|
@ -47,39 +46,25 @@ product_search_tool = Tool.from_tool(
|
|||
search,
|
||||
name="find_products",
|
||||
description="""
|
||||
Search for products in the e-commerce catalog.
|
||||
Use this when customers ask about finding specific items,
|
||||
Search for products in the e-commerce catalog.
|
||||
Use this when customers ask about finding specific items,
|
||||
checking availability, or browsing product categories.
|
||||
""",
|
||||
)
|
||||
|
||||
# Only register the transformed version
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(product_search_tool)
|
||||
```
|
||||
|
||||
<Tip>
|
||||
When you transform a tool, the original tool remains registered on the server. To avoid confusing an LLM with two similar tools, you can disable the original one:
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools import Tool
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
# The original, generic tool
|
||||
@mcp.tool
|
||||
def search(query: str, category: str = "all") -> list[dict]:
|
||||
...
|
||||
|
||||
# Create a more domain-specific version
|
||||
product_search_tool = Tool.from_tool(search, ...)
|
||||
mcp.add_tool(product_search_tool)
|
||||
|
||||
# Disable the original tool
|
||||
search.disable()
|
||||
```
|
||||
The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This is the recommended approach for tool transformation because:
|
||||
- You only register the tools you want exposed
|
||||
- No need to disable or remove the original
|
||||
- Cleaner separation between tool creation and registration
|
||||
</Tip>
|
||||
|
||||
Now, clients see a tool named `find_products` with a clear, domain-specific purpose and relevant tags, even though it still uses the original generic `search` function's logic.
|
||||
Now, clients see a tool named `find_products` with a clear, domain-specific purpose, even though it still uses the original generic `search` function's logic.
|
||||
|
||||
### Parameters
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
Traditional FastMCP servers require coordination between files. Either your tool files import the server to call `@server.tool()`, or your server file imports all the tool modules. Both approaches create coupling that some developers prefer to avoid.
|
||||
|
||||
`FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses decorators from `fastmcp.fs` that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file.
|
||||
`FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses standalone decorators (`@tool`, `@resource`, `@prompt`) that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file.
|
||||
|
||||
This is a convention some teams prefer, not necessarily better for all projects. The tradeoffs:
|
||||
|
||||
|
|
@ -25,20 +25,22 @@ This is a convention some teams prefer, not necessarily better for all projects.
|
|||
|
||||
## Quick Start
|
||||
|
||||
Create a provider pointing to your components directory, then pass it to your server.
|
||||
Create a provider pointing to your components directory, then pass it to your server. Use `Path(__file__).parent` to make the path relative to your server file.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
from pathlib import Path
|
||||
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
|
||||
```
|
||||
|
||||
In your `mcp/` directory, create Python files with decorated functions.
|
||||
|
||||
```python
|
||||
# mcp/tools/greet.py
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -50,14 +52,14 @@ When the server starts, `FileSystemProvider` scans the directory, imports all Py
|
|||
|
||||
## Decorators
|
||||
|
||||
The `fastmcp.fs` module provides three decorators that mark functions for discovery: `@tool`, `@resource`, and `@prompt`. These support the full syntax of standard FastMCP decorators—all the same parameters work identically.
|
||||
FastMCP provides standalone decorators that mark functions for discovery: `@tool` from `fastmcp.tools`, `@resource` from `fastmcp.resources`, and `@prompt` from `fastmcp.prompts`. These support the full syntax of server-bound decorators—all the same parameters work identically.
|
||||
|
||||
### @tool
|
||||
|
||||
Mark a function as a tool. The function name becomes the tool name by default.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def calculate_sum(a: float, b: float) -> float:
|
||||
|
|
@ -68,7 +70,7 @@ def calculate_sum(a: float, b: float) -> float:
|
|||
Customize the tool with optional parameters.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool(
|
||||
name="add-numbers",
|
||||
|
|
@ -86,7 +88,7 @@ The decorator supports all standard tool options: `name`, `title`, `description`
|
|||
Mark a function as a resource. Unlike `@tool`, the `@resource` decorator requires a URI argument.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("config://app")
|
||||
def get_app_config() -> str:
|
||||
|
|
@ -97,7 +99,7 @@ def get_app_config() -> str:
|
|||
URIs with template parameters create resource templates. The provider automatically detects whether to register a static resource or a template based on whether the URI contains `{parameters}` or the function has arguments.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_user_profile(user_id: str) -> str:
|
||||
|
|
@ -112,7 +114,7 @@ The decorator supports: `uri` (required), `name`, `title`, `description`, `icons
|
|||
Mark a function as a prompt template.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import prompt
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
@prompt
|
||||
def code_review(code: str, language: str = "python") -> str:
|
||||
|
|
@ -121,7 +123,7 @@ def code_review(code: str, language: str = "python") -> str:
|
|||
```
|
||||
|
||||
```python
|
||||
from fastmcp.fs import prompt
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
@prompt(name="explain-concept", tags={"education"})
|
||||
def explain(topic: str) -> str:
|
||||
|
|
@ -190,9 +192,11 @@ Without `__init__.py`, files are imported directly using `importlib.util.spec_fr
|
|||
During development, you may want changes to component files to take effect without restarting the server. Enable reload mode to re-scan the directory on every request.
|
||||
|
||||
```python
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
from pathlib import Path
|
||||
|
||||
provider = FileSystemProvider("mcp/", reload=True)
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
provider = FileSystemProvider(Path(__file__).parent / "mcp", reload=True)
|
||||
```
|
||||
|
||||
With `reload=True`, the provider:
|
||||
|
|
@ -238,7 +242,7 @@ The server entry point is minimal.
|
|||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
provider = FileSystemProvider(
|
||||
root=Path(__file__).parent / "mcp",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Assistant prompts."""
|
||||
|
||||
from fastmcp.fs import prompt
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
|
||||
@prompt
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import json
|
||||
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
|
||||
# Static resource - no parameters in URI
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Math tools with custom metadata."""
|
||||
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
|
||||
@tool(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Greeting tools - multiple tools in one file."""
|
||||
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
|
||||
@tool
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Dev mode (re-scan files on every request):
|
|||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
# The provider scans all .py files in the directory recursively.
|
||||
# Functions decorated with @tool, @resource, or @prompt are registered.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations as _annotations
|
|||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
|
||||
|
||||
import pydantic
|
||||
|
|
@ -608,3 +609,165 @@ class FunctionPrompt(Prompt):
|
|||
if task_key:
|
||||
kwargs["key"] = task_key
|
||||
return await docket.add(lookup_key, **kwargs)(**(arguments or {}))
|
||||
|
||||
|
||||
# Type alias for any function that can be decorated
|
||||
AnyFunction = Callable[..., Any]
|
||||
|
||||
|
||||
@overload
|
||||
def prompt(fn: AnyFunction) -> FunctionPrompt: ...
|
||||
|
||||
|
||||
@overload
|
||||
def prompt(
|
||||
name_or_fn: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> Callable[[AnyFunction], FunctionPrompt]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def prompt(
|
||||
name_or_fn: None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> Callable[[AnyFunction], FunctionPrompt]: ...
|
||||
|
||||
|
||||
def prompt(
|
||||
name_or_fn: str | AnyFunction | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> (
|
||||
Callable[[AnyFunction], FunctionPrompt]
|
||||
| FunctionPrompt
|
||||
| partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt]
|
||||
):
|
||||
"""Standalone decorator to create a prompt without registering it to a server.
|
||||
|
||||
This decorator creates a FunctionPrompt object from a function. Unlike
|
||||
@server.prompt(), this does NOT register the prompt with any server - you must
|
||||
explicitly add it using server.add_prompt().
|
||||
|
||||
This is useful for:
|
||||
- Creating prompts that will be modified before registration
|
||||
- Defining prompts in modules that are discovered by FileSystemProvider
|
||||
- Creating reusable prompt definitions
|
||||
|
||||
This decorator supports multiple calling patterns:
|
||||
- @prompt (without parentheses)
|
||||
- @prompt() (with empty parentheses)
|
||||
- @prompt("custom_name") (with name as first argument)
|
||||
- @prompt(name="custom_name") (with name as keyword argument)
|
||||
|
||||
Args:
|
||||
name_or_fn: Either a function (when used as @prompt), a string name, or None
|
||||
name: Optional name for the prompt (keyword-only, alternative to name_or_fn)
|
||||
title: Optional title for the prompt
|
||||
description: Optional description of what the prompt does
|
||||
icons: Optional icons for the prompt
|
||||
tags: Optional set of tags for categorizing the prompt
|
||||
meta: Optional meta information about the prompt
|
||||
task: Optional task configuration for background execution (default False)
|
||||
|
||||
Returns:
|
||||
A FunctionPrompt when decorating a function, or a decorator function when
|
||||
called with parameters.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.prompts import prompt
|
||||
from fastmcp import FastMCP
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Please analyze: {topic}"
|
||||
|
||||
@prompt("custom_prompt")
|
||||
def my_prompt(data: str) -> str:
|
||||
return f"Process this data: {data}"
|
||||
|
||||
# Prompts are not registered yet - add them explicitly
|
||||
mcp = FastMCP()
|
||||
mcp.add_prompt(analyze)
|
||||
mcp.add_prompt(my_prompt)
|
||||
```
|
||||
"""
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise TypeError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
prompt() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Determine the actual name and function based on the calling pattern
|
||||
if inspect.isroutine(name_or_fn):
|
||||
# Case 1: @prompt (without parens) - function passed directly
|
||||
fn = name_or_fn
|
||||
prompt_name = name # Use keyword name if provided, otherwise None
|
||||
|
||||
# Default to False for standalone usage (no server to inherit from)
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Create the prompt object without registration
|
||||
return Prompt.from_function(
|
||||
fn=fn,
|
||||
name=prompt_name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
|
||||
elif isinstance(name_or_fn, str):
|
||||
# Case 2: @prompt("custom_name") - name passed as first argument
|
||||
if name is not None:
|
||||
raise TypeError(
|
||||
"Cannot specify both a name as first argument and as keyword argument. "
|
||||
f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both."
|
||||
)
|
||||
prompt_name = name_or_fn
|
||||
elif name_or_fn is None:
|
||||
# Case 3: @prompt() or @prompt(name="something") - use keyword name
|
||||
prompt_name = name
|
||||
else:
|
||||
raise TypeError(
|
||||
f"First argument to @prompt must be a function, string, or None, got {type(name_or_fn)}"
|
||||
)
|
||||
|
||||
# Return partial for cases where we need to wait for the function
|
||||
return partial(
|
||||
prompt,
|
||||
name=prompt_name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=task,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
from .resource import FunctionResource, Resource, ResourceContent, ResourceResult
|
||||
from .resource import (
|
||||
FunctionResource,
|
||||
Resource,
|
||||
ResourceContent,
|
||||
ResourceResult,
|
||||
resource,
|
||||
)
|
||||
from .template import ResourceTemplate
|
||||
from .types import (
|
||||
BinaryResource,
|
||||
|
|
@ -19,4 +25,5 @@ __all__ = [
|
|||
"ResourceResult",
|
||||
"ResourceTemplate",
|
||||
"TextResource",
|
||||
"resource",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import mcp.types
|
|||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
from docket.execution import Execution
|
||||
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
import pydantic
|
||||
import pydantic_core
|
||||
from mcp.types import Annotations, Icon
|
||||
|
|
@ -484,3 +486,136 @@ class FunctionResource(Resource):
|
|||
if not self.task_config.supports_tasks():
|
||||
return
|
||||
docket.register(self.fn, names=[self.key])
|
||||
|
||||
|
||||
# Type alias for any function that can be decorated
|
||||
AnyFunction = Callable[..., Any]
|
||||
|
||||
|
||||
def resource(
|
||||
uri: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
mime_type: str | None = None,
|
||||
tags: set[str] | None = None,
|
||||
annotations: Annotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> Callable[[AnyFunction], Resource | ResourceTemplate]:
|
||||
"""Standalone decorator to create a resource without registering it to a server.
|
||||
|
||||
This decorator creates a Resource or ResourceTemplate object from a function.
|
||||
Unlike @server.resource(), this does NOT register the resource with any server -
|
||||
you must explicitly add it using server.add_resource() or server.add_template().
|
||||
|
||||
If the URI contains parameters (e.g. "resource://{param}") or the function
|
||||
has parameters, it will create a ResourceTemplate instead of a Resource.
|
||||
|
||||
This is useful for:
|
||||
- Creating resources that will be modified before registration
|
||||
- Defining resources in modules that are discovered by FileSystemProvider
|
||||
- Creating reusable resource definitions
|
||||
|
||||
Args:
|
||||
uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}")
|
||||
name: Optional name for the resource
|
||||
title: Optional title for the resource
|
||||
description: Optional description of the resource
|
||||
icons: Optional icons for the resource
|
||||
mime_type: Optional MIME type for the resource
|
||||
tags: Optional set of tags for categorizing the resource
|
||||
annotations: Optional annotations about the resource's behavior
|
||||
meta: Optional meta information about the resource
|
||||
task: Optional task configuration for background execution (default False)
|
||||
|
||||
Returns:
|
||||
A decorator function that returns a Resource or ResourceTemplate.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.resources import resource
|
||||
from fastmcp import FastMCP
|
||||
|
||||
@resource("data://config")
|
||||
def get_config() -> str:
|
||||
return '{"setting": "value"}'
|
||||
|
||||
@resource("data://{city}/weather")
|
||||
def get_weather(city: str) -> str:
|
||||
return f"Weather for {city}"
|
||||
|
||||
# Resources are not registered yet - add them explicitly
|
||||
mcp = FastMCP()
|
||||
mcp.add_resource(get_config)
|
||||
mcp.add_template(get_weather)
|
||||
```
|
||||
"""
|
||||
if isinstance(annotations, dict):
|
||||
annotations = Annotations(**annotations)
|
||||
|
||||
# Check if user passed function directly instead of calling decorator
|
||||
if inspect.isroutine(uri):
|
||||
raise TypeError(
|
||||
"The @resource decorator was used incorrectly. "
|
||||
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
||||
)
|
||||
|
||||
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
|
||||
if isinstance(fn, classmethod):
|
||||
raise TypeError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
resource() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Default to False for standalone usage (no server to inherit from)
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Check if this should be a template
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
# Use wrapper to check for user-facing parameters
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
|
||||
wrapper_fn = without_injected_parameters(fn)
|
||||
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
|
||||
|
||||
if has_uri_params or has_func_params:
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
|
||||
return ResourceTemplate.from_function(
|
||||
fn=fn,
|
||||
uri_template=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
else:
|
||||
return Resource.from_function(
|
||||
fn=fn,
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
|
||||
return decorator
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from typing import TYPE_CHECKING
|
|||
|
||||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
|
||||
from fastmcp.server.providers.filesystem import FileSystemProvider
|
||||
from fastmcp.server.providers.local_provider import LocalProvider
|
||||
from fastmcp.server.providers.transforming import TransformingProvider
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ if TYPE_CHECKING:
|
|||
|
||||
__all__ = [
|
||||
"FastMCPProvider",
|
||||
"FileSystemProvider",
|
||||
"LocalProvider",
|
||||
"OpenAPIProvider",
|
||||
"Provider",
|
||||
|
|
|
|||
|
|
@ -1,32 +1,43 @@
|
|||
"""FileSystemProvider for filesystem-based component discovery.
|
||||
|
||||
FileSystemProvider scans a directory for Python files, imports them, and
|
||||
registers any functions decorated with @tool, @resource, or @prompt.
|
||||
registers any Tool, Resource, ResourceTemplate, or Prompt objects found.
|
||||
|
||||
Components are created using the standalone decorators from fastmcp.tools,
|
||||
fastmcp.resources, and fastmcp.prompts:
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
# In mcp/tools.py
|
||||
from fastmcp.tools import tool
|
||||
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# In main.py
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.fs.decorators import PromptMeta, ResourceMeta, ToolMeta
|
||||
from fastmcp.fs.discovery import discover_and_import
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.providers.filesystem_discovery import discover_and_import
|
||||
from fastmcp.server.providers.local_provider import LocalProvider
|
||||
from fastmcp.tools.tool import Tool
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -35,8 +46,12 @@ logger = get_logger(__name__)
|
|||
class FileSystemProvider(LocalProvider):
|
||||
"""Provider that discovers components from the filesystem.
|
||||
|
||||
Scans a directory for Python files and registers functions decorated
|
||||
with @tool, @resource, or @prompt from fastmcp.fs.
|
||||
Scans a directory for Python files and registers any Tool, Resource,
|
||||
ResourceTemplate, or Prompt objects found. Components are created using
|
||||
the standalone decorators:
|
||||
- @tool from fastmcp.tools
|
||||
- @resource from fastmcp.resources
|
||||
- @prompt from fastmcp.prompts
|
||||
|
||||
Args:
|
||||
root: Root directory to scan. Defaults to current directory.
|
||||
|
|
@ -45,14 +60,24 @@ class FileSystemProvider(LocalProvider):
|
|||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
# In mcp/tools.py
|
||||
from fastmcp.tools import tool
|
||||
|
||||
# Basic usage
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# In main.py
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
# Path relative to this file
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")])
|
||||
|
||||
# Dev mode - re-scan on every request
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/", reload=True)])
|
||||
mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp", reload=True)])
|
||||
```
|
||||
"""
|
||||
|
||||
|
|
@ -97,16 +122,18 @@ class FileSystemProvider(LocalProvider):
|
|||
self._warned_files[file_path] = current_mtime
|
||||
|
||||
# Clear warnings for files that now import successfully
|
||||
successful_files = {fp for fp, _, _ in result.components}
|
||||
successful_files = {fp for fp, _ in result.components}
|
||||
for fp in successful_files:
|
||||
self._warned_files.pop(fp, None)
|
||||
|
||||
for file_path, func, meta in result.components:
|
||||
for file_path, component in result.components:
|
||||
try:
|
||||
self._register_component(func, meta)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to register {func.__name__} from {file_path}: {e}"
|
||||
self._register_component(component)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to register %s from %s",
|
||||
getattr(component, "name", repr(component)),
|
||||
file_path,
|
||||
)
|
||||
|
||||
self._loaded = True
|
||||
|
|
@ -114,88 +141,18 @@ class FileSystemProvider(LocalProvider):
|
|||
f"FileSystemProvider loaded {len(self._components)} components from {self._root}"
|
||||
)
|
||||
|
||||
def _register_component(
|
||||
self, func: Any, meta: ToolMeta | ResourceMeta | PromptMeta
|
||||
) -> None:
|
||||
"""Register a single component based on its metadata type."""
|
||||
if isinstance(meta, ToolMeta):
|
||||
self._register_tool(func, meta)
|
||||
elif isinstance(meta, ResourceMeta):
|
||||
self._register_resource(func, meta)
|
||||
elif isinstance(meta, PromptMeta):
|
||||
self._register_prompt(func, meta)
|
||||
|
||||
def _register_tool(self, func: Any, meta: ToolMeta) -> None:
|
||||
"""Register a tool from a decorated function."""
|
||||
tool = Tool.from_function(
|
||||
fn=func,
|
||||
name=meta.name,
|
||||
title=meta.title,
|
||||
description=meta.description,
|
||||
icons=meta.icons,
|
||||
tags=meta.tags,
|
||||
output_schema=meta.output_schema,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
)
|
||||
self.add_tool(tool)
|
||||
|
||||
def _register_resource(self, func: Any, meta: ResourceMeta) -> None:
|
||||
"""Register a resource or resource template from a decorated function."""
|
||||
uri = meta.uri
|
||||
|
||||
# Check if this should be a template
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
|
||||
# Check for function parameters (excluding injected ones)
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
|
||||
wrapper_fn = without_injected_parameters(func)
|
||||
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
|
||||
|
||||
if has_uri_params or has_func_params:
|
||||
# Register as template
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=func,
|
||||
uri_template=uri,
|
||||
name=meta.name,
|
||||
title=meta.title,
|
||||
description=meta.description,
|
||||
icons=meta.icons,
|
||||
mime_type=meta.mime_type,
|
||||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
)
|
||||
self.add_template(template)
|
||||
def _register_component(self, component: FastMCPComponent) -> None:
|
||||
"""Register a single component based on its type."""
|
||||
if isinstance(component, Tool):
|
||||
self.add_tool(component)
|
||||
elif isinstance(component, ResourceTemplate):
|
||||
self.add_template(component)
|
||||
elif isinstance(component, Resource):
|
||||
self.add_resource(component)
|
||||
elif isinstance(component, Prompt):
|
||||
self.add_prompt(component)
|
||||
else:
|
||||
# Register as static resource
|
||||
resource = Resource.from_function(
|
||||
fn=func,
|
||||
uri=uri,
|
||||
name=meta.name,
|
||||
title=meta.title,
|
||||
description=meta.description,
|
||||
icons=meta.icons,
|
||||
mime_type=meta.mime_type,
|
||||
tags=meta.tags,
|
||||
annotations=meta.annotations,
|
||||
meta=meta.meta,
|
||||
)
|
||||
self.add_resource(resource)
|
||||
|
||||
def _register_prompt(self, func: Any, meta: PromptMeta) -> None:
|
||||
"""Register a prompt from a decorated function."""
|
||||
prompt = Prompt.from_function(
|
||||
fn=func,
|
||||
name=meta.name,
|
||||
title=meta.title,
|
||||
description=meta.description,
|
||||
icons=meta.icons,
|
||||
tags=meta.tags,
|
||||
meta=meta.meta,
|
||||
)
|
||||
self.add_prompt(prompt)
|
||||
logger.debug("Ignoring unknown component type: %r", type(component))
|
||||
|
||||
async def _ensure_loaded(self) -> None:
|
||||
"""Ensure components are loaded, reloading if in reload mode.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
This module provides functions to:
|
||||
1. Discover Python files in a directory tree
|
||||
2. Import modules (as packages if __init__.py exists, else directly)
|
||||
3. Extract decorated functions from imported modules
|
||||
3. Extract decorated components (Tool, Resource, Prompt objects) from imported modules
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,12 +13,8 @@ import sys
|
|||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.fs.decorators import (
|
||||
FSMeta,
|
||||
get_fs_meta,
|
||||
)
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -28,7 +24,8 @@ logger = get_logger(__name__)
|
|||
class DiscoveryResult:
|
||||
"""Result of filesystem discovery."""
|
||||
|
||||
components: list[tuple[Path, Any, FSMeta]] = field(default_factory=list)
|
||||
# Components are real objects (Tool, Resource, ResourceTemplate, Prompt)
|
||||
components: list[tuple[Path, FastMCPComponent]] = field(default_factory=list)
|
||||
failed_files: dict[Path, str] = field(default_factory=dict) # path -> error message
|
||||
|
||||
|
||||
|
|
@ -175,19 +172,26 @@ def import_module_from_file(file_path: Path) -> ModuleType:
|
|||
return module
|
||||
|
||||
|
||||
def extract_components(module: ModuleType) -> list[tuple[Any, FSMeta]]:
|
||||
"""Extract all decorated functions from a module.
|
||||
def extract_components(module: ModuleType) -> list[FastMCPComponent]:
|
||||
"""Extract all MCP components from a module.
|
||||
|
||||
Scans all module attributes for functions that have been decorated
|
||||
with @tool, @resource, or @prompt.
|
||||
Scans all module attributes for instances of Tool, Resource,
|
||||
ResourceTemplate, or Prompt objects created by standalone decorators.
|
||||
|
||||
Args:
|
||||
module: The imported module to scan.
|
||||
|
||||
Returns:
|
||||
List of (function, metadata) tuples for each decorated function.
|
||||
List of component objects (Tool, Resource, ResourceTemplate, Prompt).
|
||||
"""
|
||||
components: list[tuple[Any, FSMeta]] = []
|
||||
# Import here to avoid circular imports
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
component_types = (Tool, Resource, ResourceTemplate, Prompt)
|
||||
components: list[FastMCPComponent] = []
|
||||
|
||||
for name in dir(module):
|
||||
# Skip private/magic attributes
|
||||
|
|
@ -199,10 +203,9 @@ def extract_components(module: ModuleType) -> list[tuple[Any, FSMeta]]:
|
|||
except AttributeError:
|
||||
continue
|
||||
|
||||
# Check if this object has our marker
|
||||
meta = get_fs_meta(obj)
|
||||
if meta is not None:
|
||||
components.append((obj, meta))
|
||||
# Check if this object is a component type
|
||||
if isinstance(obj, component_types):
|
||||
components.append(obj)
|
||||
|
||||
return components
|
||||
|
||||
|
|
@ -221,7 +224,7 @@ def discover_and_import(root: Path) -> DiscoveryResult:
|
|||
Note:
|
||||
Files that fail to import are tracked in failed_files, not logged.
|
||||
The caller is responsible for logging/handling failures.
|
||||
Files with no decorated functions are silently skipped.
|
||||
Files with no components are silently skipped.
|
||||
"""
|
||||
result = DiscoveryResult()
|
||||
|
||||
|
|
@ -236,7 +239,7 @@ def discover_and_import(root: Path) -> DiscoveryResult:
|
|||
continue
|
||||
|
||||
components = extract_components(module)
|
||||
for func, meta in components:
|
||||
result.components.append((file_path, func, meta))
|
||||
for component in components:
|
||||
result.components.append((file_path, component))
|
||||
|
||||
return result
|
||||
|
|
@ -35,7 +35,9 @@ from mcp.types import Annotations, AnyFunction, ToolAnnotations
|
|||
|
||||
import fastmcp
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
|
||||
from fastmcp.prompts.prompt import prompt as standalone_prompt
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.resource import resource as standalone_resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.providers.base import Provider
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
|
|
@ -382,6 +384,10 @@ class LocalProvider(Provider):
|
|||
serializer: ToolResultSerializerType | None = None, # Deprecated
|
||||
) -> Callable[[AnyFunction], FunctionTool]: ...
|
||||
|
||||
# NOTE: This method mirrors fastmcp.tools.tool() but adds registration,
|
||||
# the `enabled` param, and supports deprecated params (serializer, exclude_args).
|
||||
# When deprecated params are removed, this should delegate to the standalone
|
||||
# decorator to reduce duplication.
|
||||
def tool(
|
||||
self,
|
||||
name_or_fn: str | AnyFunction | None = None,
|
||||
|
|
@ -455,7 +461,7 @@ class LocalProvider(Provider):
|
|||
annotations = ToolAnnotations(**annotations)
|
||||
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise ValueError(
|
||||
raise TypeError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
|
|
@ -579,83 +585,35 @@ class LocalProvider(Provider):
|
|||
return f"Weather for {city}"
|
||||
```
|
||||
"""
|
||||
if isinstance(annotations, dict):
|
||||
annotations = Annotations(**annotations)
|
||||
# Resolve task parameter - default to False for standalone usage
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Check if user passed function directly instead of calling decorator
|
||||
if inspect.isroutine(uri):
|
||||
raise TypeError(
|
||||
"The @resource decorator was used incorrectly. "
|
||||
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
||||
)
|
||||
# Get the standalone decorator
|
||||
create_resource = standalone_resource(
|
||||
uri,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
|
||||
def decorator(fn: AnyFunction) -> Resource | ResourceTemplate:
|
||||
if isinstance(fn, classmethod):
|
||||
raise ValueError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
resource() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve task parameter - default to False for standalone usage
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Check if this should be a template
|
||||
has_uri_params = "{" in uri and "}" in uri
|
||||
# Use wrapper to check for user-facing parameters
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
|
||||
wrapper_fn = without_injected_parameters(fn)
|
||||
has_func_params = bool(inspect.signature(wrapper_fn).parameters)
|
||||
|
||||
if has_uri_params or has_func_params:
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=fn,
|
||||
uri_template=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
self.add_template(template)
|
||||
# If disabled, add to blocklist
|
||||
if not enabled:
|
||||
self.disable(keys=[template.key])
|
||||
return template
|
||||
elif not has_uri_params and not has_func_params:
|
||||
resource_obj = Resource.from_function(
|
||||
fn=fn,
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
self.add_resource(resource_obj)
|
||||
# If disabled, add to blocklist
|
||||
if not enabled:
|
||||
self.disable(keys=[resource_obj.key])
|
||||
return resource_obj
|
||||
# Delegate to standalone decorator for object creation
|
||||
obj = create_resource(fn)
|
||||
# Register with this provider
|
||||
if isinstance(obj, ResourceTemplate):
|
||||
self.add_template(obj)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid resource or template definition due to a "
|
||||
"mismatch between URI parameters and function parameters."
|
||||
)
|
||||
self.add_resource(obj)
|
||||
# Handle enabled flag
|
||||
if not enabled:
|
||||
self.disable(keys=[obj.key])
|
||||
return obj
|
||||
|
||||
return decorator
|
||||
|
||||
|
|
@ -742,70 +700,39 @@ class LocalProvider(Provider):
|
|||
return [{"role": "user", "content": data}]
|
||||
```
|
||||
"""
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise ValueError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
prompt() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Determine the actual name and function based on the calling pattern
|
||||
if inspect.isroutine(name_or_fn):
|
||||
# Case 1: @prompt (without parens) - function passed directly
|
||||
# Case 2: direct call like prompt(fn, name="something")
|
||||
fn = name_or_fn
|
||||
prompt_name = name # Use keyword name if provided, otherwise None
|
||||
|
||||
# Resolve task parameter - default to False for standalone usage
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Register the prompt immediately
|
||||
prompt_obj = Prompt.from_function(
|
||||
fn=fn,
|
||||
name=prompt_name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
def register(prompt_obj: FunctionPrompt) -> FunctionPrompt:
|
||||
"""Register the prompt and handle enabled flag."""
|
||||
self.add_prompt(prompt_obj)
|
||||
# If disabled, add to blocklist
|
||||
if not enabled:
|
||||
self.disable(keys=[prompt_obj.key])
|
||||
return prompt_obj
|
||||
|
||||
elif isinstance(name_or_fn, str):
|
||||
# Case 3: @prompt("custom_name") - name passed as first argument
|
||||
if name is not None:
|
||||
raise TypeError(
|
||||
"Cannot specify both a name as first argument and as keyword argument. "
|
||||
f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both."
|
||||
)
|
||||
prompt_name = name_or_fn
|
||||
elif name_or_fn is None:
|
||||
# Case 4: @prompt() or @prompt(name="something") - use keyword name
|
||||
prompt_name = name
|
||||
else:
|
||||
raise TypeError(
|
||||
f"First argument to @prompt must be a function, string, or None, got {type(name_or_fn)}"
|
||||
)
|
||||
# Resolve task parameter - default to False for standalone usage
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Return partial for cases where we need to wait for the function
|
||||
return partial(
|
||||
self.prompt,
|
||||
name=prompt_name,
|
||||
# Delegate to standalone decorator for object creation
|
||||
# Type ignore: standalone_prompt has overloads for specific types, but we pass
|
||||
# through the union type. Runtime behavior is correct.
|
||||
result = standalone_prompt(
|
||||
name_or_fn, # type: ignore[arg-type]
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
enabled=enabled,
|
||||
meta=meta,
|
||||
task=task,
|
||||
task=supports_task,
|
||||
)
|
||||
|
||||
# If standalone returned a FunctionPrompt directly (@prompt without parens),
|
||||
# register it and return
|
||||
if isinstance(result, FunctionPrompt):
|
||||
return register(result)
|
||||
|
||||
# Otherwise, standalone returned a decorator/partial - wrap it to register after creation
|
||||
def decorator(fn: AnyFunction) -> FunctionPrompt:
|
||||
prompt_obj = result(fn)
|
||||
return register(prompt_obj)
|
||||
|
||||
return decorator
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import inspect
|
|||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
|
|
@ -764,3 +765,180 @@ def _convert_to_content(
|
|||
]
|
||||
# If none of the items are ContentBlocks, aggregate all items into a single TextContent
|
||||
return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))]
|
||||
|
||||
|
||||
# Type alias for any function that can be decorated
|
||||
AnyFunction = Callable[..., Any]
|
||||
|
||||
|
||||
@overload
|
||||
def tool(fn: AnyFunction) -> FunctionTool: ...
|
||||
|
||||
|
||||
@overload
|
||||
def tool(
|
||||
name_or_fn: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[mcp.types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
output_schema: dict[str, Any] | NotSetT | None = NotSet,
|
||||
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> Callable[[AnyFunction], FunctionTool]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def tool(
|
||||
name_or_fn: None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[mcp.types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
output_schema: dict[str, Any] | NotSetT | None = NotSet,
|
||||
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> Callable[[AnyFunction], FunctionTool]: ...
|
||||
|
||||
|
||||
def tool(
|
||||
name_or_fn: str | AnyFunction | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[mcp.types.Icon] | None = None,
|
||||
tags: set[str] | None = None,
|
||||
output_schema: dict[str, Any] | NotSetT | None = NotSet,
|
||||
annotations: ToolAnnotations | dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool | TaskConfig | None = None,
|
||||
) -> (
|
||||
Callable[[AnyFunction], FunctionTool]
|
||||
| FunctionTool
|
||||
| partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]
|
||||
):
|
||||
"""Standalone decorator to create a tool without registering it to a server.
|
||||
|
||||
This decorator creates a FunctionTool object from a function. Unlike
|
||||
@server.tool(), this does NOT register the tool with any server - you must
|
||||
explicitly add it using server.add_tool().
|
||||
|
||||
This is useful for:
|
||||
- Creating tools that will be transformed before registration
|
||||
- Defining tools in modules that are discovered by FileSystemProvider
|
||||
- Creating reusable tool definitions
|
||||
|
||||
This decorator supports multiple calling patterns:
|
||||
- @tool (without parentheses)
|
||||
- @tool() (with empty parentheses)
|
||||
- @tool("custom_name") (with name as first argument)
|
||||
- @tool(name="custom_name") (with name as keyword argument)
|
||||
|
||||
Args:
|
||||
name_or_fn: Either a function (when used as @tool), a string name, or None
|
||||
name: Optional name for the tool (keyword-only, alternative to name_or_fn)
|
||||
title: Optional title for the tool
|
||||
description: Optional description of what the tool does
|
||||
icons: Optional icons for the tool
|
||||
tags: Optional set of tags for categorizing the tool
|
||||
output_schema: Optional JSON schema for the tool's output
|
||||
annotations: Optional annotations about the tool's behavior
|
||||
meta: Optional meta information about the tool
|
||||
task: Optional task configuration for background execution (default False)
|
||||
|
||||
Returns:
|
||||
A FunctionTool when decorating a function, or a decorator function when
|
||||
called with parameters.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp.tools import tool
|
||||
from fastmcp import FastMCP
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@tool("search_products")
|
||||
def search(query: str) -> list[dict]:
|
||||
return database.search(query)
|
||||
|
||||
# Tools are not registered yet - add them explicitly
|
||||
mcp = FastMCP()
|
||||
mcp.add_tool(greet)
|
||||
mcp.add_tool(search)
|
||||
```
|
||||
"""
|
||||
if isinstance(annotations, dict):
|
||||
annotations = ToolAnnotations(**annotations)
|
||||
|
||||
if isinstance(name_or_fn, classmethod):
|
||||
raise TypeError(
|
||||
inspect.cleandoc(
|
||||
"""
|
||||
To decorate a classmethod, first define the method and then call
|
||||
tool() directly on the method instead of using it as a
|
||||
decorator. See https://gofastmcp.com/patterns/decorating-methods
|
||||
for examples and more information.
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Determine the actual name and function based on the calling pattern
|
||||
if inspect.isroutine(name_or_fn):
|
||||
# Case 1: @tool (without parens) - function passed directly
|
||||
fn = name_or_fn
|
||||
tool_name = name # Use keyword name if provided, otherwise None
|
||||
|
||||
# Default to False for standalone usage (no server to inherit from)
|
||||
supports_task: bool | TaskConfig = task if task is not None else False
|
||||
|
||||
# Create the tool object without registration
|
||||
return Tool.from_function(
|
||||
fn,
|
||||
name=tool_name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
output_schema=output_schema,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=supports_task,
|
||||
)
|
||||
|
||||
elif isinstance(name_or_fn, str):
|
||||
# Case 2: @tool("custom_name") - name passed as first argument
|
||||
if name is not None:
|
||||
raise TypeError(
|
||||
"Cannot specify both a name as first argument and as keyword argument. "
|
||||
f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both."
|
||||
)
|
||||
tool_name = name_or_fn
|
||||
elif name_or_fn is None:
|
||||
# Case 3: @tool() or @tool(name="something") - use keyword name
|
||||
tool_name = name
|
||||
else:
|
||||
raise TypeError(
|
||||
f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}"
|
||||
)
|
||||
|
||||
# Return partial for cases where we need to wait for the function
|
||||
return partial(
|
||||
tool,
|
||||
name=tool_name,
|
||||
title=title,
|
||||
description=description,
|
||||
icons=icons,
|
||||
tags=tags,
|
||||
output_schema=output_schema,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
task=task,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
"""Tests for fastmcp.fs discovery module."""
|
||||
"""Tests for filesystem discovery module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp.fs.decorators import ToolMeta
|
||||
from fastmcp.fs.discovery import (
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
from fastmcp.server.providers.filesystem_discovery import (
|
||||
discover_and_import,
|
||||
discover_files,
|
||||
extract_components,
|
||||
import_module_from_file,
|
||||
)
|
||||
from fastmcp.tools import FunctionTool
|
||||
|
||||
|
||||
class TestDiscoverFiles:
|
||||
|
|
@ -173,7 +174,7 @@ class TestExtractComponents:
|
|||
"""Tests for extract_components function."""
|
||||
|
||||
def test_extract_no_components(self, tmp_path: Path):
|
||||
"""Should return empty list for module with no decorated functions."""
|
||||
"""Should return empty list for module with no components."""
|
||||
py_file = tmp_path / "plain.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
|
|
@ -189,11 +190,11 @@ SOME_VAR = 42
|
|||
assert components == []
|
||||
|
||||
def test_extract_tool_component(self, tmp_path: Path):
|
||||
"""Should extract @tool decorated functions."""
|
||||
"""Should extract Tool objects."""
|
||||
py_file = tmp_path / "tools.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -205,16 +206,18 @@ def greet(name: str) -> str:
|
|||
components = extract_components(module)
|
||||
|
||||
assert len(components) == 1
|
||||
func, meta = components[0]
|
||||
assert func.__name__ == "greet"
|
||||
assert isinstance(meta, ToolMeta)
|
||||
component = components[0]
|
||||
assert isinstance(component, FunctionTool)
|
||||
assert component.name == "greet"
|
||||
|
||||
def test_extract_multiple_components(self, tmp_path: Path):
|
||||
"""Should extract multiple decorated functions."""
|
||||
"""Should extract multiple component types."""
|
||||
py_file = tmp_path / "multi.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool, resource, prompt
|
||||
from fastmcp.tools import tool
|
||||
from fastmcp.resources import resource
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -225,8 +228,8 @@ def get_config() -> dict:
|
|||
return {}
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
return []
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -234,21 +237,22 @@ def analyze(topic: str) -> list:
|
|||
components = extract_components(module)
|
||||
|
||||
assert len(components) == 3
|
||||
names = {func.__name__ for func, _ in components}
|
||||
assert names == {"greet", "get_config", "analyze"}
|
||||
types = {type(c).__name__ for c in components}
|
||||
assert types == {"FunctionTool", "FunctionResource", "FunctionPrompt"}
|
||||
|
||||
def test_extract_skips_private_functions(self, tmp_path: Path):
|
||||
"""Should skip private functions even if decorated."""
|
||||
def test_extract_skips_private_components(self, tmp_path: Path):
|
||||
"""Should skip private components (those starting with _)."""
|
||||
py_file = tmp_path / "private.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def public_tool() -> str:
|
||||
return "public"
|
||||
|
||||
@tool
|
||||
# The module attribute starts with _, so it's skipped during discovery
|
||||
@tool("private_tool_name")
|
||||
def _private_tool() -> str:
|
||||
return "private"
|
||||
"""
|
||||
|
|
@ -257,10 +261,31 @@ def _private_tool() -> str:
|
|||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
|
||||
# Only public tool should be found (private starts with _)
|
||||
# Only public_tool should be found (_private_tool starts with _, so skipped)
|
||||
assert len(components) == 1
|
||||
func, _ = components[0]
|
||||
assert func.__name__ == "public_tool"
|
||||
component = components[0]
|
||||
assert component.name == "public_tool"
|
||||
|
||||
def test_extract_resource_template(self, tmp_path: Path):
|
||||
"""Should extract ResourceTemplate objects."""
|
||||
py_file = tmp_path / "templates.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
return {"id": user_id}
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
|
||||
assert len(components) == 1
|
||||
component = components[0]
|
||||
assert isinstance(component, FunctionResourceTemplate)
|
||||
assert component.uri_template == "users://{user_id}/profile"
|
||||
|
||||
|
||||
class TestDiscoverAndImport:
|
||||
|
|
@ -278,7 +303,7 @@ class TestDiscoverAndImport:
|
|||
tools_dir.mkdir()
|
||||
(tools_dir / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -289,16 +314,16 @@ def greet(name: str) -> str:
|
|||
result = discover_and_import(tmp_path)
|
||||
|
||||
assert len(result.components) == 1
|
||||
file_path, func, meta = result.components[0]
|
||||
file_path, component = result.components[0]
|
||||
assert file_path.name == "greet.py"
|
||||
assert func.__name__ == "greet"
|
||||
assert isinstance(meta, ToolMeta)
|
||||
assert isinstance(component, FunctionTool)
|
||||
assert component.name == "greet"
|
||||
|
||||
def test_discover_and_import_skips_bad_imports(self, tmp_path: Path):
|
||||
"""Should skip files that fail to import and track them."""
|
||||
(tmp_path / "good.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def good_tool() -> str:
|
||||
|
|
@ -318,8 +343,8 @@ def bad_function():
|
|||
|
||||
# Only good.py should be imported
|
||||
assert len(result.components) == 1
|
||||
_, func, _ = result.components[0]
|
||||
assert func.__name__ == "good_tool"
|
||||
_, component = result.components[0]
|
||||
assert component.name == "good_tool"
|
||||
|
||||
# bad.py should be in failed_files
|
||||
assert len(result.failed_files) == 1
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Tests for fastmcp.fs FileSystemProvider."""
|
||||
"""Tests for FileSystemProvider."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
from fastmcp.server.providers import FileSystemProvider
|
||||
|
||||
|
||||
class TestFileSystemProvider:
|
||||
|
|
@ -22,7 +22,7 @@ class TestFileSystemProvider:
|
|||
tools_dir.mkdir()
|
||||
(tools_dir / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -40,7 +40,7 @@ def greet(name: str) -> str:
|
|||
"""Provider should discover @resource decorated functions."""
|
||||
(tmp_path / "config.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
|
|
@ -56,7 +56,7 @@ def get_config() -> dict:
|
|||
"""Provider should discover resource templates."""
|
||||
(tmp_path / "users.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
|
|
@ -72,7 +72,7 @@ def get_profile(user_id: str) -> dict:
|
|||
"""Provider should discover @prompt decorated functions."""
|
||||
(tmp_path / "analyze.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import prompt
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
|
|
@ -88,7 +88,8 @@ def analyze(topic: str) -> list:
|
|||
"""Provider should discover multiple components in one file."""
|
||||
(tmp_path / "multi.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool, resource, prompt
|
||||
from fastmcp.tools import tool
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@tool
|
||||
def tool1() -> str:
|
||||
|
|
@ -119,7 +120,7 @@ SOME_CONSTANT = 42
|
|||
)
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def my_tool() -> str:
|
||||
|
|
@ -139,7 +140,7 @@ class TestFileSystemProviderReloadMode:
|
|||
"""With reload=False, components are cached at init."""
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def original() -> str:
|
||||
|
|
@ -153,7 +154,7 @@ def original() -> str:
|
|||
# Add another file - should NOT be picked up
|
||||
(tmp_path / "tool2.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def added() -> str:
|
||||
|
|
@ -168,7 +169,7 @@ def added() -> str:
|
|||
"""With reload=True, components are rescanned on each request."""
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def original() -> str:
|
||||
|
|
@ -185,7 +186,7 @@ def original() -> str:
|
|||
# Add another file - should be picked up on next _ensure_loaded
|
||||
(tmp_path / "tool2.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def added() -> str:
|
||||
|
|
@ -252,7 +253,7 @@ def added() -> str:
|
|||
time.sleep(0.01)
|
||||
bad_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def my_tool() -> str:
|
||||
|
|
@ -284,7 +285,7 @@ class TestFileSystemProviderIntegration:
|
|||
"""FileSystemProvider should work with FastMCP server."""
|
||||
(tmp_path / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -310,7 +311,7 @@ def greet(name: str) -> str:
|
|||
"""FileSystemProvider should work with resources."""
|
||||
(tmp_path / "config.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> str:
|
||||
|
|
@ -336,7 +337,7 @@ def get_config() -> str:
|
|||
"""FileSystemProvider should work with resource templates."""
|
||||
(tmp_path / "users.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
from fastmcp.resources import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> str:
|
||||
|
|
@ -361,7 +362,7 @@ def get_profile(user_id: str) -> str:
|
|||
"""FileSystemProvider should work with prompts."""
|
||||
(tmp_path / "analyze.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import prompt
|
||||
from fastmcp.prompts import prompt
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
|
|
@ -390,7 +391,7 @@ def analyze(topic: str) -> str:
|
|||
tools.mkdir()
|
||||
(tools / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
|
|
@ -402,7 +403,7 @@ def greet(name: str) -> str:
|
|||
payments.mkdir()
|
||||
(payments / "charge.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
from fastmcp.tools import tool
|
||||
|
||||
@tool
|
||||
def charge(amount: float) -> str:
|
||||
|
|
|
|||
123
tests/prompts/test_standalone_decorator.py
Normal file
123
tests/prompts/test_standalone_decorator.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for the standalone @prompt decorator.
|
||||
|
||||
The @prompt decorator creates FunctionPrompt objects without registering them
|
||||
to a server. Objects can be added explicitly via server.add_prompt() or
|
||||
discovered by FileSystemProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.prompts import FunctionPrompt, prompt
|
||||
|
||||
|
||||
class TestPromptDecorator:
|
||||
"""Tests for the @prompt decorator."""
|
||||
|
||||
def test_prompt_without_parens(self):
|
||||
"""@prompt without parentheses should create a FunctionPrompt."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "analyze"
|
||||
|
||||
def test_prompt_with_empty_parens(self):
|
||||
"""@prompt() with empty parentheses should create a FunctionPrompt."""
|
||||
|
||||
@prompt()
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "analyze"
|
||||
|
||||
def test_prompt_with_name_arg(self):
|
||||
"""@prompt("name") with name as first arg should work."""
|
||||
|
||||
@prompt("custom-analyze")
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_name_kwarg(self):
|
||||
"""@prompt(name="name") with keyword arg should work."""
|
||||
|
||||
@prompt(name="custom-analyze")
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_all_metadata(self):
|
||||
"""@prompt with all metadata should store it all."""
|
||||
|
||||
@prompt(
|
||||
name="custom-analyze",
|
||||
title="Analysis Prompt",
|
||||
description="Analyzes topics",
|
||||
tags={"analysis"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def analyze(topic: str) -> str:
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
assert isinstance(analyze, FunctionPrompt)
|
||||
assert analyze.name == "custom-analyze"
|
||||
assert analyze.title == "Analysis Prompt"
|
||||
assert analyze.description == "Analyzes topics"
|
||||
assert analyze.tags == {"analysis"}
|
||||
assert analyze.meta == {"custom": "value"}
|
||||
|
||||
async def test_prompt_can_be_rendered(self):
|
||||
"""Prompt created by @prompt should be renderable."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
"""Analyze a topic."""
|
||||
return f"Analyze: {topic}"
|
||||
|
||||
result = await analyze.render({"topic": "Python"})
|
||||
assert result.messages[0].content.text == "Analyze: Python" # type: ignore[union-attr]
|
||||
|
||||
def test_prompt_rejects_classmethod_decorator(self):
|
||||
"""@prompt should reject classmethod-decorated functions."""
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@prompt # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def my_prompt(cls) -> str:
|
||||
return "hello"
|
||||
|
||||
def test_prompt_with_both_name_args_raises(self):
|
||||
"""@prompt should raise if both positional and keyword name are given."""
|
||||
with pytest.raises(TypeError, match="Cannot specify both"):
|
||||
|
||||
@prompt("name1", name="name2") # type: ignore[call-overload]
|
||||
def my_prompt() -> str:
|
||||
return "hello"
|
||||
|
||||
async def test_prompt_added_to_server(self):
|
||||
"""Prompt created by @prompt should work when added to a server."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
"""Analyze a topic."""
|
||||
return f"Please analyze: {topic}"
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_prompt(analyze)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
prompts = await client.list_prompts()
|
||||
assert any(p.name == "analyze" for p in prompts)
|
||||
|
||||
result = await client.get_prompt("analyze", {"topic": "Python"})
|
||||
assert "Python" in str(result)
|
||||
141
tests/resources/test_standalone_decorator.py
Normal file
141
tests/resources/test_standalone_decorator.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Tests for the standalone @resource decorator.
|
||||
|
||||
The @resource decorator creates Resource or ResourceTemplate objects without
|
||||
registering them to a server. Objects can be added explicitly via
|
||||
server.add_resource() / server.add_template() or discovered by FileSystemProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.resources import FunctionResource, resource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate
|
||||
|
||||
|
||||
class TestResourceDecorator:
|
||||
"""Tests for the @resource decorator."""
|
||||
|
||||
def test_resource_requires_uri(self):
|
||||
"""@resource should require a URI argument."""
|
||||
with pytest.raises(TypeError, match="requires a URI|was used incorrectly"):
|
||||
|
||||
@resource # type: ignore[arg-type]
|
||||
def get_config() -> str:
|
||||
return "{}"
|
||||
|
||||
def test_resource_with_uri(self):
|
||||
"""@resource("uri") should create a FunctionResource."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
assert str(get_config.uri) == "config://app"
|
||||
|
||||
def test_resource_with_template_uri(self):
|
||||
"""@resource with template URI should create a FunctionResourceTemplate."""
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
assert isinstance(get_profile, FunctionResourceTemplate)
|
||||
assert get_profile.uri_template == "users://{user_id}/profile"
|
||||
|
||||
def test_resource_with_function_params_becomes_template(self):
|
||||
"""@resource with function params and URI params should create a template."""
|
||||
|
||||
@resource("data://items/{category}")
|
||||
def get_items(category: str, limit: int = 10) -> list:
|
||||
return list(range(limit))
|
||||
|
||||
assert isinstance(get_items, FunctionResourceTemplate)
|
||||
assert get_items.uri_template == "data://items/{category}"
|
||||
|
||||
def test_resource_with_all_metadata(self):
|
||||
"""@resource with all metadata should store it all."""
|
||||
|
||||
@resource(
|
||||
"config://app",
|
||||
name="app-config",
|
||||
title="Application Config",
|
||||
description="Gets app configuration",
|
||||
mime_type="application/json",
|
||||
tags={"config"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
assert str(get_config.uri) == "config://app"
|
||||
assert get_config.name == "app-config"
|
||||
assert get_config.title == "Application Config"
|
||||
assert get_config.description == "Gets app configuration"
|
||||
assert get_config.mime_type == "application/json"
|
||||
assert get_config.tags == {"config"}
|
||||
assert get_config.meta == {"custom": "value"}
|
||||
|
||||
async def test_resource_can_be_read(self):
|
||||
"""Resource created by @resource should be readable."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
"""Get config."""
|
||||
return {"setting": "value"}
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
result = await get_config.read()
|
||||
assert result == {"setting": "value"}
|
||||
|
||||
def test_resource_rejects_classmethod_decorator(self):
|
||||
"""@resource should reject classmethod-decorated functions."""
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@resource("config://app") # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def get_config(cls) -> str:
|
||||
return "{}"
|
||||
|
||||
async def test_resource_added_to_server(self):
|
||||
"""Resource created by @resource should work when added to a server."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> str:
|
||||
"""Get config."""
|
||||
return '{"version": "1.0"}'
|
||||
|
||||
assert isinstance(get_config, FunctionResource)
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_resource(get_config)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
resources = await client.list_resources()
|
||||
assert any(str(r.uri) == "config://app" for r in resources)
|
||||
|
||||
result = await client.read_resource("config://app")
|
||||
assert "1.0" in str(result)
|
||||
|
||||
async def test_template_added_to_server(self):
|
||||
"""Template created by @resource should work when added to a server."""
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> str:
|
||||
"""Get user profile."""
|
||||
return f'{{"id": "{user_id}"}}'
|
||||
|
||||
assert isinstance(get_profile, FunctionResourceTemplate)
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_template(get_profile)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
templates = await client.list_resource_templates()
|
||||
assert any(t.uriTemplate == "users://{user_id}/profile" for t in templates)
|
||||
|
||||
result = await client.read_resource("users://123/profile")
|
||||
assert "123" in str(result)
|
||||
|
|
@ -294,10 +294,16 @@ class TestLocalProviderInterface:
|
|||
|
||||
|
||||
class TestLocalProviderDecorators:
|
||||
"""Tests for LocalProvider decorator methods."""
|
||||
"""Tests for LocalProvider decorator registration.
|
||||
|
||||
def test_tool_decorator_bare(self):
|
||||
"""Test @provider.tool without parentheses."""
|
||||
Note: Decorator calling patterns and metadata are tested in the standalone
|
||||
decorator tests (tests/tools/test_standalone_decorator.py, etc.). These tests
|
||||
focus on LocalProvider-specific behavior: registration into _components,
|
||||
the enabled flag, and round-trip execution via Client.
|
||||
"""
|
||||
|
||||
def test_tool_decorator_registers(self):
|
||||
"""Tool decorator should register in _components."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool
|
||||
|
|
@ -307,18 +313,8 @@ class TestLocalProviderDecorators:
|
|||
assert "tool:my_tool" in provider._components
|
||||
assert provider._components["tool:my_tool"].name == "my_tool"
|
||||
|
||||
def test_tool_decorator_with_parens(self):
|
||||
"""Test @provider.tool() with empty parentheses."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool()
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
assert "tool:my_tool" in provider._components
|
||||
|
||||
def test_tool_decorator_with_name_kwarg(self):
|
||||
"""Test @provider.tool(name='custom')."""
|
||||
def test_tool_decorator_with_custom_name_registers(self):
|
||||
"""Tool with custom name should register under that name."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool(name="custom_name")
|
||||
|
|
@ -328,18 +324,8 @@ class TestLocalProviderDecorators:
|
|||
assert "tool:custom_name" in provider._components
|
||||
assert "tool:my_tool" not in provider._components
|
||||
|
||||
def test_tool_decorator_with_description(self):
|
||||
"""Test @provider.tool(description='...')."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool(description="Custom description")
|
||||
def my_tool(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
assert provider._components["tool:my_tool"].description == "Custom description"
|
||||
|
||||
def test_tool_direct_call(self):
|
||||
"""Test provider.tool(fn, name='...')."""
|
||||
"""provider.tool(fn) should register the function."""
|
||||
provider = LocalProvider()
|
||||
|
||||
def my_tool(x: int) -> int:
|
||||
|
|
@ -349,8 +335,37 @@ class TestLocalProviderDecorators:
|
|||
|
||||
assert "tool:direct_tool" in provider._components
|
||||
|
||||
async def test_tool_decorator_execution(self):
|
||||
"""Test that decorated tools execute correctly."""
|
||||
def test_tool_enabled_false(self):
|
||||
"""Tool with enabled=False should be disabled."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool(enabled=False)
|
||||
def disabled_tool() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
assert "tool:disabled_tool" in provider._components
|
||||
tool = provider._components["tool:disabled_tool"]
|
||||
assert not provider._is_component_enabled(tool)
|
||||
|
||||
async def test_tool_enabled_false_not_listed(self):
|
||||
"""Disabled tool should not appear in list_tools."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool(enabled=False)
|
||||
def disabled_tool() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
@provider.tool
|
||||
def enabled_tool() -> str:
|
||||
return "should be enabled"
|
||||
|
||||
tools = await provider.list_tools()
|
||||
names = {t.name for t in tools}
|
||||
assert "enabled_tool" in names
|
||||
assert "disabled_tool" not in names
|
||||
|
||||
async def test_tool_roundtrip(self):
|
||||
"""Tool should execute correctly via Client."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.tool
|
||||
|
|
@ -363,8 +378,8 @@ class TestLocalProviderDecorators:
|
|||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
assert result.data == 5
|
||||
|
||||
def test_resource_decorator(self):
|
||||
"""Test @provider.resource decorator."""
|
||||
def test_resource_decorator_registers(self):
|
||||
"""Resource decorator should register in _components."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("resource://test")
|
||||
|
|
@ -373,8 +388,8 @@ class TestLocalProviderDecorators:
|
|||
|
||||
assert "resource:resource://test" in provider._components
|
||||
|
||||
def test_resource_decorator_with_name(self):
|
||||
"""Test @provider.resource with custom name."""
|
||||
def test_resource_with_custom_name_registers(self):
|
||||
"""Resource with custom name should register with that name."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("resource://test", name="custom_name")
|
||||
|
|
@ -383,8 +398,66 @@ class TestLocalProviderDecorators:
|
|||
|
||||
assert provider._components["resource:resource://test"].name == "custom_name"
|
||||
|
||||
async def test_resource_decorator_execution(self):
|
||||
"""Test that decorated resources execute correctly."""
|
||||
def test_resource_enabled_false(self):
|
||||
"""Resource with enabled=False should be disabled."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("resource://test", enabled=False)
|
||||
def disabled_resource() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
assert "resource:resource://test" in provider._components
|
||||
resource = provider._components["resource:resource://test"]
|
||||
assert not provider._is_component_enabled(resource)
|
||||
|
||||
async def test_resource_enabled_false_not_listed(self):
|
||||
"""Disabled resource should not appear in list_resources."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("resource://disabled", enabled=False)
|
||||
def disabled_resource() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
@provider.resource("resource://enabled")
|
||||
def enabled_resource() -> str:
|
||||
return "should be enabled"
|
||||
|
||||
resources = await provider.list_resources()
|
||||
uris = {str(r.uri) for r in resources}
|
||||
assert "resource://enabled" in uris
|
||||
assert "resource://disabled" not in uris
|
||||
|
||||
def test_template_enabled_false(self):
|
||||
"""Template with enabled=False should be disabled."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("data://{id}", enabled=False)
|
||||
def disabled_template(id: str) -> str:
|
||||
return f"Data {id}"
|
||||
|
||||
assert "template:data://{id}" in provider._components
|
||||
template = provider._components["template:data://{id}"]
|
||||
assert not provider._is_component_enabled(template)
|
||||
|
||||
async def test_template_enabled_false_not_listed(self):
|
||||
"""Disabled template should not appear in list_resource_templates."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("data://{id}", enabled=False)
|
||||
def disabled_template(id: str) -> str:
|
||||
return f"Data {id}"
|
||||
|
||||
@provider.resource("items://{id}")
|
||||
def enabled_template(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
templates = await provider.list_resource_templates()
|
||||
uris = {t.uri_template for t in templates}
|
||||
assert "items://{id}" in uris
|
||||
assert "data://{id}" not in uris
|
||||
|
||||
async def test_resource_roundtrip(self):
|
||||
"""Resource should execute correctly via Client."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.resource("resource://greeting")
|
||||
|
|
@ -397,8 +470,8 @@ class TestLocalProviderDecorators:
|
|||
result = await client.read_resource("resource://greeting")
|
||||
assert "Hello, World!" in str(result)
|
||||
|
||||
def test_prompt_decorator_bare(self):
|
||||
"""Test @provider.prompt without parentheses."""
|
||||
def test_prompt_decorator_registers(self):
|
||||
"""Prompt decorator should register in _components."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt
|
||||
|
|
@ -407,18 +480,8 @@ class TestLocalProviderDecorators:
|
|||
|
||||
assert "prompt:my_prompt" in provider._components
|
||||
|
||||
def test_prompt_decorator_with_parens(self):
|
||||
"""Test @provider.prompt() with empty parentheses."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt()
|
||||
def my_prompt() -> str:
|
||||
return "A prompt"
|
||||
|
||||
assert "prompt:my_prompt" in provider._components
|
||||
|
||||
def test_prompt_decorator_with_name(self):
|
||||
"""Test @provider.prompt(name='custom')."""
|
||||
def test_prompt_with_custom_name_registers(self):
|
||||
"""Prompt with custom name should register under that name."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt(name="custom_prompt")
|
||||
|
|
@ -428,6 +491,49 @@ class TestLocalProviderDecorators:
|
|||
assert "prompt:custom_prompt" in provider._components
|
||||
assert "prompt:my_prompt" not in provider._components
|
||||
|
||||
def test_prompt_enabled_false(self):
|
||||
"""Prompt with enabled=False should be disabled."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt(enabled=False)
|
||||
def disabled_prompt() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
assert "prompt:disabled_prompt" in provider._components
|
||||
prompt = provider._components["prompt:disabled_prompt"]
|
||||
assert not provider._is_component_enabled(prompt)
|
||||
|
||||
async def test_prompt_enabled_false_not_listed(self):
|
||||
"""Disabled prompt should not appear in list_prompts."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt(enabled=False)
|
||||
def disabled_prompt() -> str:
|
||||
return "should be disabled"
|
||||
|
||||
@provider.prompt
|
||||
def enabled_prompt() -> str:
|
||||
return "should be enabled"
|
||||
|
||||
prompts = await provider.list_prompts()
|
||||
names = {p.name for p in prompts}
|
||||
assert "enabled_prompt" in names
|
||||
assert "disabled_prompt" not in names
|
||||
|
||||
async def test_prompt_roundtrip(self):
|
||||
"""Prompt should execute correctly via Client."""
|
||||
provider = LocalProvider()
|
||||
|
||||
@provider.prompt
|
||||
def greeting(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
server = FastMCP("Test", providers=[provider])
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.get_prompt("greeting", {"name": "World"})
|
||||
assert "Hello, World!" in str(result)
|
||||
|
||||
|
||||
class TestLocalProviderToolTransformations:
|
||||
"""Tests for tool transformations in LocalProvider."""
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ class TestPromptDecorator:
|
|||
async def test_prompt_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.prompt
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ class TestResourceDecorator:
|
|||
async def test_resource_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.resource("resource://data")
|
||||
|
|
|
|||
|
|
@ -1201,7 +1201,7 @@ class TestToolDecorator:
|
|||
async def test_tool_decorator_classmethod_error(self):
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="To decorate a classmethod"):
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@mcp.tool
|
||||
|
|
|
|||
123
tests/tools/test_standalone_decorator.py
Normal file
123
tests/tools/test_standalone_decorator.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for the standalone @tool decorator.
|
||||
|
||||
The @tool decorator creates FunctionTool objects without registering them
|
||||
to a server. Objects can be added explicitly via server.add_tool() or
|
||||
discovered by FileSystemProvider.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.tools import FunctionTool, tool
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
"""Tests for the @tool decorator."""
|
||||
|
||||
def test_tool_without_parens(self):
|
||||
"""@tool without parentheses should create a FunctionTool."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "greet"
|
||||
|
||||
def test_tool_with_empty_parens(self):
|
||||
"""@tool() with empty parentheses should create a FunctionTool."""
|
||||
|
||||
@tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "greet"
|
||||
|
||||
def test_tool_with_name_arg(self):
|
||||
"""@tool("name") with name as first arg should work."""
|
||||
|
||||
@tool("custom-greet")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
|
||||
def test_tool_with_name_kwarg(self):
|
||||
"""@tool(name="name") with keyword arg should work."""
|
||||
|
||||
@tool(name="custom-greet")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
|
||||
def test_tool_with_all_metadata(self):
|
||||
"""@tool with all metadata should store it all."""
|
||||
|
||||
@tool(
|
||||
name="custom-greet",
|
||||
title="Greeting Tool",
|
||||
description="Greets people",
|
||||
tags={"greeting", "demo"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert isinstance(greet, FunctionTool)
|
||||
assert greet.name == "custom-greet"
|
||||
assert greet.title == "Greeting Tool"
|
||||
assert greet.description == "Greets people"
|
||||
assert greet.tags == {"greeting", "demo"}
|
||||
assert greet.meta == {"custom": "value"}
|
||||
|
||||
async def test_tool_can_be_run(self):
|
||||
"""Tool created by @tool should be runnable."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
result = await greet.run({"name": "World"})
|
||||
assert result.content[0].text == "Hello, World!" # type: ignore[union-attr]
|
||||
|
||||
def test_tool_rejects_classmethod_decorator(self):
|
||||
"""@tool should reject classmethod-decorated functions."""
|
||||
with pytest.raises(TypeError, match="classmethod"):
|
||||
|
||||
class MyClass:
|
||||
@tool # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def my_method(cls) -> str:
|
||||
return "hello"
|
||||
|
||||
def test_tool_with_both_name_args_raises(self):
|
||||
"""@tool should raise if both positional and keyword name are given."""
|
||||
with pytest.raises(TypeError, match="Cannot specify both"):
|
||||
|
||||
@tool("name1", name="name2") # type: ignore[call-overload]
|
||||
def my_tool() -> str:
|
||||
return "hello"
|
||||
|
||||
async def test_tool_added_to_server(self):
|
||||
"""Tool created by @tool should work when added to a server."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_tool(greet)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert any(t.name == "greet" for t in tools)
|
||||
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
assert result.data == "Hello, World!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue