Consolidate CLI config parsing and prevent infinite loops (#1660)

This commit is contained in:
Jeremiah Lowin 2025-08-29 09:43:39 -04:00 committed by GitHub
commit 536ccde9e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 396 additions and 491 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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

View file

@ -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 {},
)

View file

@ -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 {},
)

View file

@ -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

View file

@ -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]")

View file

@ -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

View file

@ -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"""
_ __ ___ _____ __ __ _____________ ____ ____
_ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \

View file

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

View file

@ -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."""

View file

@ -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:

View file

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

View file

@ -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",

View file

@ -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: