Refactor Environment to support multiple runtime types (#1673)

This commit is contained in:
Jeremiah Lowin 2025-08-29 22:29:35 -04:00 committed by GitHub
commit ea47d232bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 480 additions and 407 deletions

View file

@ -9,9 +9,9 @@ from pydantic import ValidationError
from fastmcp.utilities.mcp_server_config import (
Deployment,
Environment,
MCPServerConfig,
)
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
@ -104,7 +104,7 @@ class TestEnvironment:
},
)
cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"])
cmd = config.environment.build_command(["fastmcp", "run", "server.py"])
assert cmd[0] == "uv"
assert cmd[1] == "run"
@ -263,7 +263,7 @@ class TestMCPServerConfig:
assert config.source.path == "server.py"
assert config.source.entrypoint is None
# Environment and deployment are now always present but empty
assert isinstance(config.environment, Environment)
assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
# Check they have no values set
assert not config.environment.needs_uv()
@ -289,7 +289,7 @@ class TestMCPServerConfig:
assert isinstance(config.source, FileSystemSource)
assert config.source.path == "server.py"
assert config.source.entrypoint is None
assert isinstance(config.environment, Environment)
assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
def test_from_file(self, tmp_path):
@ -416,7 +416,7 @@ class TestMCPServerConfig:
assert isinstance(config.source, FileSystemSource)
assert config.source.path == "server.py"
# Environment and deployment are now always present but may be empty
assert isinstance(config.environment, Environment)
assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
# Only environment with values
@ -434,9 +434,9 @@ class TestMCPServerConfig:
config = MCPServerConfig(
source={"path": "server.py"}, deployment={"transport": "http"}
)
assert isinstance(config.environment, Environment)
assert isinstance(config.environment, UVEnvironment)
assert all(
getattr(config.environment, field, None) is None
for field in Environment.model_fields
for field in UVEnvironment.model_fields
)
assert config.deployment.transport == "http"

View file

@ -229,7 +229,7 @@ class TestPathResolution:
# Build UV command
assert config.environment is not None
uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run"])
uv_cmd = config.environment.build_command(["fastmcp", "run"])
# Should include requirements file
assert "--with-requirements" in uv_cmd

View file

@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastmcp.utilities.mcp_server_config import Environment, MCPServerConfig
from fastmcp.utilities.mcp_server_config import MCPServerConfig
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
@ -25,7 +26,7 @@ class TestMCPServerConfigPrepare:
"""Test that prepare() calls both prepare_environment and prepare_source."""
config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
environment=Environment(python="3.10"),
environment=UVEnvironment(python="3.10"),
)
await config.prepare()
@ -45,7 +46,7 @@ class TestMCPServerConfigPrepare:
"""Test that prepare() with output_dir calls prepare_environment with it."""
config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
environment=Environment(python="3.10"),
environment=UVEnvironment(python="3.10"),
)
output_path = Path("/tmp/test-env")
@ -66,7 +67,7 @@ class TestMCPServerConfigPrepare:
"""Test that prepare() skips source when skip_source=True."""
config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
environment=Environment(python="3.10"),
environment=UVEnvironment(python="3.10"),
)
await config.prepare(skip_source=True)
@ -79,7 +80,7 @@ class TestMCPServerConfigPrepare:
new_callable=AsyncMock,
)
@patch(
"fastmcp.utilities.mcp_server_config.v1.mcp_server_config.Environment.prepare",
"fastmcp.utilities.mcp_server_config.v1.environments.uv.UVEnvironment.prepare",
new_callable=AsyncMock,
)
async def test_prepare_no_environment_settings(self, mock_env_prepare, mock_src):
@ -104,7 +105,7 @@ class TestEnvironmentPrepare:
"""Test that prepare() raises error when uv is not installed."""
mock_which.return_value = None
env = Environment(python="3.10")
env = UVEnvironment(python="3.10")
with pytest.raises(RuntimeError, match="uv is not installed"):
await env.prepare(tmp_path / "test-env")
@ -115,7 +116,7 @@ class TestEnvironmentPrepare:
"""Test that prepare() does nothing when no settings are configured."""
mock_which.return_value = "/usr/bin/uv"
env = Environment() # No settings
env = UVEnvironment() # No settings
await env.prepare(tmp_path / "test-env")
@ -131,7 +132,7 @@ class TestEnvironmentPrepare:
returncode=0, stdout="Environment cached", stderr=""
)
env = Environment(python="3.10")
env = UVEnvironment(python="3.10")
await env.prepare(tmp_path / "test-env")
@ -150,7 +151,7 @@ class TestEnvironmentPrepare:
mock_which.return_value = "/usr/bin/uv"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
env = Environment(dependencies=["numpy", "pandas"])
env = UVEnvironment(dependencies=["numpy", "pandas"])
await env.prepare(tmp_path / "test-env")
@ -179,7 +180,7 @@ class TestEnvironmentPrepare:
1, ["uv"], stderr="Package not found"
)
env = Environment(python="3.10")
env = UVEnvironment(python="3.10")
with pytest.raises(RuntimeError, match="Failed to initialize project"):
await env.prepare(tmp_path / "test-env")

View file

@ -9,9 +9,9 @@ import pytest
from fastmcp.cli.run import load_mcp_server_config
from fastmcp.utilities.mcp_server_config import (
Deployment,
Environment,
MCPServerConfig,
)
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
@ -56,7 +56,7 @@ def test_load_mcp_server_config(sample_config, monkeypatch):
assert isinstance(config, MCPServerConfig)
assert isinstance(config.source, FileSystemSource)
assert isinstance(config.deployment, Deployment)
assert isinstance(config.environment, Environment)
assert isinstance(config.environment, UVEnvironment)
# Check source - path is not resolved yet, only during load_server
assert config.source.path == "server.py"
@ -262,7 +262,7 @@ def test_environment_config_path_resolution(tmp_path):
config = load_mcp_server_config(config_file)
# Check that UV command is built with resolved paths
uv_cmd = config.environment.build_uv_run_command(["fastmcp", "run", "server.py"])
uv_cmd = config.environment.build_command(["fastmcp", "run", "server.py"])
assert "--with-requirements" in uv_cmd
assert "--project" in uv_cmd

View file

@ -27,9 +27,8 @@ class TestRunWithUv:
cmd = mock_run.call_args[0][0]
env = mock_run.call_args.kwargs.get("env", {})
# With no environment config, the command should be returned unchanged
expected = [
"uv",
"run",
"fastmcp",
"run",
"server.py",
@ -150,9 +149,8 @@ class TestRunWithUv:
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
# With no environment config, no uv run prefix
expected = [
"uv",
"run",
"fastmcp",
"run",
"server.py",