diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py index 8873a3a7a..af42da058 100644 --- a/src/fastmcp/cli/claude.py +++ b/src/fastmcp/cli/claude.py @@ -98,12 +98,11 @@ def update_claude_config( if not deduplicated_packages: deduplicated_packages = None - # Build uv run command using Environment.build_uv_args() + # Build uv run command using Environment.build_uv_run_command() env_config = Environment( dependencies=deduplicated_packages, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() # Convert file path to absolute before adding to command # Split off any :object suffix first @@ -113,10 +112,14 @@ def update_claude_config( else: file_spec = str(Path(file_spec).resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", file_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", file_spec]) - server_config: dict[str, Any] = {"command": "uv", "args": args} + # Extract command and args for the config + server_config: dict[str, Any] = { + "command": full_command[0], + "args": full_command[1:], + } # Add environment variables if specified if env_vars: diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 34e1a908c..09de389db 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -13,7 +13,6 @@ from typing import Annotated, Literal import cyclopts import pyperclip -from pydantic import ValidationError from rich.console import Console from rich.table import Table @@ -22,14 +21,12 @@ from fastmcp.cli import run as run_module from fastmcp.cli.install import install_app from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig -from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.inspect import ( InspectFormat, format_info, inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger("cli") console = Console() @@ -195,75 +192,33 @@ async def dev( Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - from pathlib import Path + from fastmcp.utilities.cli import load_and_merge_config - from fastmcp.utilities.fastmcp_config import FastMCPConfig - from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + editable=[str(p) for p in with_editable] if with_editable else None, + port=server_port, # Use deployment config for server port + ) - config = None - config_path = None + # Get server port from config if not specified via CLI + if not server_port: + server_port = config.deployment.port - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "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}") - - # 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: - 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 editable paths from config with CLI args - if config.environment.editable and not with_editable: - with_editable = [Path(p) for p in config.environment.editable] - - # Merge packages from both sources - if config.environment.dependencies: - packages = list(config.environment.dependencies) - if with_packages: - packages.extend(with_packages) - with_packages = packages - - # Get server port from deployment config if not specified - if config.deployment and config.deployment.port: - server_port = server_port or config.deployment.port - else: - # Create config from file path - source = FileSystemSource(path=server_spec) - config = FastMCPConfig(source=source) + except FileNotFoundError: + sys.exit(1) logger.debug( "Starting dev server", extra={ "server_spec": server_spec, - "with_editable": [str(p) for p in with_editable] if with_editable else None, - "with_packages": with_packages, + "with_editable": config.environment.editable, + "with_packages": config.environment.dependencies, "ui_port": ui_port, "server_port": server_port, }, @@ -271,6 +226,10 @@ async def dev( try: # Load server to check for deprecated dependencies + if not config: + logger.error("No configuration available") + sys.exit(1) + assert config is not None # For type checker server: FastMCP = await config.source.load_server() if server.dependencies: import warnings @@ -282,7 +241,13 @@ async def dev( DeprecationWarning, stacklevel=2, ) - with_packages = list(set(with_packages + server.dependencies)) + # Merge server dependencies with environment dependencies + env_deps = config.environment.dependencies or [] + all_deps = list(set(env_deps + server.dependencies)) + if not config.environment: + config.environment = Environment(dependencies=all_deps) + else: + config.environment.dependencies = all_deps env_vars = {} if ui_port: @@ -303,18 +268,13 @@ async def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - # Create Environment object from CLI args - 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, - editable=[str(p) for p in with_editable] if with_editable else None, + # Use the environment from config (already has CLI overrides applied) + uv_cmd = config.environment.build_uv_run_command( + ["fastmcp", "run", server_spec, "--no-banner"] ) - uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec]) - # Add --no-banner flag for dev command - uv_cmd.append("--no-banner") + # Set marker to prevent infinite loops when subprocess calls FastMCP + env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"} # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" @@ -322,7 +282,7 @@ async def dev( [npx_cmd, inspector_cmd] + uv_cmd, check=True, shell=shell, - env=dict(os.environ.items()) | env_vars, + env=env, ) sys.exit(process.returncode) except subprocess.CalledProcessError as e: @@ -454,135 +414,74 @@ async def run( Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ - # Convert None to empty lists for list parameters - with_packages = with_packages or [] - # Load configuration if needed - from pathlib import Path + from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config - from fastmcp.utilities.fastmcp_config import FastMCPConfig + # Check if we were spawned by uv (or user explicitly set --skip-env) + if skip_env or is_already_in_uv_subprocess(): + skip_env = True - config = None - config_path = None - editable = None # Initialize editable variable + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + transport=transport, + host=host, + port=port, + path=path, + log_level=log_level, + server_args=list(server_args) if server_args else None, + ) + except FileNotFoundError: + sys.exit(1) - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - sys.exit(1) + # Get effective values (CLI overrides take precedence) + final_transport = transport or config.deployment.transport + final_host = host or config.deployment.host + final_port = port or config.deployment.port + final_path = path or config.deployment.path + final_log_level = log_level or config.deployment.log_level + final_server_args = server_args or config.deployment.args - server_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") - - # Load config if server_spec is a .json file - 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 if it's an MCPConfig first (has canonical mcpServers key) - if "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: - # Try to parse as FastMCPConfig - try: - adapter = get_cached_typeadapter(FastMCPConfig) - config = adapter.validate_python(data) - - # Merge deployment config with CLI values (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - server_args = ( - tuple(server_args) - if server_args - else tuple(config.deployment.args or ()) - ) - - # Merge environment config with CLI values (CLI takes precedence) - # BUT: Skip this if --skip-env is set - if config.environment and not skip_env: - 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 - ) - # Extract editable from config (no CLI override for this) - editable = config.environment.editable - - # 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 ValidationError: - # Not a valid FastMCPConfig, treat as regular server spec - config = None - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, treat as regular server spec - config = None - else: - config = None logger.debug( "Running server or client", extra={ "server_spec": server_spec, - "transport": transport, - "host": host, - "port": port, - "path": path, - "log_level": log_level, - "server_args": list(server_args), + "transport": final_transport, + "host": final_host, + "port": final_port, + "path": final_path, + "log_level": final_log_level, + "server_args": list(final_server_args) if final_server_args else [], }, ) - # Check if we need to use uv run (either from CLI args or config) - # When --skip-env is set, we ignore config.environment entirely - needs_uv = python or with_packages or with_requirements or project or editable - if not needs_uv and config and config.environment and not skip_env: - # Check if config's environment needs uv (but only if not skipping env) - needs_uv = config.environment.needs_uv() + # Check if we need to use uv run (but skip if we're already in uv or user said to skip) + needs_uv = config.environment.needs_uv() and not skip_env if needs_uv: # Use uv run subprocess - always use run_with_uv which handles output correctly try: run_module.run_with_uv( server_spec=server_spec, - python_version=python, - with_packages=with_packages, - with_requirements=with_requirements, - project=project, - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, + python_version=config.environment.python, + with_packages=config.environment.dependencies, + with_requirements=Path(config.environment.requirements) + if config.environment.requirements + else None, + project=Path(config.environment.project) + if config.environment.project + else None, + transport=final_transport, + host=final_host, + port=final_port, + path=final_path, + log_level=final_log_level, show_banner=not no_banner, - editable=editable, + editable=config.environment.editable, ) except Exception as e: logger.error( @@ -598,12 +497,12 @@ async def run( try: await run_module.run_command( server_spec=server_spec, - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=list(server_args), + transport=final_transport, + host=final_host, + port=final_port, + path=final_path, + log_level=final_log_level, + server_args=list(final_server_args) if final_server_args else [], show_banner=not no_banner, skip_source=skip_source, ) @@ -696,100 +595,47 @@ async def inspect( Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ - # Convert None to empty lists for list parameters - with_packages = with_packages or [] - config = None - config_path = None + from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - # Check if fastmcp.json exists in current directory - found_config = FastMCPConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - sys.exit(1) + # Check if we were spawned by uv (or user explicitly set --skip-env) + if skip_env or is_already_in_uv_subprocess(): + skip_env = True - server_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") + try: + # Load config and apply CLI overrides + config, server_spec = load_and_merge_config( + server_spec, + python=python, + with_packages=with_packages or [], + with_requirements=with_requirements, + project=project, + ) - # Create FastMCPConfig from server_spec - if server_spec.endswith(".json"): - config_path = Path(server_spec) - if config_path.exists(): + # Check if it's an MCPConfig (which inspect doesn't support) + if server_spec.endswith(".json") and config is None: + # This might be an MCPConfig, check the file try: - with open(config_path) as f: + with open(Path(server_spec)) as f: data = json.load(f) - - # 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) + except (json.JSONDecodeError, FileNotFoundError): + pass - # 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 - ) + except FileNotFoundError: + sys.exit(1) - # 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: - 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 (skip if --skip-env is set) - needs_uv = False - if not skip_env: - needs_uv = python or with_packages or with_requirements or project - if not needs_uv and config and config.environment: - needs_uv = config.environment.needs_uv() + # Check if we need to use uv run (but skip if we're already in uv or user said to skip) + needs_uv = config.environment.needs_uv() and not skip_env if needs_uv: # Build and run uv command - - # 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, - ) - + # The environment is already configured in the config object inspect_command = [ "fastmcp", "inspect", server_spec, - "--skip-env", # Prevent infinite loop when calling through uv ] # Add format and output flags if specified @@ -797,7 +643,7 @@ async def inspect( inspect_command.extend(["--format", format.value]) if output: inspect_command.extend(["--output", str(output)]) - env_config.run_with_uv(inspect_command) + config.environment.run_with_uv(inspect_command) return # run_with_uv exits the process logger.debug( @@ -811,6 +657,10 @@ async def inspect( try: # Load the server using the config + if not config: + logger.error("No configuration available") + sys.exit(1) + assert config is not None # For type checker server = await config.source.load_server() # Get basic server information @@ -936,8 +786,6 @@ async def prepare( """ from pathlib import Path - from fastmcp.utilities.fastmcp_config import FastMCPConfig - # Require output-dir if output_dir is None: logger.error( diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index aaa07503c..0117f67b4 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -115,7 +115,7 @@ def install_claude_code( if not deduplicated_packages: deduplicated_packages = None - # Build uv run command using Environment.build_uv_args() + # Build uv run command using Environment.build_uv_run_command() env_config = Environment( python=python_version, dependencies=deduplicated_packages, @@ -123,7 +123,6 @@ def install_claude_code( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() # Build server spec from parsed components if server_object: @@ -131,8 +130,8 @@ def install_claude_code( else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add"] @@ -144,7 +143,7 @@ def install_claude_code( # Add server name and command cmd_parts.extend([name, "--"]) - cmd_parts.extend(["uv"] + args) + cmd_parts.extend(full_command) try: # Run the claude mcp add command diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py index 28ce20eee..d4184f95d 100644 --- a/src/fastmcp/cli/install/claude_desktop.py +++ b/src/fastmcp/cli/install/claude_desktop.py @@ -88,21 +88,19 @@ def install_claude_desktop( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index bbb94a872..f10192e16 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -122,21 +122,19 @@ def install_cursor_workspace( project=str(project.resolve()) if project else None, editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) @@ -202,16 +200,14 @@ def install_cursor( project=str(project.resolve()) if project else None, editable=[str(p.resolve()) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # If workspace is specified, install to workspace-specific config if workspace: @@ -230,8 +226,8 @@ def install_cursor( # Create server configuration server_config = StdioMCPServer( - command="uv", - args=args, + command=full_command[0], + args=full_command[1:], env=env_vars or {}, ) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py index 42696ae6c..5ea466590 100644 --- a/src/fastmcp/cli/install/mcp_json.py +++ b/src/fastmcp/cli/install/mcp_json.py @@ -63,21 +63,19 @@ def install_mcp_json( project=str(project) if project else None, editable=[str(p) for p in with_editable] if with_editable else None, ) - args = env_config.build_uv_args() - # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) - # Add fastmcp run command - args.extend(["fastmcp", "run", server_spec]) + # Build the full command + full_command = env_config.build_uv_run_command(["fastmcp", "run", server_spec]) # Build MCP server configuration server_config = { - "command": "uv", - "args": args, + "command": full_command[0], + "args": full_command[1:], } # Add environment variables if provided diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index 6db83403f..54b61977d 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -62,9 +62,9 @@ async def process_common_args( config = FastMCPConfig.from_file(config_path) # Merge packages from config if not overridden - if config.environment and config.environment.dependencies: + if config.environment.dependencies: # Merge with CLI packages (CLI takes precedence) - config_packages = list(config.environment.dependencies) or [] + config_packages = list(config.environment.dependencies) with_packages = list(set(with_packages + config_packages)) except (json.JSONDecodeError, ValidationError) as e: print(f"[red]Invalid configuration file: {e}[/red]") diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 47302f210..2b812e625 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -1,6 +1,7 @@ """FastMCP run command implementation with enhanced type hints.""" import json +import os import re import subprocess import sys @@ -8,7 +9,6 @@ from pathlib import Path from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x -from pydantic import ValidationError from fastmcp.server.server import FastMCP from fastmcp.utilities.fastmcp_config import ( @@ -17,7 +17,6 @@ from fastmcp.utilities.fastmcp_config import ( ) 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("cli.run") @@ -48,6 +47,10 @@ def run_with_uv( ) -> None: """Run a MCP server using uv run subprocess. + This function is called when we need to set up a Python environment with specific + dependencies before running the server. The config parsing and merging should already + be done by the caller. + Args: server_spec: Python file, object specification (file:obj), config file, or URL python_version: Python version to use (e.g. "3.10") @@ -60,70 +63,10 @@ def run_with_uv( path: Path to bind to when using http transport log_level: Log level show_banner: Whether to show the server banner + editable: Editable package paths """ - # Check if server_spec is a .json file - if server_spec.endswith(".json"): - config_path = Path(server_spec).resolve() # Get absolute path - 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 if it's an MCPConfig first (has canonical mcpServers key) - if "mcpServers" in data: - # It's an MCPConfig, we don't process it here - just pass through - pass - else: - # Try to parse as FastMCPConfig - try: - adapter = get_cached_typeadapter(FastMCPConfig) - config: FastMCPConfig = adapter.validate_python(data) - - # Apply deployment settings - if config.deployment: - config.deployment.apply_runtime_settings(config_path) - - # Merge environment config with CLI args (CLI takes precedence) - if config.environment: - # Use CLI values if provided, otherwise fall back to config - python_version = python_version 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 - ) - # Note: config editable is a list but CLI currently only supports single path - # Just pass through for now - Environment will handle the list - if not editable and config.environment.editable: - editable = config.environment.editable - - # Merge packages from both sources - # Only merge if with_packages doesn't already contain them - # (they may have been merged already in CLI) - if config.environment.dependencies and not with_packages: - with_packages = list(config.environment.dependencies) - - # Merge deployment config with CLI args (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - except ValidationError: - # Not a valid FastMCPConfig, just pass through - pass - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, just pass through - pass - - # Build uv command using Environment.build_uv_args() + # Build uv command using Environment.build_uv_run_command() env_config = Environment( python=python_version, dependencies=with_packages if with_packages else None, @@ -133,9 +76,9 @@ def run_with_uv( if isinstance(editable, list) else ([editable] if editable else None), ) - # Build the uv command - # Build the inner fastmcp command with --skip-env to prevent infinite recursion - inner_cmd = ["fastmcp", "run", "--skip-env", server_spec] + + # Build the inner fastmcp command (environment variable prevents infinite recursion) + inner_cmd = ["fastmcp", "run", server_spec] # Add transport options to the inner command if transport: @@ -154,13 +97,15 @@ def run_with_uv( inner_cmd.append("--no-banner") # Build the full uv command - uv_args = env_config.build_uv_args(inner_cmd) - cmd = ["uv"] + uv_args + cmd = env_config.build_uv_run_command(inner_cmd) + + # Set marker to prevent infinite loops when subprocess calls FastMCP again + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} # Run the command logger.debug(f"Running command: {' '.join(cmd)}") try: - process = subprocess.run(cmd, check=True) + process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error(f"Failed to run server: {e}") @@ -210,8 +155,7 @@ def load_fastmcp_config(config_path: Path) -> FastMCPConfig: config = FastMCPConfig.from_file(config_path) # Apply runtime settings from deployment config - if config.deployment: - config.deployment.apply_runtime_settings(config_path) + config.deployment.apply_runtime_settings(config_path) return config @@ -263,15 +207,14 @@ async def run_command( config = load_fastmcp_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) - if config.deployment: - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - server_args = ( - server_args if server_args is not None else config.deployment.args - ) + transport = transport or config.deployment.transport + host = host or config.deployment.host + port = port or config.deployment.port + path = path or config.deployment.path + log_level = log_level or config.deployment.log_level + server_args = ( + server_args if server_args is not None else config.deployment.args + ) # Prepare source only (environment is handled by uv run) await config.prepare_source() if not skip_source else None diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py index 1c208da4d..55081a5ad 100644 --- a/src/fastmcp/utilities/cli.py +++ b/src/fastmcp/utilities/cli.py @@ -1,8 +1,12 @@ from __future__ import annotations +import json +import os from importlib.metadata import version +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +from pydantic import ValidationError from rich.align import Align from rich.console import Console, Group from rich.panel import Panel @@ -10,10 +14,130 @@ from rich.table import Table from rich.text import Text import fastmcp +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 if TYPE_CHECKING: from fastmcp import FastMCP +logger = get_logger("cli.config") + + +def is_already_in_uv_subprocess() -> bool: + """Check if we're already running in a FastMCP uv subprocess.""" + return bool(os.environ.get("FASTMCP_UV_SPAWNED")) + + +def load_and_merge_config( + server_spec: str | None, + **cli_overrides, +) -> tuple[FastMCPConfig, str]: + """Load config from server_spec and apply CLI overrides. + + This consolidates the config parsing logic that was duplicated across + run, inspect, and dev commands. + + Args: + server_spec: Python file, config file, URL, or None to auto-detect + cli_overrides: CLI arguments that override config values + + Returns: + Tuple of (FastMCPConfig, resolved_server_spec) + """ + config = None + config_path = None + + # Auto-detect fastmcp.json if no server_spec provided + if server_spec is None: + config_path = Path("fastmcp.json") + if not config_path.exists(): + found_config = FastMCPConfig.find_config() + if found_config: + config_path = found_config + else: + logger.error( + "No server specification provided and no fastmcp.json found in current directory.\n" + "Please specify a server file or create a fastmcp.json configuration." + ) + raise FileNotFoundError("No server specification or fastmcp.json found") + + resolved_spec = str(config_path) + logger.info(f"Using configuration from {config_path}") + else: + resolved_spec = server_spec + + # Load config if server_spec is a .json file + if resolved_spec.endswith(".json"): + config_path = Path(resolved_spec) + if config_path.exists(): + try: + with open(config_path) as f: + data = json.load(f) + + # Check if it's an MCPConfig first (has canonical mcpServers key) + if "mcpServers" in data: + # MCPConfig - we don't process these here, just pass through + pass + else: + # Try to parse as FastMCPConfig + try: + adapter = get_cached_typeadapter(FastMCPConfig) + config = adapter.validate_python(data) + + # Apply deployment settings + if config.deployment: + config.deployment.apply_runtime_settings(config_path) + + except ValidationError: + # Not a valid FastMCPConfig, just pass through + pass + except (json.JSONDecodeError, FileNotFoundError): + # Not a valid JSON file, just pass through + pass + + # If we don't have a config object yet, create one from filesystem source + if config is None: + source = FileSystemSource(path=resolved_spec) + config = FastMCPConfig(source=source) + + # Convert to dict for immutable transformation + config_dict = config.model_dump() + + # Apply CLI overrides to config's environment (always exists due to default_factory) + if python_override := cli_overrides.get("python"): + config_dict["environment"]["python"] = python_override + if packages_override := cli_overrides.get("with_packages"): + # Merge packages - CLI packages are added to config packages + existing = config_dict["environment"].get("dependencies") or [] + config_dict["environment"]["dependencies"] = packages_override + existing + if requirements_override := cli_overrides.get("with_requirements"): + config_dict["environment"]["requirements"] = str(requirements_override) + if project_override := cli_overrides.get("project"): + config_dict["environment"]["project"] = str(project_override) + if editable_override := cli_overrides.get("editable"): + config_dict["environment"]["editable"] = editable_override + + # Apply deployment CLI overrides (always exists due to default_factory) + if transport_override := cli_overrides.get("transport"): + config_dict["deployment"]["transport"] = transport_override + if host_override := cli_overrides.get("host"): + config_dict["deployment"]["host"] = host_override + if port_override := cli_overrides.get("port"): + config_dict["deployment"]["port"] = port_override + if path_override := cli_overrides.get("path"): + config_dict["deployment"]["path"] = path_override + if log_level_override := cli_overrides.get("log_level"): + config_dict["deployment"]["log_level"] = log_level_override + if server_args_override := cli_overrides.get("server_args"): + config_dict["deployment"]["args"] = server_args_override + + # Create new config from modified dict + new_config = FastMCPConfig(**config_dict) + return new_config, resolved_spec + + LOGO_ASCII = r""" _ __ ___ _____ __ __ _____________ ____ ____ _ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \ diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py index 4fb197cbc..01cd1b846 100644 --- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py +++ b/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py @@ -63,16 +63,16 @@ class Environment(BaseModel): examples=[[".", "../my-package"], ["/path/to/package"]], ) - def build_uv_args(self, command: str | list[str] | None = None) -> list[str]: - """Build uv run arguments from this environment configuration. + def build_uv_run_command(self, command: list[str]) -> list[str]: + """Build complete uv run command with environment args and command to execute. Args: - command: Optional command to append (string or list of args) + command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) Returns: - List of arguments for uv run command + Complete command ready for subprocess.run, including "uv" prefix """ - args = ["run"] + args = ["uv", "run"] # Add project if specified if self.project: @@ -97,12 +97,8 @@ class Environment(BaseModel): for editable_path in self.editable: args.extend(["--with-editable", str(editable_path)]) - # Add the command if provided - if command: - if isinstance(command, str): - args.append(command) - else: - args.extend(command) + # Add the command + args.extend(command) return args @@ -116,14 +112,16 @@ class Environment(BaseModel): import sys # Build the full uv command - uv_args = self.build_uv_args(command) - cmd = ["uv"] + uv_args + cmd = self.build_uv_run_command(command) + + # Set marker to prevent infinite loops when subprocess calls FastMCP again + env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} logger.debug(f"Running command: {' '.join(cmd)}") try: # Run without capturing output so it flows through naturally - process = subprocess.run(cmd, check=True) + process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error(f"Command failed: {e}") diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 094b9bc52..ec8b413c1 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -92,8 +92,8 @@ class TestEnvironment: ) assert config.environment.needs_uv() - def test_build_uv_args(self): - """Test build_uv_args() method.""" + def test_build_uv_run_command(self): + """Test build_uv_run_command() method.""" config = FastMCPConfig( source={"path": "server.py"}, environment={ @@ -104,23 +104,24 @@ class TestEnvironment: }, ) - args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) - assert args[0] == "run" + assert cmd[0] == "uv" + assert cmd[1] == "run" # Python version not added when project is specified (project defines its own Python) - assert "--python" not in args - assert "3.12" not in args - assert "--project" in args - assert "." in args - assert "--with" in args - assert "requests" in args - assert "numpy" in args - assert "--with-requirements" in args - assert "requirements.txt" in args + assert "--python" not in cmd + assert "3.12" not in cmd + assert "--project" in cmd + assert "." in cmd + assert "--with" in cmd + assert "requests" in cmd + assert "numpy" in cmd + assert "--with-requirements" in cmd + assert "requirements.txt" in cmd # Command args should be at the end - assert "fastmcp" in args[-3:] - assert "run" in args[-2:] - assert "server.py" in args[-1:] + assert "fastmcp" in cmd[-3:] + assert "run" in cmd[-2:] + assert "server.py" in cmd[-1:] def test_run_with_uv(self): """Test run_with_uv() subprocess execution.""" diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_fastmcp_config_integration.py index 98070385f..800b23b08 100644 --- a/tests/cli/test_fastmcp_config_integration.py +++ b/tests/cli/test_fastmcp_config_integration.py @@ -227,14 +227,14 @@ class TestPathResolution: environment={"requirements": "requirements.txt"}, # type: ignore[arg-type] ) - # Build UV args + # Build UV command assert config.environment is not None - uv_args = config.environment.build_uv_args(["fastmcp", "run"]) + uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run"]) # Should include requirements file - assert "--with-requirements" in uv_args - req_idx = uv_args.index("--with-requirements") + 1 - assert uv_args[req_idx] == "requirements.txt" + assert "--with-requirements" in uv_cmd + req_idx = uv_cmd.index("--with-requirements") + 1 + assert uv_cmd[req_idx] == "requirements.txt" class TestConfigValidation: diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py index 5ef7c26e1..07443414f 100644 --- a/tests/cli/test_run_config.py +++ b/tests/cli/test_run_config.py @@ -261,13 +261,11 @@ def test_environment_config_path_resolution(tmp_path): config = load_fastmcp_config(config_file) - # Check that UV args are built with resolved paths - uv_args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) + # Check that UV command is built with resolved paths + uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"]) - assert "--with-requirements" in uv_args - assert "--project" in uv_args + assert "--with-requirements" in uv_cmd + assert "--project" in uv_cmd # Path should be resolved relative to config file - req_idx = uv_args.index("--with-requirements") + 1 - assert ( - Path(uv_args[req_idx]).is_absolute() or uv_args[req_idx] == "requirements.txt" - ) + req_idx = uv_cmd.index("--with-requirements") + 1 + assert Path(uv_cmd[req_idx]).is_absolute() or uv_cmd[req_idx] == "requirements.txt" diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index 027939191..446b21787 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -25,16 +25,18 @@ class TestRunWithUv: # Check the command that was called mock_run.assert_called_once() cmd = mock_run.call_args[0][0] + env = mock_run.call_args.kwargs.get("env", {}) expected = [ "uv", "run", "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected + # Check that the environment marker is set + assert env.get("FASTMCP_UV_SPAWNED") == "1" @patch("subprocess.run") def test_run_with_uv_python_version(self, mock_run): @@ -54,7 +56,6 @@ class TestRunWithUv: "3.11", "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -80,7 +81,6 @@ class TestRunWithUv: assert cmd[4:] == [ "fastmcp", "run", - "--skip-env", "server.py", ] @@ -104,7 +104,6 @@ class TestRunWithUv: "numpy", # original order preserved "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -128,7 +127,6 @@ class TestRunWithUv: str(req_path.resolve()), # auto-resolved to absolute path "fastmcp", "run", - "--skip-env", "server.py", ] assert cmd == expected @@ -157,7 +155,6 @@ class TestRunWithUv: "run", "fastmcp", "run", - "--skip-env", "server.py", "--transport", "http", @@ -220,7 +217,6 @@ class TestRunWithUv: assert cmd[next_idx:] == [ "fastmcp", "run", - "--skip-env", "server.py", "--transport", "http", diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py index bcb45f664..cd8b55dcb 100644 --- a/tests/utilities/test_cli.py +++ b/tests/utilities/test_cli.py @@ -3,22 +3,23 @@ from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment -class TestEnvironmentBuildUVArgs: - """Test the Environment.build_uv_args() method.""" +class TestEnvironmentBuildUVRunCommand: + """Test the Environment.build_uv_run_command() method.""" - def test_build_uv_args_basic(self): - """Test building basic uv args with no environment config.""" + def test_build_uv_run_command_basic(self): + """Test building basic uv command with no environment config.""" env = Environment() - args = env.build_uv_args(["fastmcp", "run", "server.py"]) - expected = ["run", "fastmcp", "run", "server.py"] - assert args == expected + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + expected = ["uv", "run", "fastmcp", "run", "server.py"] + assert cmd == expected - def test_build_uv_args_with_editable(self): - """Test building uv args with editable package.""" + def test_build_uv_run_command_with_editable(self): + """Test building uv command with editable package.""" editable_path = "/path/to/package" env = Environment(editable=[editable_path]) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with-editable", editable_path, @@ -26,13 +27,14 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_packages(self): - """Test building uv args with additional packages.""" + def test_build_uv_run_command_with_packages(self): + """Test building uv command with additional packages.""" env = Environment(dependencies=["pkg1", "pkg2"]) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with", "pkg1", @@ -42,13 +44,14 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_python_version(self): - """Test building uv args with Python version.""" + def test_build_uv_run_command_with_python_version(self): + """Test building uv command with Python version.""" env = Environment(python="3.10") - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--python", "3.10", @@ -56,14 +59,15 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_requirements(self): - """Test building uv args with requirements file.""" + def test_build_uv_run_command_with_requirements(self): + """Test building uv command with requirements file.""" requirements_path = "/path/to/requirements.txt" env = Environment(requirements=requirements_path) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--with-requirements", requirements_path, @@ -71,18 +75,26 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_with_project(self): - """Test building uv args with project directory.""" + def test_build_uv_run_command_with_project(self): + """Test building uv command with project directory.""" project_path = "/path/to/project" env = Environment(project=project_path) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) - expected = ["run", "--project", project_path, "fastmcp", "run", "server.py"] - assert args == expected + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) + expected = [ + "uv", + "run", + "--project", + project_path, + "fastmcp", + "run", + "server.py", + ] + assert cmd == expected - def test_build_uv_args_with_everything(self): - """Test building uv args with all options.""" + def test_build_uv_run_command_with_everything(self): + """Test building uv command with all options.""" requirements_path = "/path/to/requirements.txt" editable_path = "/local/pkg" env = Environment( @@ -91,8 +103,9 @@ class TestEnvironmentBuildUVArgs: requirements=requirements_path, editable=[editable_path], ) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--python", "3.10", @@ -108,23 +121,12 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected - def test_build_uv_args_no_command(self): - """Test building uv args without command.""" - env = Environment(dependencies=["pkg1"]) - args = env.build_uv_args() - expected = ["run", "--with", "pkg1"] - assert args == expected + # Note: These tests are removed because build_uv_run_command now requires a command + # and only accepts a list, not optional or string commands - def test_build_uv_args_with_string_command(self): - """Test building uv args with string command.""" - env = Environment() - args = env.build_uv_args("python") - expected = ["run", "python"] - assert args == expected - - def test_build_uv_args_project_with_extras(self): + def test_build_uv_run_command_project_with_extras(self): """Test that project flag works with additional dependencies.""" project_path = "/path/to/project" env = Environment( @@ -133,8 +135,9 @@ class TestEnvironmentBuildUVArgs: dependencies=["pandas"], # Should be added on top of project editable=["/pkg"], # Should be added on top of project ) - args = env.build_uv_args(["fastmcp", "run", "server.py"]) + cmd = env.build_uv_run_command(["fastmcp", "run", "server.py"]) expected = [ + "uv", "run", "--project", project_path, @@ -146,7 +149,7 @@ class TestEnvironmentBuildUVArgs: "run", "server.py", ] - assert args == expected + assert cmd == expected class TestEnvironmentNeedsUV: