mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Consolidate CLI config parsing and prevent infinite loops (#1660)
This commit is contained in:
parent
f7d2caf83c
commit
536ccde9e8
15 changed files with 396 additions and 491 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
_ __ ___ _____ __ __ _____________ ____ ____
|
||||
_ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue