mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Clean up uvenvironment and ensure uv run --with fastmcp is used by install commands (#1769)
This commit is contained in:
parent
7fbe3ee39b
commit
66da6d2f6a
16 changed files with 47 additions and 155 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ class TestRunWithUv:
|
|||
"uv",
|
||||
"run",
|
||||
"--with",
|
||||
"pandas", # original order preserved
|
||||
"numpy",
|
||||
"--with",
|
||||
"numpy", # original order preserved
|
||||
"pandas",
|
||||
"fastmcp",
|
||||
"run",
|
||||
"server.py",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue