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"}