From 9c5c4f51137eb360e1d998b652e28b2b98bfea15 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 24 Aug 2025 20:50:25 -0400 Subject: [PATCH] Consolidate server loading logic into FileSystemSource (#1614) --- docs/deployment/server-configuration.mdx | 41 ++- src/fastmcp/cli/cli.py | 223 +++++++++-------- src/fastmcp/cli/install/shared.py | 61 +++-- src/fastmcp/cli/run.py | 235 +++--------------- .../utilities/fastmcp_config/__init__.py | 4 +- .../utilities/fastmcp_config/v1/__init__.py | 0 .../fastmcp_config/v1/fastmcp_config.py | 136 +++------- .../fastmcp_config/v1/sources/__init__.py | 0 .../fastmcp_config/v1/sources/base.py | 30 +++ .../fastmcp_config/v1/sources/filesystem.py | 215 ++++++++++++++++ test.fastmcp.json | 17 -- tests/cli/test_cli.py | 58 ++++- tests/cli/test_config.py | 2 +- tests/cli/test_cursor.py | 6 +- tests/cli/test_run.py | 197 ++++++++++++--- tests/cli/test_run_config.py | 2 +- tests/cli/test_run_with_uv.py | 54 +++- tests/cli/test_server_args.py | 139 +++++++++++ tests/cli/test_with_argv.py | 91 +++++++ tests/utilities/test_cli.py | 120 ++++++++- 20 files changed, 1114 insertions(+), 517 deletions(-) create mode 100644 src/fastmcp/utilities/fastmcp_config/v1/__init__.py create mode 100644 src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py create mode 100644 src/fastmcp/utilities/fastmcp_config/v1/sources/base.py create mode 100644 src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py delete mode 100644 test.fastmcp.json create mode 100644 tests/cli/test_server_args.py create mode 100644 tests/cli/test_with_argv.py diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx index 82aaa840e..eb30fd2ba 100644 --- a/docs/deployment/server-configuration.mdx +++ b/docs/deployment/server-configuration.mdx @@ -304,23 +304,48 @@ fastmcp run prod.fastmcp.json # Skip environment setup when already in a uv environment fastmcp run fastmcp.json --skip-env + +# Skip source preparation when source is already prepared +fastmcp run fastmcp.json --skip-source + +# Skip both environment and source preparation +fastmcp run fastmcp.json --skip-env --skip-source ``` -### Environment Setup Control +### Using an Existing Environment -By default, FastMCP uses `uv` to create an isolated environment based on your configuration. The `--skip-env` flag allows you to skip this automatic environment setup: +By default, FastMCP creates an isolated environment with `uv` based on your configuration. When you already have a suitable Python environment, use the `--skip-env` flag to skip environment creation: ```bash fastmcp run fastmcp.json --skip-env ``` -**When to use `--skip-env`:** -- You're already in an activated virtual environment with all dependencies installed -- You're inside a Docker container with pre-installed dependencies -- You're in a uv-managed environment and want to prevent infinite recursion -- You want to test the server without environment setup for debugging purposes +**When you already have an environment:** +- You're in an activated virtual environment with all dependencies installed +- You're inside a Docker container with pre-installed dependencies +- You're in a CI/CD pipeline that pre-builds the environment +- You're using a system-wide installation with all required packages +- You're in a uv-managed environment (prevents infinite recursion) -This flag is particularly useful in CI/CD pipelines, Docker containers, or when you're managing the Python environment yourself. +This flag tells FastMCP: "I already have everything installed, just run the server." + +### Using an Existing Source + +When working with source types that require preparation (future support for git repositories or cloud sources), use the `--skip-source` flag when you already have the source code available: + +```bash +fastmcp run fastmcp.json --skip-source +``` + +**When you already have the source:** +- You've previously cloned a git repository and don't need to re-fetch +- You have a cached copy of a cloud-hosted server +- You're in a CI/CD pipeline where source checkout is a separate step +- You're iterating locally on already-downloaded code + +This flag tells FastMCP: "I already have the source code, skip any download/clone steps." + +Note: For filesystem sources (local Python files), this flag has no effect since they don't require preparation. The configuration file works with all FastMCP commands: - **`run`** - Start the server in production mode diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 05c111618..0ad3016ad 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -7,6 +7,7 @@ import os import platform import subprocess import sys +from contextlib import contextmanager from pathlib import Path from typing import Annotated, Literal @@ -59,6 +60,29 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: return key.strip(), value.strip() +@contextmanager +def with_argv(args: list[str] | None): + """Temporarily replace sys.argv if args provided. + + This context manager is used at the CLI boundary to inject + server arguments when needed, without mutating sys.argv deep + in the source loading logic. + + Args are provided without the script name, so we preserve sys.argv[0] + and replace the rest. + """ + if args is not None: + original = sys.argv[:] + try: + # Preserve the script name (sys.argv[0]) and replace the rest + sys.argv = [sys.argv[0]] + args + yield + finally: + sys.argv = original + else: + yield + + @app.command def version( *, @@ -164,12 +188,16 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ + from pathlib import Path + + from fastmcp.utilities.fastmcp_config import FastMCPConfig + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + + config = None + config_path = None + # Auto-detect fastmcp.json if no server_spec provided if server_spec is None: - from pathlib import Path - - from fastmcp.utilities.fastmcp_config import FastMCPConfig - config_path = Path("fastmcp.json") if not config_path.exists(): # Check if fastmcp.json exists in current directory @@ -182,16 +210,13 @@ async def dev( "Please specify a server file or create a fastmcp.json configuration." ) sys.exit(1) + server_spec = str(config_path) + logger.info(f"Using configuration from {config_path}") - # Load the config to get settings - config = FastMCPConfig.from_file(config_path) - entrypoint = config.get_entrypoint(config_path) - - # Convert entrypoint to string format for dev command - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Create FastMCPConfig from server_spec + if server_spec.endswith(".json"): + # Load existing config + config = FastMCPConfig.from_file(Path(server_spec)) # Merge environment settings with CLI args (CLI takes precedence) if config.environment: @@ -220,15 +245,15 @@ async def dev( # Get server port from deployment config if not specified if config.deployment and config.deployment.port: server_port = server_port or config.deployment.port - - logger.info(f"Using configuration from {config_path}") - file, server_object = run_module.parse_file_path(server_spec) + else: + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) logger.debug( "Starting dev server", extra={ - "file": str(file), - "server_object": server_object, + "server_spec": server_spec, "with_editable": str(with_editable) if with_editable else None, "with_packages": with_packages, "ui_port": ui_port, @@ -237,9 +262,8 @@ async def dev( ) try: - # Import server to get dependencies - # TODO: Remove dependencies handling (deprecated in v2.11.4) - server: FastMCP = await run_module.import_server(file, server_object) + # Load server to check for deprecated dependencies + server: FastMCP = await config.source.load_server() if server.dependencies: import warnings @@ -299,7 +323,7 @@ async def dev( logger.error( "Dev server failed", extra={ - "file": str(file), + "file": str(server_spec), "error": str(e), "returncode": e.returncode, }, @@ -310,7 +334,7 @@ async def dev( "npx not found. Please ensure Node.js and npm are properly installed " "and added to your system PATH. You may need to restart your terminal " "after installation.", - extra={"file": str(file)}, + extra={"file": str(server_spec)}, ) sys.exit(1) @@ -399,6 +423,14 @@ async def run( negative="", ), ] = False, + skip_source: Annotated[ + bool, + cyclopts.Parameter( + "--skip-source", + help="Skip source preparation step (use when source is already prepared)", + negative="", + ), + ] = False, ) -> None: """Run an MCP server or connect to a remote one. @@ -566,6 +598,7 @@ async def run( log_level=log_level, server_args=list(server_args), show_banner=not no_banner, + skip_source=skip_source, ) except Exception as e: logger.error( @@ -636,10 +669,10 @@ async def inspect( Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ - # Load configuration if needed from pathlib import Path from fastmcp.utilities.fastmcp_config import FastMCPConfig + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource config = None config_path = None @@ -662,64 +695,53 @@ async def inspect( server_spec = str(config_path) logger.info(f"Using configuration from {config_path}") - # Load config if server_spec is a .json file + # Create FastMCPConfig from server_spec if server_spec.endswith(".json"): config_path = Path(server_spec) if config_path.exists(): - # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig try: with open(config_path) as f: data = json.load(f) - # Check which type of config it is based on required fields - try: - if "source" in data: - # It's a FastMCPConfig - validate and use it - adapter = get_cached_typeadapter(FastMCPConfig) - config = adapter.validate_python(data) - # Get the actual entrypoint with resolved paths - entrypoint = config.get_entrypoint(config_path) + # Check if it's an MCPConfig (has mcpServers key) + if "mcpServers" in data: + # MCPConfig - we don't process these in inspect + logger.error("MCPConfig files are not supported by inspect command") + sys.exit(1) + else: + # It's a FastMCPConfig + config = FastMCPConfig.from_file(config_path) - if entrypoint.object: - server_spec = f"{entrypoint.file}:{entrypoint.object}" - else: - server_spec = entrypoint.file + # Merge environment settings from config with CLI (CLI takes precedence) + if config.environment: + python = python or config.environment.python + project = project or ( + Path(config.environment.project) + if config.environment.project + else None + ) + with_requirements = with_requirements or ( + Path(config.environment.requirements) + if config.environment.requirements + else None + ) - # Merge environment settings from config with CLI (CLI takes precedence) - if config.environment: - python = python or config.environment.python - project = project or ( - Path(config.environment.project) - if config.environment.project - else None - ) - with_requirements = with_requirements or ( - Path(config.environment.requirements) - if config.environment.requirements - else None - ) - - # Merge packages from both sources - if config.environment.dependencies: - packages = list(config.environment.dependencies) - if with_packages: - packages.extend(with_packages) - with_packages = packages - elif "mcpServers" in data: - # It's an MCPConfig, we don't process these in the run command - # They should be handled through different code paths - config = None - else: - # Not a recognized config format, treat as regular server spec - config = None - except ValidationError: - # Not a valid config, treat as regular server spec - config = None - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, treat as regular server spec - config = None + # Merge packages from both sources + if config.environment.dependencies: + packages = list(config.environment.dependencies) + if with_packages: + packages.extend(with_packages) + with_packages = packages + except (json.JSONDecodeError, ValidationError) as e: + logger.error(f"Invalid configuration file: {e}") + sys.exit(1) else: - config = None + logger.error(f"Configuration file not found: {config_path}") + sys.exit(1) + else: + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) # Check if we need to use uv run needs_uv = python or with_packages or with_requirements or project @@ -728,54 +750,37 @@ async def inspect( if needs_uv: # Build and run uv command - if config and config.environment: - # Use environment config's run_with_uv method - inspect_command = [ - "fastmcp", - "inspect", - server_spec, - "--output", - str(output), - ] - config.environment.run_with_uv(inspect_command) - else: - # Build an Environment from CLI args for consistency - from fastmcp.utilities.fastmcp_config import ( - Environment, - ) + from fastmcp.utilities.fastmcp_config import Environment - env_config = Environment( - python=python, - dependencies=with_packages, - requirements=str(with_requirements) if with_requirements else None, - project=str(project) if project else None, - ) + # Create or update environment config + env_config = Environment( + python=python, + dependencies=with_packages if with_packages else None, + requirements=str(with_requirements) if with_requirements else None, + project=str(project) if project else None, + ) - inspect_command = [ - "fastmcp", - "inspect", - server_spec, - "--output", - str(output), - ] - env_config.run_with_uv(inspect_command) - - # Direct import path (no uv needed) - # Parse the server specification - file, server_object = run_module.parse_file_path(server_spec) + inspect_command = [ + "fastmcp", + "inspect", + server_spec, + "--output", + str(output), + ] + env_config.run_with_uv(inspect_command) + return # run_with_uv exits the process logger.debug( "Inspecting server", extra={ - "file": str(file), - "server_object": server_object, + "server_spec": server_spec, "output": str(output), }, ) try: - # Import the server - server = await run_module.import_server(file, server_object) + # Load the server using the config + server = await config.source.load_server() # Get server information - using native async support info = await inspect_fastmcp(server) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 82843c8d9..2f92fbd89 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -8,9 +8,9 @@ from dotenv import dotenv_values from pydantic import ValidationError from rich import print -from fastmcp.cli.run import import_server, parse_file_path +from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger(__name__) @@ -37,49 +37,46 @@ async def process_common_args( Handles both fastmcp.json config files and traditional file.py:object syntax. """ - # Check if server_spec is a .json file + # Create FastMCPConfig from server_spec + config = None if server_spec.endswith(".json"): config_path = Path(server_spec).resolve() if not config_path.exists(): print(f"[red]Configuration file not found: {config_path}[/red]") sys.exit(1) - # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig try: with open(config_path) as f: data = json.load(f) - # Check if it's an MCPConfig first (has canonical mcpServers key) - from fastmcp.utilities.fastmcp_config import FastMCPConfig - + # Check if it's an MCPConfig (has mcpServers key) if "mcpServers" in data: - # It's an MCPConfig, treat as regular server spec - file, server_object = parse_file_path(server_spec) + # MCPConfig files aren't supported for install + print("[red]MCPConfig files are not supported for installation[/red]") + sys.exit(1) else: - # Try to parse as FastMCPConfig - try: - adapter = get_cached_typeadapter(FastMCPConfig) - config = adapter.validate_python(data) - entrypoint = config.get_entrypoint(config_path) + # It's a FastMCPConfig + config = FastMCPConfig.from_file(config_path) - # Convert to file and server_object - file = Path(entrypoint.file) - server_object = entrypoint.object - - # Merge packages from config if not overridden - if config.environment and config.environment.dependencies: - # Merge with CLI packages (CLI takes precedence) - config_packages = config.environment.dependencies or [] - with_packages = list(set(with_packages + config_packages)) - except ValidationError: - # Not a valid FastMCPConfig, treat as regular server spec - file, server_object = parse_file_path(server_spec) - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, treat as regular server spec - file, server_object = parse_file_path(server_spec) + # Merge packages from config if not overridden + if config.environment and config.environment.dependencies: + # Merge with CLI packages (CLI takes precedence) + config_packages = list(config.environment.dependencies) or [] + with_packages = list(set(with_packages + config_packages)) + except (json.JSONDecodeError, ValidationError) as e: + print(f"[red]Invalid configuration file: {e}[/red]") + sys.exit(1) else: - # Parse traditional server spec - file, server_object = parse_file_path(server_spec) + # Create config from file path + source = FileSystemSource(path=server_spec) + config = FastMCPConfig(source=source) + + # Extract file and server_object from the source + # The FileSystemSource handles parsing path:object syntax + file = Path(config.source.path).resolve() + server_object = ( + config.source.entrypoint if hasattr(config.source, "entrypoint") else None + ) logger.debug( "Installing server", @@ -96,7 +93,7 @@ async def process_common_args( server = None if not name: try: - server = await import_server(file, server_object) + server = await config.source.load_server() name = server.name except (ImportError, ModuleNotFoundError) as e: logger.debug( diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index eaf89ea11..d403d06a9 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -1,12 +1,9 @@ """FastMCP run command implementation with enhanced type hints.""" -import importlib.util -import inspect import json import re import subprocess import sys -from functools import partial from pathlib import Path from typing import Any, Literal @@ -17,8 +14,8 @@ from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import ( Environment, FastMCPConfig, - FileSystemSource, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter @@ -35,150 +32,6 @@ def is_url(path: str) -> bool: return bool(url_pattern.match(path)) -def parse_file_path(server_spec: str) -> tuple[Path, str | None]: - """Parse a file path that may include a server object specification. - - Args: - server_spec: Path to file, optionally with :object suffix - - Returns: - Tuple of (file_path, server_object) - """ - # First check if we have a Windows path (e.g., C:\...) - has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":" - - # Split on the last colon, but only if it's not part of the Windows drive letter - # and there's actually another colon in the string after the drive letter - if ":" in (server_spec[2:] if has_windows_drive else server_spec): - file_str, server_object = server_spec.rsplit(":", 1) - else: - file_str, server_object = server_spec, None - - # Resolve the file path - file_path = Path(file_str).expanduser().resolve() - if not file_path.exists(): - logger.error(f"File not found: {file_path}") - sys.exit(1) - if not file_path.is_file(): - logger.error(f"Not a file: {file_path}") - sys.exit(1) - - return file_path, server_object - - -async def import_server(file: Path, server_or_factory: str | None = None) -> Any: - """Import a MCP server from a file. - - Args: - file: Path to the file - server_or_factory: Optional object name in format "module:object" or just "object" - - Returns: - The server object (or result of calling a factory function) - """ - # Add parent directory to Python path so imports can be resolved - file_dir = str(file.parent) - if file_dir not in sys.path: - sys.path.insert(0, file_dir) - - # Import the module - spec = importlib.util.spec_from_file_location("server_module", file) - if not spec or not spec.loader: - logger.error("Could not load module", extra={"file": str(file)}) - sys.exit(1) - - module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] - spec.loader.exec_module(module) # type: ignore[union-attr] - - # If no object specified, try common server names - if not server_or_factory: - # Look for common server instance names - for name in ["mcp", "server", "app"]: - if hasattr(module, name): - obj = getattr(module, name) - if isinstance(obj, FastMCP | FastMCP1x): - return await _resolve_server_or_factory(obj, file, name) - - logger.error( - f"No server object found in {file}. Please either:\n" - "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the entrypoint name in fastmcp.json or use `file.py:object` syntax as your path.", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Handle module:object syntax - if server_or_factory and ":" in server_or_factory: - module_name, object_name = server_or_factory.split(":", 1) - try: - server_module = importlib.import_module(module_name) - obj = getattr(server_module, object_name, None) - except ImportError: - logger.error( - f"Could not import module '{module_name}'", - extra={"file": str(file)}, - ) - sys.exit(1) - else: - # Just object name - obj = getattr(module, server_or_factory, None) - - if obj is None: - logger.error( - f"Server object '{server_or_factory}' not found", - extra={"file": str(file)}, - ) - sys.exit(1) - - return await _resolve_server_or_factory(obj, file, server_or_factory) # type: ignore[arg-type] - - -async def _resolve_server_or_factory(obj: Any, file: Path, name: str) -> Any: - """Resolve a server object or factory function to a server instance. - - Args: - obj: The object that might be a server or factory function - file: Path to the file for error messages - name: Name of the object for error messages - - Returns: - A server instance - """ - # Check if it's a function or coroutine function - if inspect.isfunction(obj) or inspect.iscoroutinefunction(obj): - logger.debug(f"Found factory function '{name}' in {file}") - - try: - if inspect.iscoroutinefunction(obj): - # Async factory function - server = await obj() - else: - # Sync factory function - server = obj() - - # Validate the result is a FastMCP server - if not isinstance(server, FastMCP | FastMCP1x): - logger.error( - f"Factory function '{name}' must return a FastMCP server instance, " - f"got {type(server).__name__}", - extra={"file": str(file)}, - ) - sys.exit(1) - - logger.debug(f"Factory function '{name}' created server: {server.name}") - return server - - except Exception as e: - logger.error( - f"Failed to call factory function '{name}': {e}", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Not a function, return as-is (should be a server instance) - return obj - - def run_with_uv( server_spec: str, python_version: str | None = None, @@ -359,32 +212,6 @@ def load_fastmcp_config(config_path: Path) -> FastMCPConfig: return config -async def import_server_with_args( - file: Path, - server_or_factory: str | None = None, - server_args: list[str] | None = None, -) -> Any: - """Import a server with optional command line arguments. - - Args: - file: Path to the server file - server_or_factory: Optional server object or factory function name - server_args: Optional command line arguments to inject - - Returns: - The imported server object - """ - if server_args: - original_argv = sys.argv[:] - try: - sys.argv = [str(file)] + server_args - return await import_server(file, server_or_factory) - finally: - sys.argv = original_argv - else: - return await import_server(file, server_or_factory) - - async def run_command( server_spec: str, transport: TransportType | None = None, @@ -395,6 +222,7 @@ async def run_command( server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, + skip_source: bool = False, ) -> None: """Run a MCP server or connect to a remote one. @@ -408,11 +236,14 @@ async def run_command( server_args: Additional arguments to pass to the server show_banner: Whether to show the server banner use_direct_import: Whether to use direct import instead of subprocess + skip_source: Whether to skip source preparation step """ + # Special case: URLs if is_url(server_spec): # Handle URL case server = create_client_server(server_spec) logger.debug(f"Created client proxy server for {server_spec}") + # Special case: MCPConfig files (legacy) elif server_spec.endswith(".json"): # Load JSON and check which type of config it is config_path = Path(server_spec) @@ -424,10 +255,7 @@ async def run_command( # It's an MCP config server = create_mcp_config_server(config_path) else: - # Try to parse as FastMCPConfig - adapter = get_cached_typeadapter(FastMCPConfig) - adapter.validate_python(data) # Validate but don't need to store - # It's a FastMCP config - load it properly with runtime settings + # It's a FastMCP config - load it properly config = load_fastmcp_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) @@ -441,27 +269,42 @@ async def run_command( server_args if server_args is not None else config.deployment.args ) + # Prepare the source if needed (e.g., clone git repo, download from cloud) + if not skip_source: + await config.source.prepare() + # Load the server using the source - server = await config.source.load_server(config_path, server_args) + from contextlib import nullcontext + + from fastmcp.cli.cli import with_argv + + # Use sys.argv context manager if deployment args specified + argv_context = with_argv(server_args) if server_args else nullcontext() + + with argv_context: + server = await config.source.load_server() + logger.debug(f'Found server "{server.name}" from config {config_path}') else: - # Handle file case - parse into FileSystemSource immediately - if ":" in server_spec: - # Check if it's a Windows path (e.g., C:\...) - has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":" - - # Only split if colon is not part of Windows drive - if ":" in (server_spec[2:] if has_windows_drive else server_spec): - file_str, obj = server_spec.rsplit(":", 1) - source = FileSystemSource(path=file_str, object=obj) - else: - source = FileSystemSource(path=server_spec) - else: - source = FileSystemSource(path=server_spec) - - # Create a temporary config with just the source + # Regular file case - create a FastMCPConfig with FileSystemSource + source = FileSystemSource(path=server_spec) config = FastMCPConfig(source=source) - server = await config.source.load_server(None, server_args) + + # Prepare the source if needed + if not skip_source: + await config.source.prepare() + + # Load the server + from contextlib import nullcontext + + from fastmcp.cli.cli import with_argv + + # Use sys.argv context manager if server_args specified + argv_context = with_argv(server_args) if server_args else nullcontext() + + with argv_context: + server = await config.source.load_server() + logger.debug(f'Found server "{server.name}" in {source.path}') # Run the server @@ -499,6 +342,8 @@ def run_v1_server( port: int | None = None, transport: TransportType | None = None, ) -> None: + from functools import partial + if host: server.settings.host = host if port: diff --git a/src/fastmcp/utilities/fastmcp_config/__init__.py b/src/fastmcp/utilities/fastmcp_config/__init__.py index f9d6ee445..28236fe42 100644 --- a/src/fastmcp/utilities/fastmcp_config/__init__.py +++ b/src/fastmcp/utilities/fastmcp_config/__init__.py @@ -5,13 +5,13 @@ The current version is v1, which is re-exported here for convenience. """ from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import ( - BaseSource, Deployment, Environment, FastMCPConfig, - FileSystemSource, generate_schema, ) +from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource __all__ = [ "BaseSource", diff --git a/src/fastmcp/utilities/fastmcp_config/v1/__init__.py b/src/fastmcp/utilities/fastmcp_config/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py index 6a2793b54..80c9761ac 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py @@ -10,12 +10,12 @@ from __future__ import annotations import json import os import re -from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, overload from pydantic import BaseModel, Field, field_validator +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.logging import get_logger logger = get_logger("cli.config") @@ -24,56 +24,6 @@ logger = get_logger("cli.config") FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" -class BaseSource(BaseModel, ABC): - """Abstract base class for all source types.""" - - type: str = Field(description="Source type identifier") - - async def prepare(self, config_path: Path | None = None) -> Path | None: - """Prepare the source (download, clone, install, etc). - - Returns: - Path to prepared source directory, or None if no preparation needed. - This path may contain a nested fastmcp.json for configuration chaining. - """ - # Default implementation for sources that don't need preparation - return None - - @abstractmethod - async def load_server( - self, config_path: Path | None = None, server_args: list[str] | None = None - ) -> Any: - """Load and return the FastMCP server instance. - - Must be called after prepare() if the source requires preparation. - """ - ... - - -class FileSystemSource(BaseSource): - """Source for local Python files.""" - - type: Literal["filesystem"] = Field(default="filesystem", description="Source type") - path: str = Field(description="Path to Python file containing the server") - entrypoint: str | None = Field( - default=None, - description="Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - ) - - async def load_server( - self, config_path: Path | None = None, server_args: list[str] | None = None - ) -> Any: - """Load server from filesystem.""" - from fastmcp.cli.run import import_server_with_args - - # Resolve relative paths if config_path provided - file_path = Path(self.path) - if not file_path.is_absolute() and config_path: - file_path = (config_path.parent / file_path).resolve() - - return await import_server_with_args(file_path, self.entrypoint, server_args) - - # Type alias for source union (will expand with GitSource, etc in future) SourceType = FileSystemSource @@ -130,8 +80,12 @@ class Environment(BaseModel): if self.project: args.extend(["--project", str(self.project)]) - # Add fastmcp as a base dependency - args.extend(["--with", "fastmcp"]) + # Add fastmcp dependency - use editable install if in development mode + dev_path = self._find_fastmcp_dev_path() + if dev_path: + args.extend(["--with-editable", str(dev_path)]) + else: + args.extend(["--with", "fastmcp"]) # Add additional dependencies (skip fastmcp if already added) if self.dependencies: @@ -156,6 +110,34 @@ class Environment(BaseModel): return args + def _find_fastmcp_dev_path(self) -> Path | None: + """Find the fastmcp development directory by looking for pyproject.toml. + + Searches from the current working directory up the directory tree + looking for a pyproject.toml file that contains name = "fastmcp". + + Returns: + Path to the fastmcp project directory if found, None otherwise + """ + current_path = Path.cwd() + + # Search up the directory tree + for path in [current_path] + list(current_path.parents): + pyproject_path = path / "pyproject.toml" + if pyproject_path.exists(): + try: + # Read and check if this is the fastmcp project + content = pyproject_path.read_text(encoding="utf-8") + if 'name = "fastmcp"' in content or "name='fastmcp'" in content: + logger.debug(f"Found fastmcp development project at: {path}") + return path + except (OSError, UnicodeDecodeError): + # Skip files that can't be read + continue + + logger.debug("No fastmcp development project found, using PyPI package") + return None + def run_with_uv(self, command: list[str]) -> None: """Execute a command using uv run with this environment configuration. @@ -517,47 +499,6 @@ class FastMCPConfig(BaseModel): return None - async def load_server( - self, config_path: Path | None = None, server_args: list[str] | None = None - ) -> Any: - """Load the server from the configuration. - - This handles environment setup, working directory changes, - and delegates to the source's load_server method. - - Args: - config_path: Path to the config file (for resolving relative paths) - server_args: Optional arguments to pass to the server - - Returns: - The imported server object - """ - import os - from pathlib import Path - - # Set environment variables if specified - if self.deployment and self.deployment.env: - for key, value in self.deployment.env.items(): - os.environ[key] = value - - # Change working directory if specified - if self.deployment and self.deployment.cwd: - cwd_path = Path(self.deployment.cwd) - if not cwd_path.is_absolute(): - # If config_path provided, resolve relative to it - if config_path: - cwd_path = (config_path.parent / cwd_path).resolve() - else: - cwd_path = cwd_path.resolve() - os.chdir(cwd_path) - - # Use server_args from deployment if not provided - if server_args is None and self.deployment: - server_args = self.deployment.args - - # Delegate to the source's load_server method - return await self.source.load_server(config_path, server_args) - async def run_server(self, **kwargs: Any) -> None: """Load and run the server with this configuration. @@ -565,7 +506,12 @@ class FastMCPConfig(BaseModel): **kwargs: Additional arguments to pass to server.run_async() These override config settings """ - server = await self.load_server() + # Apply deployment settings (env vars, cwd) + if self.deployment: + self.deployment.apply_runtime_settings() + + # Load the server + server = await self.source.load_server() # Build run arguments from config run_args = {} diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py new file mode 100644 index 000000000..1eeb593d9 --- /dev/null +++ b/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import BaseModel, Field + + +class BaseSource(BaseModel, ABC): + """Abstract base class for all source types.""" + + type: str = Field(description="Source type identifier") + + async def prepare(self) -> None: + """Prepare the source (download, clone, install, etc). + + For sources that need preparation (e.g., git clone, download), + this method performs that preparation. For sources that don't + need preparation (e.g., local files), this is a no-op. + """ + # Default implementation for sources that don't need preparation + pass + + @abstractmethod + async def load_server(self) -> Any: + """Load and return the FastMCP server instance. + + Must be called after prepare() if the source requires preparation. + All information needed to load the server should be available + as attributes on the source instance. + """ + ... diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py b/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py new file mode 100644 index 000000000..92fb7ae3e --- /dev/null +++ b/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py @@ -0,0 +1,215 @@ +import importlib.util +import inspect +import sys +from pathlib import Path +from typing import Any, Literal + +from pydantic import Field, field_validator + +from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class FileSystemSource(BaseSource): + """Source for local Python files.""" + + type: Literal["filesystem"] = Field(default="filesystem", description="Source type") + path: str = Field(description="Path to Python file containing the server") + entrypoint: str | None = Field( + default=None, + description="Name of server instance or factory function (a no-arg function that returns a FastMCP server)", + ) + + @field_validator("path", mode="before") + @classmethod + def parse_path_with_object(cls, v: str) -> str: + """Parse path:object syntax and extract the object name. + + This validator runs before the model is created, allowing us to + handle the "file.py:object" syntax at the model boundary. + """ + if isinstance(v, str) and ":" in v: + # Check if it's a Windows path (e.g., C:\...) + has_windows_drive = len(v) > 1 and v[1] == ":" + + # Only split if colon is not part of Windows drive + if ":" in (v[2:] if has_windows_drive else v): + # This path has an object specification + # We'll handle it in __init__ by setting entrypoint + return v + return v + + def __init__(self, **data: Any) -> None: + """Initialize FileSystemSource, handling path:object syntax.""" + # Check if path contains an object specification + if "path" in data and isinstance(data["path"], str) and ":" in data["path"]: + path_str = data["path"] + # Check if it's a Windows path (e.g., C:\...) + has_windows_drive = len(path_str) > 1 and path_str[1] == ":" + + # Only split if colon is not part of Windows drive + if ":" in (path_str[2:] if has_windows_drive else path_str): + file_str, obj = path_str.rsplit(":", 1) + data["path"] = file_str + # Only set entrypoint if not already provided + if "entrypoint" not in data or data["entrypoint"] is None: + data["entrypoint"] = obj + + super().__init__(**data) + + async def load_server(self) -> Any: + """Load server from filesystem.""" + # Resolve the file path + file_path = Path(self.path).expanduser().resolve() + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + sys.exit(1) + if not file_path.is_file(): + logger.error(f"Not a file: {file_path}") + sys.exit(1) + + # Import the module + module = self._import_module(file_path) + + # Find the server object + server = await self._find_server_object(module, file_path) + + return server + + def _import_module(self, file_path: Path) -> Any: + """Import a Python module from a file path. + + Args: + file_path: Path to the Python file + + Returns: + The imported module + """ + # Add parent directory to Python path so imports can be resolved + file_dir = str(file_path.parent) + if file_dir not in sys.path: + sys.path.insert(0, file_dir) + + # Import the module + spec = importlib.util.spec_from_file_location("server_module", file_path) + if not spec or not spec.loader: + logger.error("Could not load module", extra={"file": str(file_path)}) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + sys.modules["server_module"] = module # Register in sys.modules + spec.loader.exec_module(module) # type: ignore[union-attr] + + return module + + async def _find_server_object(self, module: Any, file_path: Path) -> Any: + """Find the server object in the module. + + Args: + module: The imported Python module + file_path: Path to the file (for error messages) + + Returns: + The server object (or result of calling a factory function) + """ + # Avoid circular import by importing here + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.server.server import FastMCP + + # If entrypoint is specified, use it + if self.entrypoint: + # Handle module:object syntax (though this is legacy) + if ":" in self.entrypoint: + module_name, object_name = self.entrypoint.split(":", 1) + try: + import importlib + + server_module = importlib.import_module(module_name) + obj = getattr(server_module, object_name, None) + except ImportError: + logger.error( + f"Could not import module '{module_name}'", + extra={"file": str(file_path)}, + ) + sys.exit(1) + else: + # Just object name + obj = getattr(module, self.entrypoint, None) + + if obj is None: + logger.error( + f"Server object '{self.entrypoint}' not found", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + return await self._resolve_factory(obj, file_path, self.entrypoint) + + # No entrypoint specified, try common server names + for name in ["mcp", "server", "app"]: + if hasattr(module, name): + obj = getattr(module, name) + if isinstance(obj, FastMCP | FastMCP1x): + return await self._resolve_factory(obj, file_path, name) + + # No server found + logger.error( + f"No server object found in {file_path}. Please either:\n" + "1. Use a standard variable name (mcp, server, or app)\n" + "2. Specify the entrypoint name in fastmcp.json or use `file.py:object` syntax as your path.", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + async def _resolve_factory(self, obj: Any, file_path: Path, name: str) -> Any: + """Resolve a server object or factory function to a server instance. + + Args: + obj: The object that might be a server or factory function + file_path: Path to the file for error messages + name: Name of the object for error messages + + Returns: + A server instance + """ + # Avoid circular import by importing here + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.server.server import FastMCP + + # Check if it's a function or coroutine function + if inspect.isfunction(obj) or inspect.iscoroutinefunction(obj): + logger.debug(f"Found factory function '{name}' in {file_path}") + + try: + if inspect.iscoroutinefunction(obj): + # Async factory function + server = await obj() + else: + # Sync factory function + server = obj() + + # Validate the result is a FastMCP server + if not isinstance(server, FastMCP | FastMCP1x): + logger.error( + f"Factory function '{name}' must return a FastMCP server instance, " + f"got {type(server).__name__}", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + logger.debug(f"Factory function '{name}' created server: {server.name}") + return server + + except Exception as e: + logger.error( + f"Failed to call factory function '{name}': {e}", + extra={"file": str(file_path)}, + ) + sys.exit(1) + + # Not a function, return as-is (should be a server instance) + return obj diff --git a/test.fastmcp.json b/test.fastmcp.json deleted file mode 100644 index c2c15e00b..000000000 --- a/test.fastmcp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "source": { - "path": "test.py", - "object": "mcp" - }, - "environment": { - "python": "3.10", - "dependencies": ["fastmcp", "httpx", "pandas", "httpx23"] - }, - "deployment": { - "transport": "http", - "host": "127.0.0.1", - "port": 1234, - "path": "/mcp", - "log_level": "INFO" - } -} diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index d24203733..3cc075098 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -328,6 +328,47 @@ class TestRunCommand: ] ) + def test_run_command_parsing_skip_env_flag(self): + """Test run command parsing with --skip-env flag.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-env", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_env"] is True + + def test_run_command_parsing_skip_source_flag(self): + """Test run command parsing with --skip-source flag.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-source", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_source"] is True + + def test_run_command_parsing_both_skip_flags(self): + """Test run command parsing with both --skip-env and --skip-source flags.""" + command, bound, _ = app.parse_args( + [ + "run", + "server.py", + "--skip-env", + "--skip-source", + ] + ) + assert command is not None + assert bound.arguments["server_spec"] == "server.py" + assert bound.arguments["skip_env"] is True + assert bound.arguments["skip_source"] is True + class TestWindowsSpecific: """Test Windows-specific functionality.""" @@ -413,22 +454,27 @@ class TestWindowsSpecific: def test_windows_path_parsing_with_colon(self, tmp_path): """Test parsing Windows paths with drive letters and colons.""" - from fastmcp.cli.run import parse_file_path + from pathlib import Path + + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) # Create a real test file to test the logic test_file = tmp_path / "server.py" test_file.write_text("# test server") # Test normal file parsing (works on all platforms) - file_path, obj = parse_file_path(str(test_file)) - assert obj is None + source = FileSystemSource(path=str(test_file)) + assert source.entrypoint is None + assert Path(source.path).resolve() == test_file.resolve() # Test file:object parsing - file_path, obj = parse_file_path(f"{test_file}:myapp") - assert obj == "myapp" + source = FileSystemSource(path=f"{test_file}:myapp") + assert source.entrypoint == "myapp" # Test that the file portion resolves correctly when object is specified - assert file_path == test_file.resolve() + assert Path(source.path).resolve() == test_file.resolve() class TestInspectCommand: diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index a1d930f8e..c08c6982b 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -11,8 +11,8 @@ from fastmcp.utilities.fastmcp_config import ( Deployment, Environment, FastMCPConfig, - FileSystemSource, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource class TestFileSystemSource: diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index 37352fd10..bd2d39921 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -280,7 +280,11 @@ class TestInstallCursor: # Verify failure message was printed mock_print.assert_called() - def test_install_cursor_deduplicate_packages(self): + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, # Mock to disable dev mode so "fastmcp" count is predictable + ) + def test_install_cursor_deduplicate_packages(self, mock_find_dev): """Test that duplicate packages are deduplicated.""" with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open: mock_open.return_value = True diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index d6d2578ce..f5375f7e0 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -7,14 +7,13 @@ from pydantic import ValidationError from fastmcp.cli.run import ( create_mcp_config_server, - import_server, is_url, - parse_file_path, ) from fastmcp.client.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.mcp_config import MCPConfig, StdioMCPServer from fastmcp.server.server import FastMCP +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource class TestUrlDetection: @@ -41,52 +40,54 @@ class TestUrlDetection: assert not is_url("file:///path/to/file") -class TestFilePathParsing: - """Test file path parsing functionality.""" +class TestFileSystemSource: + """Test FileSystemSource path parsing functionality.""" - def test_parse_file_path_simple(self, tmp_path): + def test_parse_simple_path(self, tmp_path): """Test parsing simple file path without object.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - file_path, server_object = parse_file_path(str(test_file)) - assert file_path == test_file.resolve() - assert server_object is None + source = FileSystemSource(path=str(test_file)) + assert Path(source.path).resolve() == test_file.resolve() + assert source.entrypoint is None - def test_parse_file_path_with_object(self, tmp_path): + def test_parse_path_with_object(self, tmp_path): """Test parsing file path with object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - file_path, server_object = parse_file_path(f"{test_file}:app") - assert file_path == test_file.resolve() - assert server_object == "app" + source = FileSystemSource(path=f"{test_file}:app") + assert Path(source.path).resolve() == test_file.resolve() + assert source.entrypoint == "app" - def test_parse_file_path_complex_object(self, tmp_path): + def test_parse_complex_object(self, tmp_path): """Test parsing file path with complex object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") - # The current implementation splits on the last colon, so file:module:app - # becomes file_path="file:module" and server_object="app" + # The implementation splits on the last colon, so file:module:app + # becomes file_path="file:module" and entrypoint="app" # We need to create a file with a colon in the name for this test complex_file = tmp_path / "server:module.py" complex_file.write_text("# test server") - file_path, server_object = parse_file_path(f"{complex_file}:app") - assert file_path == complex_file.resolve() - assert server_object == "app" + source = FileSystemSource(path=f"{complex_file}:app") + assert Path(source.path).resolve() == complex_file.resolve() + assert source.entrypoint == "app" - def test_parse_file_path_nonexistent(self): - """Test parsing nonexistent file path exits.""" + async def test_load_server_nonexistent(self): + """Test loading nonexistent file path exits.""" + source = FileSystemSource(path="nonexistent.py") with pytest.raises(SystemExit) as exc_info: - parse_file_path("nonexistent.py") + await source.load_server() assert exc_info.value.code == 1 - def test_parse_file_path_directory(self, tmp_path): - """Test parsing directory path exits.""" + async def test_load_server_directory(self, tmp_path): + """Test loading directory path exits.""" + source = FileSystemSource(path=str(tmp_path)) with pytest.raises(SystemExit) as exc_info: - parse_file_path(str(tmp_path)) + await source.load_server() assert exc_info.value.code == 1 @@ -157,7 +158,8 @@ def greet(name: str) -> str: return f"Hello, {name}!" """) - server = await import_server(test_file) + source = FileSystemSource(path=str(test_file)) + server = await source.load_server() assert server.name == "TestServer" tools = await server.get_tools() assert "greet" in tools @@ -178,7 +180,8 @@ if __name__ == "__main__": app.run() """) - server = await import_server(test_file) + source = FileSystemSource(path=str(test_file)) + server = await source.load_server() assert server.name == "MainServer" tools = await server.get_tools() assert "calculate" in tools @@ -192,7 +195,8 @@ import fastmcp mcp = fastmcp.FastMCP("MCPServer") """) - server = await import_server(mcp_file) + source = FileSystemSource(path=str(mcp_file)) + server = await source.load_server() assert server.name == "MCPServer" # Test with 'server' name @@ -202,7 +206,8 @@ import fastmcp server = fastmcp.FastMCP("ServerServer") """) - server = await import_server(server_file) + source = FileSystemSource(path=str(server_file)) + server = await source.load_server() assert server.name == "ServerServer" # Test with 'app' name @@ -212,7 +217,8 @@ import fastmcp app = fastmcp.FastMCP("AppServer") """) - server = await import_server(app_file) + source = FileSystemSource(path=str(app_file)) + server = await source.load_server() assert server.name == "AppServer" async def test_import_server_nonstandard_name(self, tmp_path): @@ -228,7 +234,8 @@ def custom_tool() -> str: return "custom" """) - server = await import_server(test_file, "my_custom_server") + source = FileSystemSource(path=f"{test_file}:my_custom_server") + server = await source.load_server() assert server.name == "CustomServer" tools = await server.get_tools() assert "custom_tool" in tools @@ -242,8 +249,9 @@ import fastmcp other_name = fastmcp.FastMCP("OtherServer") """) + source = FileSystemSource(path=str(test_file)) with pytest.raises(SystemExit) as exc_info: - await import_server(test_file) + await source.load_server() assert exc_info.value.code == 1 async def test_import_server_nonexistent_object_fails(self, tmp_path): @@ -255,6 +263,131 @@ import fastmcp mcp = fastmcp.FastMCP("TestServer") """) + source = FileSystemSource(path=f"{test_file}:nonexistent") with pytest.raises(SystemExit) as exc_info: - await import_server(test_file, "nonexistent") + await source.load_server() assert exc_info.value.code == 1 + + +class TestSkipSource: + """Test the --skip-source functionality.""" + + async def test_run_command_calls_prepare_by_default(self, tmp_path): + """Test that run_command calls source.prepare() by default.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Create a test config file + config_file = tmp_path / "fastmcp.json" + config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} + config_file.write_text(json.dumps(config_data)) + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run the command + await run_command(str(config_file)) + + # Verify prepare was called + prepare_mock.assert_called_once() + + async def test_run_command_skips_prepare_with_flag(self, tmp_path): + """Test that run_command skips source.prepare() when skip_source=True.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Create a test config file + config_file = tmp_path / "fastmcp.json" + config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} + config_file.write_text(json.dumps(config_data)) + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run the command with skip_source=True + await run_command(str(config_file), skip_source=True) + + # Verify prepare was NOT called + prepare_mock.assert_not_called() + + async def test_filesystem_source_prepare_by_default(self, tmp_path): + """Test that FileSystemSource is prepared when using direct file spec.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run with direct file specification + await run_command(str(test_file)) + + # Verify prepare was called + prepare_mock.assert_called_once() + + async def test_filesystem_source_skip_prepare_with_flag(self, tmp_path): + """Test that FileSystemSource.prepare() is skipped with skip_source flag.""" + from unittest.mock import AsyncMock, patch + + from fastmcp.cli.run import run_command + from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import ( + FileSystemSource, + ) + + # Create a test server file + test_file = tmp_path / "server.py" + test_file.write_text(""" +import fastmcp +mcp = fastmcp.FastMCP("TestServer") +""") + + # Mock the prepare method and server run + with ( + patch.object( + FileSystemSource, "prepare", new_callable=AsyncMock + ) as prepare_mock, + patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), + ): + # Run with direct file specification and skip_source=True + await run_command(str(test_file), skip_source=True) + + # Verify prepare was NOT called + prepare_mock.assert_not_called() diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index f4ce3f547..a4320762e 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -11,8 +11,8 @@ from fastmcp.utilities.fastmcp_config import ( Deployment, Environment, FastMCPConfig, - FileSystemSource, ) +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource @pytest.fixture diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 9ab903856..6007b3d33 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -12,8 +12,12 @@ from fastmcp.cli.run import run_with_uv class TestRunWithUv: """Test the run_with_uv function.""" + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_basic(self, mock_run): + def test_run_with_uv_basic(self, mock_run, mock_find_dev_path): """Test basic run_with_uv execution.""" mock_run.return_value = Mock(returncode=0) @@ -38,8 +42,12 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_python_version(self, mock_run): + def test_run_with_uv_python_version(self, mock_run, mock_find_dev_path): """Test run_with_uv with Python version.""" mock_run.return_value = Mock(returncode=0) @@ -63,8 +71,12 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_project(self, mock_run): + def test_run_with_uv_project(self, mock_run, mock_find_dev_path): """Test run_with_uv with project directory.""" mock_run.return_value = Mock(returncode=0) # Use an absolute path that works on all platforms @@ -90,8 +102,12 @@ class TestRunWithUv: "--skip-env", ] + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_with_packages(self, mock_run): + def test_run_with_uv_with_packages(self, mock_run, mock_find_dev_path): """Test run_with_uv with additional packages.""" mock_run.return_value = Mock(returncode=0) @@ -117,8 +133,12 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_with_requirements(self, mock_run): + def test_run_with_uv_with_requirements(self, mock_run, mock_find_dev_path): """Test run_with_uv with requirements file.""" mock_run.return_value = Mock(returncode=0) req_path = Path("requirements.txt") @@ -143,8 +163,12 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_transport_options(self, mock_run): + def test_run_with_uv_transport_options(self, mock_run, mock_find_dev_path): """Test run_with_uv with transport-related options.""" mock_run.return_value = Mock(returncode=0) @@ -185,8 +209,12 @@ class TestRunWithUv: ] assert cmd == expected + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_all_options(self, mock_run): + def test_run_with_uv_all_options(self, mock_run, mock_find_dev_path): """Test run_with_uv with all options combined.""" mock_run.return_value = Mock(returncode=0) @@ -230,8 +258,12 @@ class TestRunWithUv: "--no-banner", ] + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("subprocess.run") - def test_run_with_uv_error_handling(self, mock_run): + def test_run_with_uv_error_handling(self, mock_run, mock_find_dev_path): """Test run_with_uv error handling.""" mock_run.side_effect = subprocess.CalledProcessError(1, ["uv", "run"]) @@ -240,9 +272,13 @@ class TestRunWithUv: assert exc_info.value.code == 1 + @patch( + "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment._find_fastmcp_dev_path", + return_value=None, + ) @patch("fastmcp.cli.run.logger") @patch("subprocess.run") - def test_run_with_uv_logging(self, mock_run, mock_logger): + def test_run_with_uv_logging(self, mock_run, mock_logger, mock_find_dev_path): """Test that run_with_uv logs the command.""" mock_run.return_value = Mock(returncode=0) diff --git a/tests/cli/test_server_args.py b/tests/cli/test_server_args.py new file mode 100644 index 000000000..5ec7ccc6a --- /dev/null +++ b/tests/cli/test_server_args.py @@ -0,0 +1,139 @@ +"""Test server argument passing functionality.""" + +from pathlib import Path + +import pytest + +from fastmcp.utilities.fastmcp_config import FastMCPConfig +from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + + +class TestServerArguments: + """Test passing arguments to servers.""" + + @pytest.mark.asyncio + async def test_server_with_argparse(self, tmp_path): + """Test a server that uses argparse with command line arguments.""" + server_file = tmp_path / "argparse_server.py" + server_file.write_text(""" +import argparse +from fastmcp import FastMCP + +parser = argparse.ArgumentParser() +parser.add_argument("--name", default="DefaultServer") +parser.add_argument("--port", type=int, default=8000) +parser.add_argument("--debug", action="store_true") + +args = parser.parse_args() + +server_name = f"{args.name}:{args.port}" +if args.debug: + server_name += " (Debug)" + +mcp = FastMCP(server_name) + +@mcp.tool +def get_config() -> dict: + return {"name": args.name, "port": args.port, "debug": args.debug} +""") + + # Test with arguments + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Simulate passing arguments + with with_argv(["--name", "TestServer", "--port", "9000", "--debug"]): + server = await config.source.load_server() + + assert server.name == "TestServer:9000 (Debug)" + + # Test the tool works and can access the parsed args + tools = await server.get_tools() + assert "get_config" in tools + + @pytest.mark.asyncio + async def test_server_with_no_args(self, tmp_path): + """Test a server that uses argparse with no arguments (defaults).""" + server_file = tmp_path / "default_server.py" + server_file.write_text(""" +import argparse +from fastmcp import FastMCP + +parser = argparse.ArgumentParser() +parser.add_argument("--name", default="DefaultName") +args = parser.parse_args() + +mcp = FastMCP(args.name) +""") + + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with empty args list (should use defaults) + with with_argv([]): + server = await config.source.load_server() + + assert server.name == "DefaultName" + + @pytest.mark.asyncio + async def test_server_with_sys_argv_access(self, tmp_path): + """Test a server that directly accesses sys.argv.""" + server_file = tmp_path / "sysargv_server.py" + server_file.write_text(""" +import sys +from fastmcp import FastMCP + +# Direct sys.argv access (less common but should work) +name = "DirectServer" +if len(sys.argv) > 1 and sys.argv[1] == "--custom": + name = "CustomServer" + +mcp = FastMCP(name) +""") + + source = FileSystemSource(path=str(server_file)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with custom argument + with with_argv(["--custom"]): + server = await config.source.load_server() + + assert server.name == "CustomServer" + + # Test without argument + with with_argv([]): + server = await config.source.load_server() + + assert server.name == "DirectServer" + + @pytest.mark.asyncio + async def test_config_server_example(self): + """Test the actual config_server.py example.""" + # Find the examples directory + examples_dir = Path(__file__).parent.parent.parent / "examples" + config_server = examples_dir / "config_server.py" + + if not config_server.exists(): + pytest.skip("config_server.py example not found") + + source = FileSystemSource(path=str(config_server)) + config = FastMCPConfig(source=source) + + from fastmcp.cli.cli import with_argv + + # Test with debug flag + with with_argv(["--name", "TestExample", "--debug"]): + server = await config.source.load_server() + + assert server.name == "TestExample (Debug)" + + # Verify tools are available + tools = await server.get_tools() + assert "get_status" in tools + assert "echo_message" in tools diff --git a/tests/cli/test_with_argv.py b/tests/cli/test_with_argv.py new file mode 100644 index 000000000..ee9d378d4 --- /dev/null +++ b/tests/cli/test_with_argv.py @@ -0,0 +1,91 @@ +"""Test the with_argv context manager.""" + +import sys +from unittest.mock import patch + +import pytest + +from fastmcp.cli.cli import with_argv + + +class TestWithArgv: + """Test the with_argv context manager.""" + + def test_with_argv_replaces_args(self): + """Test that with_argv properly replaces sys.argv.""" + original_argv = sys.argv[:] + test_args = ["--name", "TestServer", "--debug"] + + with with_argv(test_args): + # Should preserve script name and add new args + assert sys.argv[0] == original_argv[0] + assert sys.argv[1:] == test_args + + # Should restore original argv after context + assert sys.argv == original_argv + + def test_with_argv_none_does_nothing(self): + """Test that with_argv(None) doesn't change sys.argv.""" + original_argv = sys.argv[:] + + with with_argv(None): + assert sys.argv == original_argv + + assert sys.argv == original_argv + + def test_with_argv_empty_list(self): + """Test that with_argv([]) clears arguments but keeps script name.""" + original_argv = sys.argv[:] + + with with_argv([]): + # Should have only the script name (no additional args) + assert sys.argv == [original_argv[0]] + assert len(sys.argv) == 1 + + assert sys.argv == original_argv + + def test_with_argv_restores_on_exception(self): + """Test that sys.argv is restored even if an exception occurs.""" + original_argv = sys.argv[:] + test_args = ["--error"] + + with pytest.raises(ValueError): + with with_argv(test_args): + assert sys.argv == [original_argv[0]] + test_args + raise ValueError("Test error") + + # Should still restore original argv + assert sys.argv == original_argv + + def test_with_argv_nested(self): + """Test nested with_argv contexts.""" + original_argv = sys.argv[:] + args1 = ["--level1"] + args2 = ["--level2", "--debug"] + + with with_argv(args1): + assert sys.argv == [original_argv[0]] + args1 + + with with_argv(args2): + assert sys.argv == [original_argv[0]] + args2 + + # Should restore to level 1 + assert sys.argv == [original_argv[0]] + args1 + + # Should restore to original + assert sys.argv == original_argv + + @patch("sys.argv", ["test_script.py", "existing", "args"]) + def test_with_argv_with_existing_args(self): + """Test with_argv when sys.argv already has arguments.""" + original_argv = sys.argv[:] + assert original_argv == ["test_script.py", "existing", "args"] + + test_args = ["--new", "args"] + + with with_argv(test_args): + # Should replace existing args but keep script name + assert sys.argv == ["test_script.py", "--new", "args"] + + # Should restore original + assert sys.argv == original_argv diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index 3d0fdc435..7d7899be7 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -1,17 +1,22 @@ +from pathlib import Path +from unittest.mock import patch + from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment class TestEnvironmentBuildUVArgs: """Test the Environment.build_uv_args() method.""" - def test_build_uv_args_basic(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_basic(self, mock_dev_path): """Test building basic uv args.""" env = Environment() args = env.build_uv_args(["fastmcp", "run", "server.py"]) expected = ["run", "--with", "fastmcp", "fastmcp", "run", "server.py"] assert args == expected - def test_build_uv_args_with_editable(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_editable(self, mock_dev_path): """Test building uv args with editable package.""" editable_path = "/path/to/package" env = Environment(editable=editable_path) @@ -28,7 +33,8 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_with_packages(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_packages(self, mock_dev_path): """Test building uv args with additional packages.""" env = Environment(dependencies=["pkg1", "pkg2"]) args = env.build_uv_args(["fastmcp", "run", "server.py"]) @@ -46,7 +52,8 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_with_python_version(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_python_version(self, mock_dev_path): """Test building uv args with Python version.""" env = Environment(python="3.11") args = env.build_uv_args(["fastmcp", "run", "server.py"]) @@ -62,7 +69,8 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_with_project(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_project(self, mock_dev_path): """Test building uv args with project directory.""" project_path = "/path/to/project" env = Environment(project=project_path) @@ -79,7 +87,8 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_with_requirements(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_requirements(self, mock_dev_path): """Test building uv args with requirements file.""" req_path = "requirements.txt" env = Environment(requirements=req_path) @@ -96,7 +105,8 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_with_all_options(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_with_all_options(self, mock_dev_path): """Test building uv args with all options.""" project_path = "/my/project" editable_path = "/local/pkg" @@ -131,14 +141,16 @@ class TestEnvironmentBuildUVArgs: ] assert args == expected - def test_build_uv_args_no_command(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_no_command(self, mock_dev_path): """Test building uv args with no command.""" env = Environment(python="3.11") args = env.build_uv_args() expected = ["run", "--python", "3.11", "--with", "fastmcp"] assert args == expected - def test_build_uv_args_string_command(self): + @patch.object(Environment, "_find_fastmcp_dev_path", return_value=None) + def test_build_uv_args_string_command(self, mock_dev_path): """Test building uv args with string command.""" env = Environment() args = env.build_uv_args("python") @@ -166,3 +178,93 @@ class TestEnvironmentBuildUVArgs: """Test that needs_uv returns False when no environment settings are present.""" env = Environment() assert env.needs_uv() is False + + @patch.object(Environment, "_find_fastmcp_dev_path") + def test_build_uv_args_development_mode(self, mock_dev_path): + """Test building uv args in development mode (when fastmcp project is found).""" + # Mock finding the development path + dev_path = Path("/path/to/fastmcp/dev") + mock_dev_path.return_value = dev_path + + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = [ + "run", + "--with-editable", + str(dev_path), + "fastmcp", + "run", + "server.py", + ] + assert args == expected + + @patch.object(Environment, "_find_fastmcp_dev_path") + def test_build_uv_args_production_mode(self, mock_dev_path): + """Test building uv args in production mode (when no fastmcp project is found).""" + # Mock not finding the development path + mock_dev_path.return_value = None + + env = Environment() + args = env.build_uv_args(["fastmcp", "run", "server.py"]) + expected = ["run", "--with", "fastmcp", "fastmcp", "run", "server.py"] + assert args == expected + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.read_text") + def test_find_fastmcp_dev_path_found(self, mock_read_text, mock_exists, mock_cwd): + """Test finding fastmcp development path when pyproject.toml exists.""" + # Set up mock current directory + mock_cwd_path = Path("/path/to/fastmcp") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml exists and contains fastmcp name + mock_exists.return_value = True + mock_read_text.return_value = """[project] +name = "fastmcp" +version = "2.0.0" +""" + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result == mock_cwd_path + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + def test_find_fastmcp_dev_path_not_found(self, mock_exists, mock_cwd): + """Test not finding fastmcp development path when no pyproject.toml exists.""" + # Set up mock current directory + mock_cwd_path = Path("/some/other/directory") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml doesn't exist + mock_exists.return_value = False + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result is None + + @patch("pathlib.Path.cwd") + @patch("pathlib.Path.exists") + @patch("pathlib.Path.read_text") + def test_find_fastmcp_dev_path_wrong_project( + self, mock_read_text, mock_exists, mock_cwd + ): + """Test not finding fastmcp when pyproject.toml exists but is for different project.""" + # Set up mock current directory + mock_cwd_path = Path("/path/to/other/project") + mock_cwd.return_value = mock_cwd_path + + # Mock pyproject.toml exists but is for different project + mock_exists.return_value = True + mock_read_text.return_value = """[project] +name = "other-project" +version = "1.0.0" +""" + + env = Environment() + result = env._find_fastmcp_dev_path() + + assert result is None