Clean up uvenvironment and ensure uv run --with fastmcp is used by install commands (#1769)

This commit is contained in:
Jeremiah Lowin 2025-09-06 14:21:56 -04:00 committed by GitHub
commit 66da6d2f6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 47 additions and 155 deletions

View file

@ -90,17 +90,8 @@ def update_claude_config(
else:
env_vars = existing_env
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
# Build uv run command using Environment.build_uv_run_command()
env_config = UVEnvironment(
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
editable=[str(p) for p in with_editable] if with_editable else None,
)

View file

@ -20,6 +20,7 @@ import fastmcp
from fastmcp.cli import run as run_module
from fastmcp.cli.install import install_app
from fastmcp.server.server import FastMCP
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
from fastmcp.utilities.inspect import (
InspectFormat,
format_info,
@ -193,7 +194,6 @@ async def dev(
Args:
server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json
"""
from fastmcp.utilities.cli import load_and_merge_config
try:
# Load config and apply CLI overrides
@ -415,7 +415,6 @@ async def run(
Args:
server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect
"""
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
# Check if we were spawned by uv (or user explicitly set --skip-env)
if skip_env or is_already_in_uv_subprocess():
@ -472,12 +471,16 @@ async def run(
server_spec=server_spec,
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,
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,
@ -598,7 +601,6 @@ async def inspect(
Args:
server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json
"""
from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
# Check if we were spawned by uv (or user explicitly set --skip-env)
if skip_env or is_already_in_uv_subprocess():

View file

@ -107,18 +107,9 @@ def install_claude_code(
)
return False
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
# Build uv run command using Environment.build_uv_run_command()
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
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,

View file

@ -73,17 +73,9 @@ def install_claude_desktop(
config_file = config_dir / "claude_desktop_config.json"
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
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,

View file

@ -107,17 +107,9 @@ def install_cursor_workspace(
config_file = cursor_dir / "mcp.json"
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements.resolve()) if with_requirements else None,
project=str(project.resolve()) if project else None,
editable=[str(p.resolve()) for p in with_editable] if with_editable else None,
@ -185,17 +177,9 @@ def install_cursor(
True if installation was successful, False otherwise
"""
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
requirements=str(with_requirements.resolve()) if with_requirements else None,
project=str(project.resolve()) if project else None,
editable=[str(p.resolve()) for p in with_editable] if with_editable else None,

View file

@ -104,18 +104,10 @@ def install_gemini_cli(
)
return False
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
# Build uv run command using Environment.build_uv_run_command()
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
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,

View file

@ -48,17 +48,9 @@ def install_mcp_json(
True if generation was successful, False otherwise
"""
try:
# Deduplicate packages and exclude 'fastmcp' since Environment adds it automatically
deduplicated_packages = None
if with_packages:
deduplicated = list(dict.fromkeys(with_packages))
deduplicated_packages = [pkg for pkg in deduplicated if pkg != "fastmcp"]
if not deduplicated_packages:
deduplicated_packages = None
env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
dependencies=(with_packages or []) + ["fastmcp"],
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,

View file

@ -66,7 +66,6 @@ def run_with_uv(
editable: Editable package paths
"""
# Build uv command using Environment.build_uv_run_command()
env_config = UVEnvironment(
python=python_version,
dependencies=with_packages if with_packages else None,

View file

@ -609,7 +609,7 @@ class UvStdioTransport(StdioTransport):
uv_args: list[str] = []
# Check if we need any environment setup
if env_config.needs_uv():
if env_config._must_run_with_uv():
# Use the config to build args, but we need to handle the command differently
# since transport has specific needs
uv_args = ["run"]

View file

@ -1,7 +1,5 @@
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Literal
@ -59,7 +57,7 @@ class UVEnvironment(Environment):
If no environment configuration is set, returns the command unchanged.
"""
# If no environment setup is needed, return command as-is
if not self._needs_setup():
if not self._must_run_with_uv():
return command
args = ["uv", "run"]
@ -75,7 +73,7 @@ class UVEnvironment(Environment):
# Always add dependencies, requirements, and editable packages
# These work with --project to add additional packages on top of the project env
if self.dependencies:
for dep in self.dependencies:
for dep in sorted(set(self.dependencies)):
args.extend(["--with", dep])
# Add requirements file
@ -92,31 +90,7 @@ class UVEnvironment(Environment):
return args
def run_with_uv(self, command: list[str]) -> None:
"""Execute a command using uv run with this environment configuration.
Args:
command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
"""
import subprocess
# Build the full uv command
cmd = self.build_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, env=env)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
logger.error(f"Command failed: {e}")
sys.exit(e.returncode)
def _needs_setup(self) -> bool:
def _must_run_with_uv(self) -> bool:
"""Check if this environment config requires uv to set up.
Returns:
@ -132,15 +106,6 @@ class UVEnvironment(Environment):
]
)
# Backward compatibility aliases
def needs_uv(self) -> bool:
"""Deprecated: Use _needs_setup() internally or check if build_command modifies the command."""
return self._needs_setup()
def build_uv_run_command(self, command: list[str]) -> list[str]:
"""Deprecated: Use build_command() instead."""
return self.build_command(command)
async def prepare(self, output_dir: Path | None = None) -> None:
"""Prepare the Python environment using uv.
@ -157,7 +122,7 @@ class UVEnvironment(Environment):
)
# Only prepare environment if there are actual settings to apply
if not self._needs_setup():
if not self._must_run_with_uv():
logger.debug("No environment settings configured, skipping preparation")
return

View file

@ -74,23 +74,23 @@ class TestEnvironment:
"""Test needs_uv() method."""
# No environment config - doesn't need UV
config = MCPServerConfig(source={"path": "server.py"})
assert not config.environment.needs_uv()
assert not config.environment._must_run_with_uv()
# Empty environment - doesn't need UV
config = MCPServerConfig(source={"path": "server.py"}, environment={})
assert not config.environment.needs_uv()
assert not config.environment._must_run_with_uv()
# With dependencies - needs UV
config = MCPServerConfig(
source={"path": "server.py"}, environment={"dependencies": ["requests"]}
)
assert config.environment.needs_uv()
assert config.environment._must_run_with_uv()
# With Python version - needs UV
config = MCPServerConfig(
source={"path": "server.py"}, environment={"python": "3.12"}
)
assert config.environment.needs_uv()
assert config.environment._must_run_with_uv()
def test_build_uv_run_command(self):
"""Test build_uv_run_command() method."""
@ -123,21 +123,6 @@ class TestEnvironment:
assert "run" in cmd[-2:]
assert "server.py" in cmd[-1:]
def test_run_with_uv(self):
"""Test run_with_uv() subprocess execution."""
config = MCPServerConfig(
source={"path": "server.py"}, environment={"dependencies": ["requests"]}
)
# run_with_uv calls sys.exit, so we expect SystemExit
with pytest.raises(SystemExit) as exc_info:
# This will fail because we're running exit(1)
# but it tests that the subprocess is called correctly
config.environment.run_with_uv(["python", "-c", "exit(1)"])
# Check that it exited with code 1
assert exc_info.value.code == 1
class TestDeployment:
"""Test Deployment class."""
@ -266,7 +251,7 @@ class TestMCPServerConfig:
assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
# Check they have no values set
assert not config.environment.needs_uv()
assert not config.environment._must_run_with_uv()
assert all(
getattr(config.deployment, field, None) is None
for field in Deployment.model_fields

View file

@ -299,10 +299,9 @@ class TestInstallCursor:
# Count occurrences of each package
args_str = " ".join(config_data["args"])
assert args_str.count("numpy") == 1
assert args_str.count("pandas") == 1
# fastmcp appears once in the command only (no longer automatically added as --with)
assert args_str.count("fastmcp") == 1
assert args_str.count("--with numpy") == 1
assert args_str.count("--with pandas") == 1
assert args_str.count("--with fastmcp") == 1
class TestCursorCommand:

View file

@ -138,7 +138,7 @@ class TestEnvironmentExecution:
)
assert config.environment is not None
assert config.environment.needs_uv()
assert config.environment._must_run_with_uv()
def test_needs_uv_with_python_version(self):
"""Test that environment with Python version needs UV."""
@ -148,7 +148,7 @@ class TestEnvironmentExecution:
)
assert config.environment is not None
assert config.environment.needs_uv()
assert config.environment._must_run_with_uv()
def test_no_uv_needed_without_environment(self):
"""Test that no UV is needed without environment config."""
@ -156,7 +156,7 @@ class TestEnvironmentExecution:
# Environment is now always present but may be empty
assert config.environment is not None
assert not config.environment.needs_uv()
assert not config.environment._must_run_with_uv()
def test_no_uv_needed_with_empty_environment(self):
"""Test that no UV is needed with empty environment config."""
@ -166,7 +166,7 @@ class TestEnvironmentExecution:
)
assert config.environment is not None
assert not config.environment.needs_uv()
assert not config.environment._must_run_with_uv()
class TestPathResolution:

View file

@ -230,7 +230,7 @@ def test_config_subset_independence(tmp_path):
assert config.environment.python == "3.12"
assert config.environment.dependencies == ["pandas"]
assert config.environment.needs_uv() # Has dependencies
assert config.environment._must_run_with_uv() # Has dependencies
assert config.deployment.transport == "http"
assert config.deployment.host == "0.0.0.0"

View file

@ -98,9 +98,9 @@ class TestRunWithUv:
"uv",
"run",
"--with",
"pandas", # original order preserved
"numpy",
"--with",
"numpy", # original order preserved
"pandas",
"fastmcp",
"run",
"server.py",

View file

@ -111,9 +111,9 @@ class TestEnvironmentBuildUVRunCommand:
"--python",
"3.10",
"--with",
"pandas",
"--with",
"numpy",
"--with",
"pandas",
"--with-requirements",
requirements_path,
"--with-editable",
@ -159,34 +159,34 @@ class TestEnvironmentNeedsUV:
def test_needs_uv_with_python(self):
"""Test that needs_uv returns True with Python version."""
env = UVEnvironment(python="3.10")
assert env.needs_uv() is True
assert env._must_run_with_uv() is True
def test_needs_uv_with_dependencies(self):
"""Test that needs_uv returns True with dependencies."""
env = UVEnvironment(dependencies=["pandas"])
assert env.needs_uv() is True
assert env._must_run_with_uv() is True
def test_needs_uv_with_requirements(self):
"""Test that needs_uv returns True with requirements."""
env = UVEnvironment(requirements="/path/to/requirements.txt")
assert env.needs_uv() is True
assert env._must_run_with_uv() is True
def test_needs_uv_with_project(self):
"""Test that needs_uv returns True with project."""
env = UVEnvironment(project="/path/to/project")
assert env.needs_uv() is True
assert env._must_run_with_uv() is True
def test_needs_uv_with_editable(self):
"""Test that needs_uv returns True with editable."""
env = UVEnvironment(editable=["/pkg"])
assert env.needs_uv() is True
assert env._must_run_with_uv() is True
def test_needs_uv_empty(self):
"""Test that needs_uv returns False with empty config."""
env = UVEnvironment()
assert env.needs_uv() is False
assert env._must_run_with_uv() is False
def test_needs_uv_with_empty_lists(self):
"""Test that needs_uv returns False with empty lists."""
env = UVEnvironment(dependencies=None, editable=None)
assert env.needs_uv() is False
assert env._must_run_with_uv() is False