diff --git a/docs/docs.json b/docs/docs.json
index 6ebb90fa55..7a9b883119 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -20,7 +20,10 @@
"primary": "#2d00f7"
},
"contextual": {
- "options": ["copy", "view"]
+ "options": [
+ "copy",
+ "view"
+ ]
},
"description": "The fast, Pythonic way to build MCP servers and clients.",
"errors": {
@@ -106,6 +109,7 @@
"pages": [
"servers/providers/overview",
"servers/providers/local",
+ "servers/providers/filesystem",
"servers/providers/mounting",
"servers/providers/namespacing",
"servers/providers/proxy",
@@ -157,7 +161,10 @@
{
"group": "Essentials",
"icon": "cube",
- "pages": ["clients/client", "clients/transports"]
+ "pages": [
+ "clients/client",
+ "clients/transports"
+ ]
},
{
"group": "Core Operations",
@@ -184,7 +191,10 @@
{
"group": "Authentication",
"icon": "user-shield",
- "pages": ["clients/auth/oauth", "clients/auth/bearer"]
+ "pages": [
+ "clients/auth/oauth",
+ "clients/auth/bearer"
+ ]
}
]
},
@@ -194,7 +204,10 @@
{
"group": "Providers",
"icon": "globe",
- "pages": ["integrations/fastapi", "integrations/openapi"]
+ "pages": [
+ "integrations/fastapi",
+ "integrations/openapi"
+ ]
},
{
"group": "Authentication",
@@ -336,6 +349,15 @@
"python-sdk/fastmcp-client-transports"
]
},
+ {
+ "group": "fastmcp.fs",
+ "pages": [
+ "python-sdk/fastmcp-fs-__init__",
+ "python-sdk/fastmcp-fs-decorators",
+ "python-sdk/fastmcp-fs-discovery",
+ "python-sdk/fastmcp-fs-provider"
+ ]
+ },
{
"group": "fastmcp.prompts",
"pages": [
diff --git a/docs/servers/providers/filesystem.mdx b/docs/servers/providers/filesystem.mdx
new file mode 100644
index 0000000000..671d76777b
--- /dev/null
+++ b/docs/servers/providers/filesystem.mdx
@@ -0,0 +1,251 @@
+---
+title: Filesystem Provider
+sidebarTitle: Filesystem
+description: Automatic component discovery from Python files
+icon: folder-tree
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+`FileSystemProvider` scans a directory for Python files and automatically registers functions decorated with `@tool`, `@resource`, or `@prompt`. This enables a file-based organization pattern similar to Next.js routing, where your project structure becomes your component registry.
+
+## Why Filesystem Discovery
+
+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.
+
+This is a convention some teams prefer, not necessarily better for all projects. The tradeoffs:
+
+- **No coordination**: Files don't import the server; server doesn't import files
+- **Predictable naming**: Function names become component names (unless overridden)
+- **Development mode**: Optionally re-scan files on every request for rapid iteration
+
+## Quick Start
+
+Create a provider pointing to your components directory, then pass it to your server.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.fs import FileSystemProvider
+
+mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
+```
+
+In your `mcp/` directory, create Python files with decorated functions.
+
+```python
+# mcp/tools/greet.py
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ """Greet someone by name."""
+ return f"Hello, {name}!"
+```
+
+When the server starts, `FileSystemProvider` scans the directory, imports all Python files, and registers any decorated functions it finds.
+
+## 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.
+
+### @tool
+
+Mark a function as a tool. The function name becomes the tool name by default.
+
+```python
+from fastmcp.fs import tool
+
+@tool
+def calculate_sum(a: float, b: float) -> float:
+ """Add two numbers together."""
+ return a + b
+```
+
+Customize the tool with optional parameters.
+
+```python
+from fastmcp.fs import tool
+
+@tool(
+ name="add-numbers",
+ description="Add two numbers together.",
+ tags={"math", "arithmetic"},
+)
+def add(a: float, b: float) -> float:
+ return a + b
+```
+
+The decorator supports all standard tool options: `name`, `title`, `description`, `icons`, `tags`, `output_schema`, `annotations`, and `meta`.
+
+### @resource
+
+Mark a function as a resource. Unlike `@tool`, the `@resource` decorator requires a URI argument.
+
+```python
+from fastmcp.fs import resource
+
+@resource("config://app")
+def get_app_config() -> str:
+ """Get application configuration."""
+ return '{"version": "1.0"}'
+```
+
+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
+
+@resource("users://{user_id}/profile")
+def get_user_profile(user_id: str) -> str:
+ """Get a user's profile by ID."""
+ return f'{{"id": "{user_id}", "name": "User"}}'
+```
+
+The decorator supports: `uri` (required), `name`, `title`, `description`, `icons`, `mime_type`, `tags`, `annotations`, and `meta`.
+
+### @prompt
+
+Mark a function as a prompt template.
+
+```python
+from fastmcp.fs import prompt
+
+@prompt
+def code_review(code: str, language: str = "python") -> str:
+ """Generate a code review prompt."""
+ return f"Please review this {language} code:\n\n```{language}\n{code}\n```"
+```
+
+```python
+from fastmcp.fs import prompt
+
+@prompt(name="explain-concept", tags={"education"})
+def explain(topic: str) -> str:
+ """Generate an explanation prompt."""
+ return f"Explain {topic} using clear examples and analogies."
+```
+
+The decorator supports: `name`, `title`, `description`, `icons`, `tags`, and `meta`.
+
+## Directory Structure
+
+The directory structure is purely organizational. The provider recursively scans all `.py` files regardless of which subdirectory they're in. Subdirectories like `tools/`, `resources/`, and `prompts/` are optional conventions that help you organize code.
+
+```
+mcp/
+├── tools/
+│ ├── greeting.py # @tool functions
+│ └── calculator.py # @tool functions
+├── resources/
+│ └── config.py # @resource functions
+└── prompts/
+ └── assistant.py # @prompt functions
+```
+
+You can also put all components in a single file or organize by feature rather than type.
+
+```
+mcp/
+├── user_management.py # @tool, @resource, @prompt for users
+├── billing.py # @tool, @resource for billing
+└── analytics.py # @tool for analytics
+```
+
+## Discovery Rules
+
+The provider follows these rules when scanning:
+
+| Rule | Behavior |
+|------|----------|
+| File extensions | Only `.py` files are scanned |
+| `__init__.py` | Skipped (used for package structure, not components) |
+| `__pycache__` | Skipped |
+| Private functions | Functions starting with `_` are ignored, even if decorated |
+| No decorators | Files without `@tool`, `@resource`, or `@prompt` are silently skipped |
+| Multiple components | A single file can contain any number of decorated functions |
+
+### Package Imports
+
+If your directory contains an `__init__.py` file, the provider imports files as proper Python package members. This means relative imports work correctly within your components directory.
+
+```python
+# mcp/__init__.py exists
+
+# mcp/tools/greeting.py
+from ..helpers import format_name # Relative imports work
+
+@tool
+def greet(name: str) -> str:
+ return f"Hello, {format_name(name)}!"
+```
+
+Without `__init__.py`, files are imported directly using `importlib.util.spec_from_file_location`.
+
+## Reload Mode
+
+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
+
+provider = FileSystemProvider("mcp/", reload=True)
+```
+
+With `reload=True`, the provider:
+
+1. Re-discovers all Python files on each request
+2. Re-imports modules that have changed
+3. Updates the component registry with any new, modified, or removed components
+
+
+Reload mode adds overhead to every request. Use it only during development, not in production.
+
+
+## Error Handling
+
+When a file fails to import (syntax error, missing dependency, etc.), the provider logs a warning and continues scanning other files. Failed imports don't prevent the server from starting.
+
+```
+WARNING - Failed to import /path/to/broken.py: No module named 'missing_dep'
+```
+
+The provider tracks which files have failed and only re-logs warnings when the file's modification time changes. This prevents log spam when a broken file is repeatedly scanned in reload mode.
+
+## Example Project
+
+A complete example is available in the repository at `examples/filesystem-provider/`. The structure demonstrates the recommended organization.
+
+```
+examples/filesystem-provider/
+├── server.py # Server entry point
+└── mcp/
+ ├── tools/
+ │ ├── greeting.py # greet, farewell tools
+ │ └── calculator.py # add, multiply tools
+ ├── resources/
+ │ └── config.py # Static and templated resources
+ └── prompts/
+ └── assistant.py # code_review, explain prompts
+```
+
+The server entry point is minimal.
+
+```python
+from pathlib import Path
+
+from fastmcp import FastMCP
+from fastmcp.fs import FileSystemProvider
+
+provider = FileSystemProvider(
+ root=Path(__file__).parent / "mcp",
+ reload=True,
+)
+
+mcp = FastMCP("FilesystemDemo", providers=[provider])
+```
+
+Run with `fastmcp run examples/filesystem-provider/server.py` or inspect with `fastmcp inspect examples/filesystem-provider/server.py`.
diff --git a/examples/filesystem-provider/mcp/prompts/assistant.py b/examples/filesystem-provider/mcp/prompts/assistant.py
new file mode 100644
index 0000000000..2181a5599e
--- /dev/null
+++ b/examples/filesystem-provider/mcp/prompts/assistant.py
@@ -0,0 +1,39 @@
+"""Assistant prompts."""
+
+from fastmcp.fs import prompt
+
+
+@prompt
+def code_review(code: str, language: str = "python") -> str:
+ """Generate a code review prompt.
+
+ Args:
+ code: The code to review.
+ language: Programming language (default: python).
+ """
+ return f"""Please review this {language} code:
+
+```{language}
+{code}
+```
+
+Focus on:
+- Code quality and readability
+- Potential bugs or issues
+- Performance considerations
+- Best practices"""
+
+
+@prompt(
+ name="explain-concept",
+ description="Generate a prompt to explain a technical concept.",
+ tags={"education", "explanation"},
+)
+def explain(topic: str, audience: str = "developer") -> str:
+ """Generate an explanation prompt.
+
+ Args:
+ topic: The concept to explain.
+ audience: Target audience level.
+ """
+ return f"Explain {topic} to a {audience}. Use clear examples and analogies."
diff --git a/examples/filesystem-provider/mcp/resources/config.py b/examples/filesystem-provider/mcp/resources/config.py
new file mode 100644
index 0000000000..d53bd83853
--- /dev/null
+++ b/examples/filesystem-provider/mcp/resources/config.py
@@ -0,0 +1,55 @@
+"""Configuration resources - static and templated."""
+
+import json
+
+from fastmcp.fs import resource
+
+
+# Static resource - no parameters in URI
+@resource("config://app")
+def get_app_config() -> str:
+ """Get application configuration."""
+ return json.dumps(
+ {
+ "name": "FilesystemDemo",
+ "version": "1.0.0",
+ "features": ["tools", "resources", "prompts"],
+ },
+ indent=2,
+ )
+
+
+# Resource template - {env} is a parameter
+@resource("config://env/{env}")
+def get_env_config(env: str) -> str:
+ """Get environment-specific configuration.
+
+ Args:
+ env: Environment name (dev, staging, prod).
+ """
+ configs = {
+ "dev": {"debug": True, "log_level": "DEBUG", "database": "localhost"},
+ "staging": {"debug": True, "log_level": "INFO", "database": "staging-db"},
+ "prod": {"debug": False, "log_level": "WARNING", "database": "prod-db"},
+ }
+ config = configs.get(env, {"error": f"Unknown environment: {env}"})
+ return json.dumps(config, indent=2)
+
+
+# Resource with custom metadata
+@resource(
+ "config://features",
+ name="feature-flags",
+ mime_type="application/json",
+ tags={"config", "features"},
+)
+def get_feature_flags() -> str:
+ """Get feature flags configuration."""
+ return json.dumps(
+ {
+ "dark_mode": True,
+ "beta_features": False,
+ "max_upload_size_mb": 100,
+ },
+ indent=2,
+ )
diff --git a/examples/filesystem-provider/mcp/tools/calculator.py b/examples/filesystem-provider/mcp/tools/calculator.py
new file mode 100644
index 0000000000..ac9079f73a
--- /dev/null
+++ b/examples/filesystem-provider/mcp/tools/calculator.py
@@ -0,0 +1,24 @@
+"""Math tools with custom metadata."""
+
+from fastmcp.fs import tool
+
+
+@tool(
+ name="add-numbers", # Custom name (default would be "add")
+ description="Add two numbers together.",
+ tags={"math", "arithmetic"},
+)
+def add(a: float, b: float) -> float:
+ """Add two numbers."""
+ return a + b
+
+
+@tool(tags={"math", "arithmetic"})
+def multiply(a: float, b: float) -> float:
+ """Multiply two numbers.
+
+ Args:
+ a: First number.
+ b: Second number.
+ """
+ return a * b
diff --git a/examples/filesystem-provider/mcp/tools/greeting.py b/examples/filesystem-provider/mcp/tools/greeting.py
new file mode 100644
index 0000000000..3dcea84d49
--- /dev/null
+++ b/examples/filesystem-provider/mcp/tools/greeting.py
@@ -0,0 +1,28 @@
+"""Greeting tools - multiple tools in one file."""
+
+from fastmcp.fs import tool
+
+
+@tool
+def greet(name: str) -> str:
+ """Greet someone by name.
+
+ Args:
+ name: The person's name.
+ """
+ return f"Hello, {name}!"
+
+
+@tool
+def farewell(name: str) -> str:
+ """Say goodbye to someone.
+
+ Args:
+ name: The person's name.
+ """
+ return f"Goodbye, {name}!"
+
+
+# Helper functions without decorators are ignored
+def _format_message(msg: str) -> str:
+ return msg.strip().capitalize()
diff --git a/examples/filesystem-provider/server.py b/examples/filesystem-provider/server.py
new file mode 100644
index 0000000000..9a7267c31c
--- /dev/null
+++ b/examples/filesystem-provider/server.py
@@ -0,0 +1,29 @@
+"""Filesystem-based MCP server using FileSystemProvider.
+
+This example demonstrates how to use FileSystemProvider to automatically
+discover and register tools, resources, and prompts from the filesystem.
+
+Run:
+ fastmcp run examples/filesystem-provider/server.py
+
+Inspect:
+ fastmcp inspect examples/filesystem-provider/server.py
+
+Dev mode (re-scan files on every request):
+ Change reload=True below, then modify files while the server runs.
+"""
+
+from pathlib import Path
+
+from fastmcp import FastMCP
+from fastmcp.fs import FileSystemProvider
+
+# The provider scans all .py files in the directory recursively.
+# Functions decorated with @tool, @resource, or @prompt are registered.
+# Directory structure is purely organizational - decorators determine type.
+provider = FileSystemProvider(
+ root=Path(__file__).parent / "mcp",
+ reload=True, # Set True for dev mode (re-scan on every request)
+)
+
+mcp = FastMCP("FilesystemDemo", providers=[provider])
diff --git a/src/fastmcp/fs/__init__.py b/src/fastmcp/fs/__init__.py
new file mode 100644
index 0000000000..09a6bca97b
--- /dev/null
+++ b/src/fastmcp/fs/__init__.py
@@ -0,0 +1,45 @@
+"""Filesystem-based component discovery for FastMCP.
+
+This module provides decorators and a provider for discovering MCP components
+from the filesystem. Files are scanned for functions decorated with @tool,
+@resource, or @prompt, and automatically registered with the server.
+
+Example:
+ ```python
+ # server.py
+ from fastmcp import FastMCP
+ from fastmcp.fs import FileSystemProvider
+
+ mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
+ ```
+
+ ```python
+ # mcp/tools/greet.py
+ from fastmcp.fs import tool
+
+ @tool
+ def greet(name: str) -> str:
+ '''Greet someone by name.'''
+ return f"Hello, {name}!"
+ ```
+
+ ```python
+ # mcp/resources/config.py
+ from fastmcp.fs import resource
+
+ @resource("config://app")
+ def get_config() -> dict:
+ '''Get application configuration.'''
+ return {"version": "1.0"}
+ ```
+"""
+
+from fastmcp.fs.decorators import prompt, resource, tool
+from fastmcp.fs.provider import FileSystemProvider
+
+__all__ = [
+ "FileSystemProvider",
+ "prompt",
+ "resource",
+ "tool",
+]
diff --git a/src/fastmcp/fs/decorators.py b/src/fastmcp/fs/decorators.py
new file mode 100644
index 0000000000..8ccb3a3ecf
--- /dev/null
+++ b/src/fastmcp/fs/decorators.py
@@ -0,0 +1,394 @@
+"""Decorators for marking functions in filesystem-based discovery.
+
+These decorators mark functions with metadata so that FileSystemProvider
+can discover and register them. Unlike LocalProvider's decorators, these
+do NOT register components immediately - they just store metadata on the
+function for later discovery.
+
+Example:
+ ```python
+ # mcp/tools/greet.py
+ from fastmcp.fs import tool
+
+ @tool
+ def greet(name: str) -> str:
+ '''Greet someone by name.'''
+ return f"Hello, {name}!"
+
+ @tool(name="custom-greet", tags={"greeting"})
+ def my_greet(name: str) -> str:
+ return f"Hi, {name}!"
+ ```
+"""
+
+from __future__ import annotations
+
+import inspect
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Literal, overload
+
+from mcp.types import Annotations, AnyFunction, ToolAnnotations
+
+if TYPE_CHECKING:
+ import mcp.types
+
+# Attribute name used to store metadata on decorated functions
+FS_META_ATTR = "_fastmcp_fs_meta"
+
+
+@dataclass
+class ToolMeta:
+ """Metadata stored on functions decorated with @tool."""
+
+ type: Literal["tool"] = "tool"
+ name: str | None = None
+ title: str | None = None
+ description: str | None = None
+ icons: list[mcp.types.Icon] | None = None
+ tags: set[str] | None = None
+ output_schema: dict[str, Any] | None = None
+ annotations: ToolAnnotations | None = None
+ meta: dict[str, Any] | None = None
+
+
+@dataclass
+class ResourceMeta:
+ """Metadata stored on functions decorated with @resource."""
+
+ type: Literal["resource"] = "resource"
+ uri: str = ""
+ name: str | None = None
+ title: str | None = None
+ description: str | None = None
+ icons: list[mcp.types.Icon] | None = None
+ mime_type: str | None = None
+ tags: set[str] | None = None
+ annotations: Annotations | None = None
+ meta: dict[str, Any] | None = None
+
+
+@dataclass
+class PromptMeta:
+ """Metadata stored on functions decorated with @prompt."""
+
+ type: Literal["prompt"] = "prompt"
+ name: str | None = None
+ title: str | None = None
+ description: str | None = None
+ icons: list[mcp.types.Icon] | None = None
+ tags: set[str] | None = None
+ meta: dict[str, Any] | None = None
+
+
+FSMeta = ToolMeta | ResourceMeta | PromptMeta
+
+
+def get_fs_meta(fn: Any) -> FSMeta | None:
+ """Get filesystem metadata from a function if it has been decorated."""
+ return getattr(fn, FS_META_ATTR, None)
+
+
+def has_fs_meta(fn: Any) -> bool:
+ """Check if a function has filesystem metadata."""
+ return hasattr(fn, FS_META_ATTR)
+
+
+# =============================================================================
+# @tool decorator
+# =============================================================================
+
+
+@overload
+def tool(fn: AnyFunction) -> AnyFunction: ...
+
+
+@overload
+def tool(
+ fn: None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any: ...
+
+
+@overload
+def tool(
+ fn: str,
+ *,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any: ...
+
+
+def tool(
+ fn: AnyFunction | str | None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ annotations: ToolAnnotations | dict[str, Any] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any:
+ """Mark a function as a tool for filesystem-based discovery.
+
+ This decorator stores metadata on the function but does NOT register it.
+ FileSystemProvider discovers marked functions when scanning directories.
+
+ Supports multiple calling patterns:
+ - @tool (without parentheses)
+ - @tool() (with empty parentheses)
+ - @tool("custom_name") (with name as first argument)
+ - @tool(name="custom_name") (with keyword arguments)
+
+ Args:
+ fn: The function to decorate, or a name string, or None
+ name: Optional name for the tool (defaults to function name)
+ title: Optional title for display
+ description: Optional description (defaults to docstring)
+ icons: Optional icons for the tool
+ tags: Optional tags for categorization
+ output_schema: Optional JSON schema for output
+ annotations: Optional tool annotations
+ meta: Optional metadata dict
+
+ Example:
+ ```python
+ @tool
+ def greet(name: str) -> str:
+ '''Greet someone.'''
+ return f"Hello, {name}!"
+
+ @tool(name="custom-greet", tags={"greeting"})
+ def my_greet(name: str) -> str:
+ return f"Hi, {name}!"
+ ```
+ """
+ if isinstance(annotations, dict):
+ annotations = ToolAnnotations(**annotations)
+
+ def decorator(func: AnyFunction) -> AnyFunction:
+ tool_meta = ToolMeta(
+ name=name,
+ title=title,
+ description=description,
+ icons=icons,
+ tags=tags,
+ output_schema=output_schema,
+ annotations=annotations,
+ meta=meta,
+ )
+ setattr(func, FS_META_ATTR, tool_meta)
+ return func
+
+ if inspect.isroutine(fn):
+ # @tool without parentheses
+ return decorator(fn)
+ elif isinstance(fn, str):
+ # @tool("custom_name")
+ return tool(
+ name=fn,
+ title=title,
+ description=description,
+ icons=icons,
+ tags=tags,
+ output_schema=output_schema,
+ annotations=annotations,
+ meta=meta,
+ )
+ else:
+ # @tool() or @tool(name="...") - return decorator
+ return decorator
+
+
+# =============================================================================
+# @resource decorator
+# =============================================================================
+
+
+def resource(
+ uri: str,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ mime_type: str | None = None,
+ tags: set[str] | None = None,
+ annotations: Annotations | dict[str, Any] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any:
+ """Mark a function as a resource for filesystem-based discovery.
+
+ This decorator stores metadata on the function but does NOT register it.
+ FileSystemProvider discovers marked functions when scanning directories.
+
+ Unlike @tool and @prompt, @resource REQUIRES a URI argument.
+
+ Args:
+ uri: URI for the resource (e.g., "config://app" or "users://{user_id}")
+ name: Optional name for the resource
+ title: Optional title for display
+ description: Optional description (defaults to docstring)
+ icons: Optional icons for the resource
+ mime_type: Optional MIME type
+ tags: Optional tags for categorization
+ annotations: Optional resource annotations
+ meta: Optional metadata dict
+
+ Example:
+ ```python
+ @resource("config://app")
+ def get_config() -> dict:
+ return {"setting": "value"}
+
+ @resource("users://{user_id}/profile")
+ def get_profile(user_id: str) -> dict:
+ return {"id": user_id, "name": "User"}
+ ```
+ """
+ if inspect.isroutine(uri):
+ raise TypeError(
+ "The @resource decorator requires a URI. "
+ "Use @resource('uri://...') instead of @resource"
+ )
+
+ if isinstance(annotations, dict):
+ annotations = Annotations(**annotations)
+
+ def decorator(func: AnyFunction) -> AnyFunction:
+ resource_meta = ResourceMeta(
+ uri=uri,
+ name=name,
+ title=title,
+ description=description,
+ icons=icons,
+ mime_type=mime_type,
+ tags=tags,
+ annotations=annotations,
+ meta=meta,
+ )
+ setattr(func, FS_META_ATTR, resource_meta)
+ return func
+
+ return decorator
+
+
+# =============================================================================
+# @prompt decorator
+# =============================================================================
+
+
+@overload
+def prompt(fn: AnyFunction) -> AnyFunction: ...
+
+
+@overload
+def prompt(
+ fn: None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any: ...
+
+
+@overload
+def prompt(
+ fn: str,
+ *,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any: ...
+
+
+def prompt(
+ fn: AnyFunction | str | None = None,
+ *,
+ name: str | None = None,
+ title: str | None = None,
+ description: str | None = None,
+ icons: list[mcp.types.Icon] | None = None,
+ tags: set[str] | None = None,
+ meta: dict[str, Any] | None = None,
+) -> Any:
+ """Mark a function as a prompt for filesystem-based discovery.
+
+ This decorator stores metadata on the function but does NOT register it.
+ FileSystemProvider discovers marked functions when scanning directories.
+
+ Supports multiple calling patterns:
+ - @prompt (without parentheses)
+ - @prompt() (with empty parentheses)
+ - @prompt("custom_name") (with name as first argument)
+ - @prompt(name="custom_name") (with keyword arguments)
+
+ Args:
+ fn: The function to decorate, or a name string, or None
+ name: Optional name for the prompt (defaults to function name)
+ title: Optional title for display
+ description: Optional description (defaults to docstring)
+ icons: Optional icons for the prompt
+ tags: Optional tags for categorization
+ meta: Optional metadata dict
+
+ Example:
+ ```python
+ @prompt
+ def analyze(topic: str) -> list:
+ '''Analyze a topic.'''
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ @prompt(name="custom-analyze")
+ def my_analyze(topic: str) -> list:
+ return [{"role": "user", "content": topic}]
+ ```
+ """
+
+ def decorator(func: AnyFunction) -> AnyFunction:
+ prompt_meta = PromptMeta(
+ name=name,
+ title=title,
+ description=description,
+ icons=icons,
+ tags=tags,
+ meta=meta,
+ )
+ setattr(func, FS_META_ATTR, prompt_meta)
+ return func
+
+ if inspect.isroutine(fn):
+ # @prompt without parentheses
+ return decorator(fn)
+ elif isinstance(fn, str):
+ # @prompt("custom_name")
+ return prompt(
+ name=fn,
+ title=title,
+ description=description,
+ icons=icons,
+ tags=tags,
+ meta=meta,
+ )
+ else:
+ # @prompt() or @prompt(name="...") - return decorator
+ return decorator
diff --git a/src/fastmcp/fs/discovery.py b/src/fastmcp/fs/discovery.py
new file mode 100644
index 0000000000..311aee94e4
--- /dev/null
+++ b/src/fastmcp/fs/discovery.py
@@ -0,0 +1,242 @@
+"""File discovery and module import utilities for filesystem-based routing.
+
+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
+"""
+
+from __future__ import annotations
+
+import importlib.util
+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.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+@dataclass
+class DiscoveryResult:
+ """Result of filesystem discovery."""
+
+ components: list[tuple[Path, Any, FSMeta]] = field(default_factory=list)
+ failed_files: dict[Path, str] = field(default_factory=dict) # path -> error message
+
+
+def discover_files(root: Path) -> list[Path]:
+ """Recursively discover all Python files under a directory.
+
+ Excludes __init__.py files (they're for package structure, not components).
+
+ Args:
+ root: Root directory to scan.
+
+ Returns:
+ List of .py file paths, sorted for deterministic order.
+ """
+ if not root.exists():
+ return []
+
+ if not root.is_dir():
+ # If root is a file, just return it (if it's a .py file)
+ if root.suffix == ".py" and root.name != "__init__.py":
+ return [root]
+ return []
+
+ files: list[Path] = []
+ for path in root.rglob("*.py"):
+ # Skip __init__.py files
+ if path.name == "__init__.py":
+ continue
+ # Skip __pycache__ directories
+ if "__pycache__" in path.parts:
+ continue
+ files.append(path)
+
+ # Sort for deterministic discovery order
+ return sorted(files)
+
+
+def _is_package_dir(directory: Path) -> bool:
+ """Check if a directory is a Python package (has __init__.py)."""
+ return (directory / "__init__.py").exists()
+
+
+def _find_package_root(file_path: Path) -> Path | None:
+ """Find the root of the package containing this file.
+
+ Walks up the directory tree until we find a directory without __init__.py.
+
+ Returns:
+ The package root directory, or None if not in a package.
+ """
+ current = file_path.parent
+ package_root = None
+
+ while current != current.parent: # Stop at filesystem root
+ if _is_package_dir(current):
+ package_root = current
+ current = current.parent
+ else:
+ break
+
+ return package_root
+
+
+def _compute_module_name(file_path: Path, package_root: Path) -> str:
+ """Compute the dotted module name for a file within a package.
+
+ Args:
+ file_path: Path to the Python file.
+ package_root: Root directory of the package.
+
+ Returns:
+ Dotted module name (e.g., "mcp.tools.greet").
+ """
+ relative = file_path.relative_to(package_root.parent)
+ parts = list(relative.parts)
+ # Remove .py extension from last part
+ parts[-1] = parts[-1].removesuffix(".py")
+ return ".".join(parts)
+
+
+def import_module_from_file(file_path: Path) -> ModuleType:
+ """Import a Python file as a module.
+
+ If the file is part of a package (directory has __init__.py), imports
+ it as a proper package member (relative imports work). Otherwise,
+ imports directly using spec_from_file_location.
+
+ Args:
+ file_path: Path to the Python file.
+
+ Returns:
+ The imported module.
+
+ Raises:
+ ImportError: If the module cannot be imported.
+ """
+ file_path = file_path.resolve()
+
+ # Check if this file is part of a package
+ package_root = _find_package_root(file_path)
+
+ if package_root is not None:
+ # Import as part of a package
+ module_name = _compute_module_name(file_path, package_root)
+
+ # Ensure package root's parent is in sys.path
+ package_parent = str(package_root.parent)
+ if package_parent not in sys.path:
+ sys.path.insert(0, package_parent)
+
+ # Import using standard import machinery
+ # If already imported, reload to pick up changes (for reload mode)
+ try:
+ if module_name in sys.modules:
+ return importlib.reload(sys.modules[module_name])
+ return importlib.import_module(module_name)
+ except ImportError as e:
+ raise ImportError(
+ f"Failed to import {module_name} from {file_path}: {e}"
+ ) from e
+ else:
+ # Import directly using spec_from_file_location
+ module_name = file_path.stem
+
+ # Ensure parent directory is in sys.path for imports
+ parent_dir = str(file_path.parent)
+ if parent_dir not in sys.path:
+ sys.path.insert(0, parent_dir)
+
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Cannot load spec for {file_path}")
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+
+ try:
+ spec.loader.exec_module(module)
+ except Exception as e:
+ # Clean up sys.modules on failure
+ sys.modules.pop(module_name, None)
+ raise ImportError(f"Failed to execute module {file_path}: {e}") from e
+
+ return module
+
+
+def extract_components(module: ModuleType) -> list[tuple[Any, FSMeta]]:
+ """Extract all decorated functions from a module.
+
+ Scans all module attributes for functions that have been decorated
+ with @tool, @resource, or @prompt.
+
+ Args:
+ module: The imported module to scan.
+
+ Returns:
+ List of (function, metadata) tuples for each decorated function.
+ """
+ components: list[tuple[Any, FSMeta]] = []
+
+ for name in dir(module):
+ # Skip private/magic attributes
+ if name.startswith("_"):
+ continue
+
+ try:
+ obj = getattr(module, name)
+ except AttributeError:
+ continue
+
+ # Check if this object has our marker
+ meta = get_fs_meta(obj)
+ if meta is not None:
+ components.append((obj, meta))
+
+ return components
+
+
+def discover_and_import(root: Path) -> DiscoveryResult:
+ """Discover files, import modules, and extract components.
+
+ This is the main entry point for filesystem-based discovery.
+
+ Args:
+ root: Root directory to scan.
+
+ Returns:
+ DiscoveryResult with components and any failed files.
+
+ 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.
+ """
+ result = DiscoveryResult()
+
+ for file_path in discover_files(root):
+ try:
+ module = import_module_from_file(file_path)
+ except ImportError as e:
+ result.failed_files[file_path] = str(e)
+ continue
+ except Exception as e:
+ result.failed_files[file_path] = str(e)
+ continue
+
+ components = extract_components(module)
+ for func, meta in components:
+ result.components.append((file_path, func, meta))
+
+ return result
diff --git a/src/fastmcp/fs/provider.py b/src/fastmcp/fs/provider.py
new file mode 100644
index 0000000000..0cec6c50f6
--- /dev/null
+++ b/src/fastmcp/fs/provider.py
@@ -0,0 +1,261 @@
+"""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.
+
+Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.fs import FileSystemProvider
+
+ mcp = FastMCP("MyServer", providers=[FileSystemProvider("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.local_provider import LocalProvider
+from fastmcp.tools.tool import Tool
+from fastmcp.utilities.logging import get_logger
+
+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.
+
+ Args:
+ root: Root directory to scan. Defaults to current directory.
+ reload: If True, re-scan files on every request (dev mode).
+ Defaults to False (scan once at init, cache results).
+
+ Example:
+ ```python
+ from fastmcp import FastMCP
+ from fastmcp.fs import FileSystemProvider
+
+ # Basic usage
+ mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/")])
+
+ # Dev mode - re-scan on every request
+ mcp = FastMCP("MyServer", providers=[FileSystemProvider("mcp/", reload=True)])
+ ```
+ """
+
+ def __init__(
+ self,
+ root: str | Path = ".",
+ reload: bool = False,
+ ) -> None:
+ super().__init__(on_duplicate="replace")
+ self._root = Path(root).resolve()
+ self._reload = reload
+ self._loaded = False
+ # Track files we've warned about: path -> mtime when warned
+ # Re-warn if file changes (mtime differs)
+ self._warned_files: dict[Path, float] = {}
+ # Lock for serializing reload operations (created lazily)
+ self._reload_lock: asyncio.Lock | None = None
+
+ # Always load once at init to catch errors early
+ self._load_components()
+
+ def _load_components(self) -> None:
+ """Discover and register all components from the filesystem."""
+ # Clear existing components if reloading
+ if self._loaded:
+ self._components.clear()
+ self._tool_transformations.clear()
+
+ result = discover_and_import(self._root)
+
+ # Log warnings for failed files (only once per file version)
+ for file_path, error in result.failed_files.items():
+ try:
+ current_mtime = file_path.stat().st_mtime
+ except OSError:
+ current_mtime = 0.0
+
+ # Warn if we haven't warned about this file, or if it changed
+ last_warned_mtime = self._warned_files.get(file_path)
+ if last_warned_mtime is None or last_warned_mtime != current_mtime:
+ logger.warning(f"Failed to import {file_path}: {error}")
+ self._warned_files[file_path] = current_mtime
+
+ # Clear warnings for files that now import successfully
+ 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:
+ try:
+ self._register_component(func, meta)
+ except Exception as e:
+ logger.warning(
+ f"Failed to register {func.__name__} from {file_path}: {e}"
+ )
+
+ self._loaded = True
+ logger.debug(
+ 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)
+ 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)
+
+ async def _ensure_loaded(self) -> None:
+ """Ensure components are loaded, reloading if in reload mode.
+
+ Uses a lock to serialize concurrent reload operations and runs
+ filesystem I/O off the event loop using asyncio.to_thread.
+ """
+ if not self._reload and self._loaded:
+ return
+
+ # Create lock lazily (can't create in __init__ without event loop)
+ if self._reload_lock is None:
+ self._reload_lock = asyncio.Lock()
+
+ async with self._reload_lock:
+ # Double-check after acquiring lock
+ if self._reload or not self._loaded:
+ await asyncio.to_thread(self._load_components)
+
+ # Override provider methods to support reload mode
+
+ async def list_tools(self) -> Sequence[Tool]:
+ """Return all tools, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().list_tools()
+
+ async def get_tool(self, name: str) -> Tool | None:
+ """Get a tool by name, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().get_tool(name)
+
+ async def list_resources(self) -> Sequence[Resource]:
+ """Return all resources, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().list_resources()
+
+ async def get_resource(self, uri: str) -> Resource | None:
+ """Get a resource by URI, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().get_resource(uri)
+
+ async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
+ """Return all resource templates, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().list_resource_templates()
+
+ async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
+ """Get a resource template, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().get_resource_template(uri)
+
+ async def list_prompts(self) -> Sequence[Prompt]:
+ """Return all prompts, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().list_prompts()
+
+ async def get_prompt(self, name: str) -> Prompt | None:
+ """Get a prompt by name, reloading if in reload mode."""
+ await self._ensure_loaded()
+ return await super().get_prompt(name)
+
+ def __repr__(self) -> str:
+ return f"FileSystemProvider(root={self._root!r}, reload={self._reload})"
diff --git a/tests/fs/test_decorators.py b/tests/fs/test_decorators.py
new file mode 100644
index 0000000000..f12b311e2b
--- /dev/null
+++ b/tests/fs/test_decorators.py
@@ -0,0 +1,277 @@
+"""Tests for fastmcp.fs decorators."""
+
+import pytest
+
+from fastmcp.fs.decorators import (
+ PromptMeta,
+ ResourceMeta,
+ ToolMeta,
+ get_fs_meta,
+ has_fs_meta,
+ prompt,
+ resource,
+ tool,
+)
+
+
+class TestToolDecorator:
+ """Tests for the @tool decorator."""
+
+ def test_tool_without_parens(self):
+ """@tool without parentheses should work."""
+
+ @tool
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ assert has_fs_meta(greet)
+ meta = get_fs_meta(greet)
+ assert isinstance(meta, ToolMeta)
+ assert meta.type == "tool"
+ assert meta.name is None # Will use function name
+
+ def test_tool_with_empty_parens(self):
+ """@tool() with empty parentheses should work."""
+
+ @tool()
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ assert has_fs_meta(greet)
+ meta = get_fs_meta(greet)
+ assert isinstance(meta, ToolMeta)
+
+ def test_tool_with_name_arg(self):
+ """@tool("name") with name as first arg should work."""
+
+ @tool("custom-greet")
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ meta = get_fs_meta(greet)
+ assert meta is not None
+ assert meta.name == "custom-greet"
+
+ def test_tool_with_name_kwarg(self):
+ """@tool(name="name") with keyword arg should work."""
+
+ @tool(name="custom-greet")
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ meta = get_fs_meta(greet)
+ assert meta is not None
+ assert meta.name == "custom-greet"
+
+ def test_tool_with_all_metadata(self):
+ """@tool with all metadata should store it all."""
+
+ @tool(
+ name="custom-greet",
+ title="Greeting Tool",
+ description="Greets people",
+ tags={"greeting", "demo"},
+ meta={"custom": "value"},
+ )
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ meta = get_fs_meta(greet)
+ assert meta is not None
+ assert meta.name == "custom-greet"
+ assert meta.title == "Greeting Tool"
+ assert meta.description == "Greets people"
+ assert meta.tags == {"greeting", "demo"}
+ assert meta.meta == {"custom": "value"}
+
+ def test_tool_preserves_function(self):
+ """@tool should preserve the original function."""
+
+ @tool
+ def greet(name: str) -> str:
+ """Greet someone."""
+ return f"Hello, {name}!"
+
+ # Function should still work
+ assert greet("World") == "Hello, World!"
+ assert greet.__name__ == "greet"
+ assert greet.__doc__ == "Greet someone."
+
+
+class TestResourceDecorator:
+ """Tests for the @resource decorator."""
+
+ def test_resource_requires_uri(self):
+ """@resource should require a URI argument."""
+ with pytest.raises(TypeError, match="requires a URI"):
+
+ @resource # type: ignore[arg-type]
+ def get_config() -> str:
+ return "{}"
+
+ def test_resource_with_uri(self):
+ """@resource("uri") should store the URI."""
+
+ @resource("config://app")
+ def get_config() -> dict:
+ return {"setting": "value"}
+
+ assert has_fs_meta(get_config)
+ meta = get_fs_meta(get_config)
+ assert isinstance(meta, ResourceMeta)
+ assert meta.type == "resource"
+ assert meta.uri == "config://app"
+
+ def test_resource_with_template_uri(self):
+ """@resource with template URI should work."""
+
+ @resource("users://{user_id}/profile")
+ def get_profile(user_id: str) -> dict:
+ return {"id": user_id}
+
+ meta = get_fs_meta(get_profile)
+ assert isinstance(meta, ResourceMeta)
+ assert meta.uri == "users://{user_id}/profile"
+
+ def test_resource_with_all_metadata(self):
+ """@resource with all metadata should store it all."""
+
+ @resource(
+ "config://app",
+ name="app-config",
+ title="Application Config",
+ description="Gets app configuration",
+ mime_type="application/json",
+ tags={"config"},
+ meta={"custom": "value"},
+ )
+ def get_config() -> dict:
+ return {"setting": "value"}
+
+ meta = get_fs_meta(get_config)
+ assert isinstance(meta, ResourceMeta)
+ assert meta.uri == "config://app"
+ assert meta.name == "app-config"
+ assert meta.title == "Application Config"
+ assert meta.description == "Gets app configuration"
+ assert meta.mime_type == "application/json"
+ assert meta.tags == {"config"}
+ assert meta.meta == {"custom": "value"}
+
+ def test_resource_preserves_function(self):
+ """@resource should preserve the original function."""
+
+ @resource("config://app")
+ def get_config() -> dict:
+ """Get config."""
+ return {"setting": "value"}
+
+ # Function should still work
+ assert get_config() == {"setting": "value"}
+ assert get_config.__name__ == "get_config"
+ assert get_config.__doc__ == "Get config."
+
+
+class TestPromptDecorator:
+ """Tests for the @prompt decorator."""
+
+ def test_prompt_without_parens(self):
+ """@prompt without parentheses should work."""
+
+ @prompt
+ def analyze(topic: str) -> list:
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ assert has_fs_meta(analyze)
+ meta = get_fs_meta(analyze)
+ assert isinstance(meta, PromptMeta)
+ assert meta.type == "prompt"
+ assert meta.name is None
+
+ def test_prompt_with_empty_parens(self):
+ """@prompt() with empty parentheses should work."""
+
+ @prompt()
+ def analyze(topic: str) -> list:
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ assert has_fs_meta(analyze)
+ meta = get_fs_meta(analyze)
+ assert isinstance(meta, PromptMeta)
+
+ def test_prompt_with_name_arg(self):
+ """@prompt("name") with name as first arg should work."""
+
+ @prompt("custom-analyze")
+ def analyze(topic: str) -> list:
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ meta = get_fs_meta(analyze)
+ assert meta is not None
+ assert meta.name == "custom-analyze"
+
+ def test_prompt_with_name_kwarg(self):
+ """@prompt(name="name") with keyword arg should work."""
+
+ @prompt(name="custom-analyze")
+ def analyze(topic: str) -> list:
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ meta = get_fs_meta(analyze)
+ assert meta is not None
+ assert meta.name == "custom-analyze"
+
+ def test_prompt_with_all_metadata(self):
+ """@prompt with all metadata should store it all."""
+
+ @prompt(
+ name="custom-analyze",
+ title="Analysis Prompt",
+ description="Analyzes topics",
+ tags={"analysis"},
+ meta={"custom": "value"},
+ )
+ def analyze(topic: str) -> list:
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ meta = get_fs_meta(analyze)
+ assert meta is not None
+ assert meta.name == "custom-analyze"
+ assert meta.title == "Analysis Prompt"
+ assert meta.description == "Analyzes topics"
+ assert meta.tags == {"analysis"}
+ assert meta.meta == {"custom": "value"}
+
+ def test_prompt_preserves_function(self):
+ """@prompt should preserve the original function."""
+
+ @prompt
+ def analyze(topic: str) -> list:
+ """Analyze a topic."""
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+
+ # Function should still work
+ result = analyze("Python")
+ assert result == [{"role": "user", "content": "Analyze: Python"}]
+ assert analyze.__name__ == "analyze"
+ assert analyze.__doc__ == "Analyze a topic."
+
+
+class TestHelperFunctions:
+ """Tests for helper functions."""
+
+ def test_has_fs_meta_false_for_undecorated(self):
+ """has_fs_meta should return False for undecorated functions."""
+
+ def plain_function():
+ pass
+
+ assert not has_fs_meta(plain_function)
+
+ def test_get_fs_meta_none_for_undecorated(self):
+ """get_fs_meta should return None for undecorated functions."""
+
+ def plain_function():
+ pass
+
+ assert get_fs_meta(plain_function) is None
diff --git a/tests/fs/test_discovery.py b/tests/fs/test_discovery.py
new file mode 100644
index 0000000000..575ea1404d
--- /dev/null
+++ b/tests/fs/test_discovery.py
@@ -0,0 +1,328 @@
+"""Tests for fastmcp.fs discovery module."""
+
+from pathlib import Path
+
+from fastmcp.fs.decorators import ToolMeta
+from fastmcp.fs.discovery import (
+ discover_and_import,
+ discover_files,
+ extract_components,
+ import_module_from_file,
+)
+
+
+class TestDiscoverFiles:
+ """Tests for discover_files function."""
+
+ def test_discover_files_empty_dir(self, tmp_path: Path):
+ """Should return empty list for empty directory."""
+ files = discover_files(tmp_path)
+ assert files == []
+
+ def test_discover_files_nonexistent_dir(self, tmp_path: Path):
+ """Should return empty list for nonexistent directory."""
+ nonexistent = tmp_path / "does_not_exist"
+ files = discover_files(nonexistent)
+ assert files == []
+
+ def test_discover_files_single_file(self, tmp_path: Path):
+ """Should find a single Python file."""
+ py_file = tmp_path / "test.py"
+ py_file.write_text("# test")
+
+ files = discover_files(tmp_path)
+ assert files == [py_file]
+
+ def test_discover_files_skips_init(self, tmp_path: Path):
+ """Should skip __init__.py files."""
+ init_file = tmp_path / "__init__.py"
+ init_file.write_text("# init")
+ py_file = tmp_path / "test.py"
+ py_file.write_text("# test")
+
+ files = discover_files(tmp_path)
+ assert files == [py_file]
+
+ def test_discover_files_recursive(self, tmp_path: Path):
+ """Should find files in subdirectories."""
+ subdir = tmp_path / "subdir"
+ subdir.mkdir()
+ file1 = tmp_path / "a.py"
+ file2 = subdir / "b.py"
+ file1.write_text("# a")
+ file2.write_text("# b")
+
+ files = discover_files(tmp_path)
+ assert sorted(files) == sorted([file1, file2])
+
+ def test_discover_files_skips_pycache(self, tmp_path: Path):
+ """Should skip __pycache__ directories."""
+ pycache = tmp_path / "__pycache__"
+ pycache.mkdir()
+ cache_file = pycache / "test.py"
+ cache_file.write_text("# cache")
+ py_file = tmp_path / "test.py"
+ py_file.write_text("# test")
+
+ files = discover_files(tmp_path)
+ assert files == [py_file]
+
+ def test_discover_files_sorted(self, tmp_path: Path):
+ """Files should be returned in sorted order."""
+ (tmp_path / "z.py").write_text("# z")
+ (tmp_path / "a.py").write_text("# a")
+ (tmp_path / "m.py").write_text("# m")
+
+ files = discover_files(tmp_path)
+ names = [f.name for f in files]
+ assert names == ["a.py", "m.py", "z.py"]
+
+
+class TestImportModuleFromFile:
+ """Tests for import_module_from_file function."""
+
+ def test_import_simple_module(self, tmp_path: Path):
+ """Should import a simple module."""
+ py_file = tmp_path / "simple.py"
+ py_file.write_text("VALUE = 42")
+
+ module = import_module_from_file(py_file)
+ assert module.VALUE == 42
+
+ def test_import_module_with_function(self, tmp_path: Path):
+ """Should import a module with functions."""
+ py_file = tmp_path / "funcs.py"
+ py_file.write_text(
+ """\
+def greet(name):
+ return f"Hello, {name}!"
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ assert module.greet("World") == "Hello, World!"
+
+ def test_import_module_with_imports(self, tmp_path: Path):
+ """Should handle modules with standard library imports."""
+ py_file = tmp_path / "with_imports.py"
+ py_file.write_text(
+ """\
+import os
+import sys
+
+def get_cwd():
+ return os.getcwd()
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ assert callable(module.get_cwd)
+
+ def test_import_as_package_with_init(self, tmp_path: Path):
+ """Should import as package when __init__.py exists."""
+ # Create package structure (use unique name to avoid module caching)
+ pkg = tmp_path / "testpkg_init"
+ pkg.mkdir()
+ (pkg / "__init__.py").write_text("PKG_VAR = 'package'")
+ module_file = pkg / "module.py"
+ module_file.write_text("MODULE_VAR = 'module'")
+
+ module = import_module_from_file(module_file)
+ assert module.MODULE_VAR == "module"
+
+ def test_import_with_relative_import(self, tmp_path: Path):
+ """Should support relative imports when in a package."""
+ # Create package with relative import (use unique name to avoid module caching)
+ pkg = tmp_path / "testpkg_relative"
+ pkg.mkdir()
+ (pkg / "__init__.py").write_text("")
+ (pkg / "helper.py").write_text("HELPER_VALUE = 123")
+ (pkg / "main.py").write_text(
+ """\
+from .helper import HELPER_VALUE
+
+MAIN_VALUE = HELPER_VALUE * 2
+"""
+ )
+
+ module = import_module_from_file(pkg / "main.py")
+ assert module.MAIN_VALUE == 246
+
+ def test_import_package_module_reload(self, tmp_path: Path):
+ """Re-importing a package module should return updated content."""
+ # Create package (use unique name to avoid conflicts)
+ pkg = tmp_path / "testpkg_reload"
+ pkg.mkdir()
+ (pkg / "__init__.py").write_text("")
+ module_file = pkg / "reloadable.py"
+ module_file.write_text("VALUE = 'original'")
+
+ # First import
+ module = import_module_from_file(module_file)
+ assert module.VALUE == "original"
+
+ # Modify the file
+ module_file.write_text("VALUE = 'updated'")
+
+ # Re-import should see the updated value
+ module = import_module_from_file(module_file)
+ assert module.VALUE == "updated"
+
+
+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."""
+ py_file = tmp_path / "plain.py"
+ py_file.write_text(
+ """\
+def plain_function():
+ pass
+
+SOME_VAR = 42
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ components = extract_components(module)
+ assert components == []
+
+ def test_extract_tool_component(self, tmp_path: Path):
+ """Should extract @tool decorated functions."""
+ py_file = tmp_path / "tools.py"
+ py_file.write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ return f"Hello, {name}!"
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ components = extract_components(module)
+
+ assert len(components) == 1
+ func, meta = components[0]
+ assert func.__name__ == "greet"
+ assert isinstance(meta, ToolMeta)
+
+ def test_extract_multiple_components(self, tmp_path: Path):
+ """Should extract multiple decorated functions."""
+ py_file = tmp_path / "multi.py"
+ py_file.write_text(
+ """\
+from fastmcp.fs import tool, resource, prompt
+
+@tool
+def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+@resource("config://app")
+def get_config() -> dict:
+ return {}
+
+@prompt
+def analyze(topic: str) -> list:
+ return []
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ components = extract_components(module)
+
+ assert len(components) == 3
+ names = {func.__name__ for func, _ in components}
+ assert names == {"greet", "get_config", "analyze"}
+
+ def test_extract_skips_private_functions(self, tmp_path: Path):
+ """Should skip private functions even if decorated."""
+ py_file = tmp_path / "private.py"
+ py_file.write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def public_tool() -> str:
+ return "public"
+
+@tool
+def _private_tool() -> str:
+ return "private"
+"""
+ )
+
+ module = import_module_from_file(py_file)
+ components = extract_components(module)
+
+ # Only public tool should be found (private starts with _)
+ assert len(components) == 1
+ func, _ = components[0]
+ assert func.__name__ == "public_tool"
+
+
+class TestDiscoverAndImport:
+ """Tests for discover_and_import function."""
+
+ def test_discover_and_import_empty(self, tmp_path: Path):
+ """Should return empty result for empty directory."""
+ result = discover_and_import(tmp_path)
+ assert result.components == []
+ assert result.failed_files == {}
+
+ def test_discover_and_import_with_tools(self, tmp_path: Path):
+ """Should discover and import tools."""
+ tools_dir = tmp_path / "tools"
+ tools_dir.mkdir()
+ (tools_dir / "greet.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ return f"Hello, {name}!"
+"""
+ )
+
+ result = discover_and_import(tmp_path)
+
+ assert len(result.components) == 1
+ file_path, func, meta = result.components[0]
+ assert file_path.name == "greet.py"
+ assert func.__name__ == "greet"
+ assert isinstance(meta, ToolMeta)
+
+ 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
+
+@tool
+def good_tool() -> str:
+ return "good"
+"""
+ )
+ (tmp_path / "bad.py").write_text(
+ """\
+import nonexistent_module_xyz123
+
+def bad_function():
+ pass
+"""
+ )
+
+ result = discover_and_import(tmp_path)
+
+ # Only good.py should be imported
+ assert len(result.components) == 1
+ _, func, _ = result.components[0]
+ assert func.__name__ == "good_tool"
+
+ # bad.py should be in failed_files
+ assert len(result.failed_files) == 1
+ failed_path = tmp_path / "bad.py"
+ assert failed_path in result.failed_files
+ assert "nonexistent_module_xyz123" in result.failed_files[failed_path]
diff --git a/tests/fs/test_provider.py b/tests/fs/test_provider.py
new file mode 100644
index 0000000000..4e1281fb0d
--- /dev/null
+++ b/tests/fs/test_provider.py
@@ -0,0 +1,420 @@
+"""Tests for fastmcp.fs FileSystemProvider."""
+
+import time
+from pathlib import Path
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.fs import FileSystemProvider
+
+
+class TestFileSystemProvider:
+ """Tests for FileSystemProvider."""
+
+ def test_provider_empty_directory(self, tmp_path: Path):
+ """Provider should work with empty directory."""
+ provider = FileSystemProvider(tmp_path)
+ assert repr(provider).startswith("FileSystemProvider")
+
+ def test_provider_discovers_tools(self, tmp_path: Path):
+ """Provider should discover @tool decorated functions."""
+ tools_dir = tmp_path / "tools"
+ tools_dir.mkdir()
+ (tools_dir / "greet.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ '''Greet someone by name.'''
+ return f"Hello, {name}!"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+
+ # Check tool was registered
+ assert len(provider._components) == 1
+
+ def test_provider_discovers_resources(self, tmp_path: Path):
+ """Provider should discover @resource decorated functions."""
+ (tmp_path / "config.py").write_text(
+ """\
+from fastmcp.fs import resource
+
+@resource("config://app")
+def get_config() -> dict:
+ '''Get app config.'''
+ return {"setting": "value"}
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ assert len(provider._components) == 1
+
+ def test_provider_discovers_resource_templates(self, tmp_path: Path):
+ """Provider should discover resource templates."""
+ (tmp_path / "users.py").write_text(
+ """\
+from fastmcp.fs import resource
+
+@resource("users://{user_id}/profile")
+def get_profile(user_id: str) -> dict:
+ '''Get user profile.'''
+ return {"id": user_id}
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ assert len(provider._components) == 1
+
+ def test_provider_discovers_prompts(self, tmp_path: Path):
+ """Provider should discover @prompt decorated functions."""
+ (tmp_path / "analyze.py").write_text(
+ """\
+from fastmcp.fs import prompt
+
+@prompt
+def analyze(topic: str) -> list:
+ '''Analyze a topic.'''
+ return [{"role": "user", "content": f"Analyze: {topic}"}]
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ assert len(provider._components) == 1
+
+ def test_provider_discovers_multiple_in_one_file(self, tmp_path: Path):
+ """Provider should discover multiple components in one file."""
+ (tmp_path / "multi.py").write_text(
+ """\
+from fastmcp.fs import tool, resource, prompt
+
+@tool
+def tool1() -> str:
+ return "tool1"
+
+@tool
+def tool2() -> str:
+ return "tool2"
+
+@resource("config://app")
+def get_config() -> dict:
+ return {}
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ assert len(provider._components) == 3
+
+ def test_provider_skips_undecorated_files(self, tmp_path: Path):
+ """Provider should skip files with no decorated functions."""
+ (tmp_path / "utils.py").write_text(
+ """\
+def helper_function():
+ return "helper"
+
+SOME_CONSTANT = 42
+"""
+ )
+ (tmp_path / "tool.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def my_tool() -> str:
+ return "tool"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ # Only the tool should be registered
+ assert len(provider._components) == 1
+
+
+class TestFileSystemProviderReloadMode:
+ """Tests for FileSystemProvider reload mode."""
+
+ def test_reload_false_caches_at_init(self, tmp_path: Path):
+ """With reload=False, components are cached at init."""
+ (tmp_path / "tool.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def original() -> str:
+ return "original"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path, reload=False)
+ assert len(provider._components) == 1
+
+ # Add another file - should NOT be picked up
+ (tmp_path / "tool2.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def added() -> str:
+ return "added"
+"""
+ )
+
+ # Still only one component
+ assert len(provider._components) == 1
+
+ async def test_reload_true_rescans(self, tmp_path: Path):
+ """With reload=True, components are rescanned on each request."""
+ (tmp_path / "tool.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def original() -> str:
+ return "original"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path, reload=True)
+
+ # Always loaded once at init (to catch errors early)
+ assert provider._loaded
+ assert len(provider._components) == 1
+
+ # Add another file - should be picked up on next _ensure_loaded
+ (tmp_path / "tool2.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def added() -> str:
+ return "added"
+"""
+ )
+
+ # With reload=True, _ensure_loaded re-scans
+ await provider._ensure_loaded()
+ assert len(provider._components) == 2
+
+ async def test_warning_deduplication_same_file(self, tmp_path: Path, capsys):
+ """Warnings for the same broken file should not repeat."""
+ bad_file = tmp_path / "bad.py"
+ bad_file.write_text("1/0 # division by zero")
+
+ provider = FileSystemProvider(tmp_path, reload=True)
+
+ # First load - should warn
+ captured = capsys.readouterr()
+ # Check for warning indicator (rich may truncate long paths)
+ assert "WARNING" in captured.err and "Failed to import" in captured.err
+
+ # Second load (same file, unchanged) - should NOT warn again
+ await provider._ensure_loaded()
+ captured = capsys.readouterr()
+ assert "Failed to import" not in captured.err
+
+ async def test_warning_on_file_change(self, tmp_path: Path, capsys):
+ """Warnings should reappear when a broken file changes."""
+ bad_file = tmp_path / "bad.py"
+ bad_file.write_text("1/0 # division by zero")
+
+ provider = FileSystemProvider(tmp_path, reload=True)
+
+ # First load - should warn
+ captured = capsys.readouterr()
+ # Check for warning indicator (rich may truncate long paths)
+ assert "WARNING" in captured.err and "Failed to import" in captured.err
+
+ # Modify the file (different error) - need to ensure mtime changes
+ time.sleep(0.01) # Ensure mtime differs
+ bad_file.write_text("syntax error here !!!")
+
+ # Next load - should warn again (file changed)
+ await provider._ensure_loaded()
+ captured = capsys.readouterr()
+ # Check for warning indicator (rich may truncate long paths)
+ assert "WARNING" in captured.err and "Failed to import" in captured.err
+
+ async def test_warning_cleared_when_fixed(self, tmp_path: Path, capsys):
+ """Warnings should clear when a file is fixed, and reappear if broken again."""
+ bad_file = tmp_path / "tool.py"
+ bad_file.write_text("1/0 # broken")
+
+ provider = FileSystemProvider(tmp_path, reload=True)
+
+ # First load - should warn
+ captured = capsys.readouterr()
+ # Check for warning indicator (rich may truncate long paths)
+ assert "WARNING" in captured.err and "Failed to import" in captured.err
+
+ # Fix the file
+ time.sleep(0.01)
+ bad_file.write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def my_tool() -> str:
+ return "fixed"
+"""
+ )
+
+ # Load again - should NOT warn, file is fixed
+ await provider._ensure_loaded()
+ captured = capsys.readouterr()
+ assert "Failed to import" not in captured.err
+ assert len(provider._components) == 1
+
+ # Break it again
+ time.sleep(0.01)
+ bad_file.write_text("1/0 # broken again")
+
+ # Should warn again
+ await provider._ensure_loaded()
+ captured = capsys.readouterr()
+ # Check for warning indicator (rich may truncate long paths)
+ assert "WARNING" in captured.err and "Failed to import" in captured.err
+
+
+class TestFileSystemProviderIntegration:
+ """Integration tests with FastMCP server."""
+
+ async def test_provider_with_fastmcp_server(self, tmp_path: Path):
+ """FileSystemProvider should work with FastMCP server."""
+ (tmp_path / "greet.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ '''Greet someone.'''
+ return f"Hello, {name}!"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ mcp = FastMCP("TestServer", providers=[provider])
+
+ async with Client(mcp) as client:
+ # List tools
+ tools = await client.list_tools()
+ assert len(tools) == 1
+ assert tools[0].name == "greet"
+
+ # Call tool
+ result = await client.call_tool("greet", {"name": "World"})
+ assert "Hello, World!" in str(result)
+
+ async def test_provider_with_resources(self, tmp_path: Path):
+ """FileSystemProvider should work with resources."""
+ (tmp_path / "config.py").write_text(
+ """\
+from fastmcp.fs import resource
+
+@resource("config://app")
+def get_config() -> str:
+ '''Get app config.'''
+ return '{"version": "1.0"}'
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ mcp = FastMCP("TestServer", providers=[provider])
+
+ async with Client(mcp) as client:
+ # List resources
+ resources = await client.list_resources()
+ assert len(resources) == 1
+ assert str(resources[0].uri) == "config://app"
+
+ # Read resource
+ result = await client.read_resource("config://app")
+ assert "1.0" in str(result)
+
+ async def test_provider_with_resource_templates(self, tmp_path: Path):
+ """FileSystemProvider should work with resource templates."""
+ (tmp_path / "users.py").write_text(
+ """\
+from fastmcp.fs import resource
+
+@resource("users://{user_id}/profile")
+def get_profile(user_id: str) -> str:
+ '''Get user profile.'''
+ return f'{{"id": "{user_id}", "name": "User {user_id}"}}'
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ mcp = FastMCP("TestServer", providers=[provider])
+
+ async with Client(mcp) as client:
+ # List templates
+ templates = await client.list_resource_templates()
+ assert len(templates) == 1
+
+ # Read with parameter
+ result = await client.read_resource("users://123/profile")
+ assert "123" in str(result)
+
+ async def test_provider_with_prompts(self, tmp_path: Path):
+ """FileSystemProvider should work with prompts."""
+ (tmp_path / "analyze.py").write_text(
+ """\
+from fastmcp.fs import prompt
+
+@prompt
+def analyze(topic: str) -> str:
+ '''Analyze a topic.'''
+ return f"Please analyze: {topic}"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ mcp = FastMCP("TestServer", providers=[provider])
+
+ async with Client(mcp) as client:
+ # List prompts
+ prompts = await client.list_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].name == "analyze"
+
+ # Get prompt
+ result = await client.get_prompt("analyze", {"topic": "Python"})
+ assert "Python" in str(result)
+
+ async def test_nested_directory_structure(self, tmp_path: Path):
+ """FileSystemProvider should work with nested directories."""
+ # Create nested structure
+ tools = tmp_path / "tools"
+ tools.mkdir()
+ (tools / "greet.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def greet(name: str) -> str:
+ return f"Hello, {name}!"
+"""
+ )
+
+ payments = tools / "payments"
+ payments.mkdir()
+ (payments / "charge.py").write_text(
+ """\
+from fastmcp.fs import tool
+
+@tool
+def charge(amount: float) -> str:
+ return f"Charged ${amount}"
+"""
+ )
+
+ provider = FileSystemProvider(tmp_path)
+ mcp = FastMCP("TestServer", providers=[provider])
+
+ async with Client(mcp) as client:
+ tools_list = await client.list_tools()
+ assert len(tools_list) == 2
+ names = {t.name for t in tools_list}
+ assert names == {"greet", "charge"}