60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
462 lines
17 KiB
Python
462 lines
17 KiB
Python
"""Tests for FastMCP configuration file support with nested structure."""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from fastmcp.utilities.mcp_server_config import (
|
|
Deployment,
|
|
MCPServerConfig,
|
|
)
|
|
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
|
|
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
|
|
|
|
|
|
class TestFileSystemSource:
|
|
"""Test FileSystemSource class."""
|
|
|
|
def test_dict_source_minimal(self):
|
|
"""Test that dict source is converted to FileSystemSource."""
|
|
config = MCPServerConfig(source={"path": "server.py"})
|
|
# Dict is converted to FileSystemSource
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "server.py"
|
|
assert config.source.entrypoint is None
|
|
assert config.source.type == "filesystem"
|
|
|
|
def test_dict_source_with_entrypoint(self):
|
|
"""Test dict source with entrypoint field."""
|
|
config = MCPServerConfig(source={"path": "server.py", "entrypoint": "app"})
|
|
# Dict with entrypoint is converted to FileSystemSource
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "server.py"
|
|
assert config.source.entrypoint == "app"
|
|
assert config.source.type == "filesystem"
|
|
|
|
def test_filesystem_source_entrypoint(self):
|
|
"""Test FileSystemSource entrypoint format."""
|
|
config = MCPServerConfig(
|
|
source=FileSystemSource(path="src/server.py", entrypoint="mcp")
|
|
)
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "src/server.py"
|
|
assert config.source.entrypoint == "mcp"
|
|
assert config.source.type == "filesystem"
|
|
|
|
|
|
class TestEnvironment:
|
|
"""Test Environment class."""
|
|
|
|
def test_environment_config_fields(self):
|
|
"""Test all Environment fields."""
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
environment={
|
|
"python": "3.12",
|
|
"dependencies": ["requests", "numpy>=2.0"],
|
|
"requirements": "requirements.txt",
|
|
"project": ".",
|
|
"editable": ["../my-package"],
|
|
},
|
|
)
|
|
|
|
env = config.environment
|
|
assert env.python == "3.12"
|
|
assert env.dependencies == ["requests", "numpy>=2.0"]
|
|
# Paths are stored as Path objects
|
|
assert env.requirements == Path("requirements.txt")
|
|
assert env.project == Path(".")
|
|
assert env.editable == [Path("../my-package")]
|
|
|
|
def test_needs_uv(self):
|
|
"""Test needs_uv() method."""
|
|
# No environment config - doesn't need UV
|
|
config = MCPServerConfig(source={"path": "server.py"})
|
|
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._must_run_with_uv()
|
|
|
|
# With dependencies - needs UV
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"}, environment={"dependencies": ["requests"]}
|
|
)
|
|
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._must_run_with_uv()
|
|
|
|
def test_build_uv_run_command(self):
|
|
"""Test build_uv_run_command() method."""
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
environment={
|
|
"python": "3.12",
|
|
"dependencies": ["requests", "numpy"],
|
|
"requirements": "requirements.txt",
|
|
"project": ".",
|
|
},
|
|
)
|
|
|
|
cmd = config.environment.build_command(["fastmcp", "run", "server.py"])
|
|
|
|
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 cmd
|
|
assert "3.12" not in cmd
|
|
assert "--project" in cmd
|
|
# Project path should be resolved to absolute path
|
|
project_idx = cmd.index("--project")
|
|
assert Path(cmd[project_idx + 1]).is_absolute()
|
|
assert "--with" in cmd
|
|
assert "requests" in cmd
|
|
assert "numpy" in cmd
|
|
assert "--with-requirements" in cmd
|
|
# Requirements path should be resolved to absolute path
|
|
req_idx = cmd.index("--with-requirements")
|
|
assert Path(cmd[req_idx + 1]).is_absolute()
|
|
# Command args should be at the end
|
|
assert "fastmcp" in cmd[-3:]
|
|
assert "run" in cmd[-2:]
|
|
assert "server.py" in cmd[-1:]
|
|
|
|
|
|
class TestDeployment:
|
|
"""Test Deployment class."""
|
|
|
|
def test_deployment_config_fields(self):
|
|
"""Test all Deployment fields."""
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
deployment={
|
|
"transport": "http",
|
|
"host": "0.0.0.0",
|
|
"port": 8000,
|
|
"path": "/api/",
|
|
"log_level": "DEBUG",
|
|
"env": {"API_KEY": "secret"},
|
|
"cwd": "./work",
|
|
"args": ["--debug"],
|
|
},
|
|
)
|
|
|
|
deploy = config.deployment
|
|
assert deploy.transport == "http"
|
|
assert deploy.host == "0.0.0.0"
|
|
assert deploy.port == 8000
|
|
assert deploy.path == "/api/"
|
|
assert deploy.log_level == "DEBUG"
|
|
assert deploy.env == {"API_KEY": "secret"}
|
|
assert deploy.cwd == "./work"
|
|
assert deploy.args == ["--debug"]
|
|
|
|
def test_apply_runtime_settings(self, tmp_path):
|
|
"""Test apply_runtime_settings() method."""
|
|
import os
|
|
|
|
# Create config with env vars and cwd
|
|
work_dir = tmp_path / "work"
|
|
work_dir.mkdir()
|
|
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
deployment={
|
|
"env": {"TEST_VAR": "test_value"},
|
|
"cwd": "work",
|
|
},
|
|
)
|
|
|
|
original_cwd = os.getcwd()
|
|
original_env = os.environ.get("TEST_VAR")
|
|
|
|
try:
|
|
config.deployment.apply_runtime_settings(tmp_path / "fastmcp.json")
|
|
|
|
# Check environment variable was set
|
|
assert os.environ["TEST_VAR"] == "test_value"
|
|
|
|
# Check working directory was changed
|
|
assert Path.cwd() == work_dir.resolve()
|
|
|
|
finally:
|
|
# Restore original state
|
|
os.chdir(original_cwd)
|
|
if original_env is None:
|
|
os.environ.pop("TEST_VAR", None)
|
|
else:
|
|
os.environ["TEST_VAR"] = original_env
|
|
|
|
def test_env_var_interpolation(self, tmp_path):
|
|
"""Test environment variable interpolation in deployment env."""
|
|
import os
|
|
|
|
# Set up test environment variables
|
|
os.environ["BASE_URL"] = "example.com"
|
|
os.environ["ENV_NAME"] = "production"
|
|
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
deployment={
|
|
"env": {
|
|
"API_URL": "https://api.${BASE_URL}/v1",
|
|
"DATABASE": "postgres://${ENV_NAME}.db",
|
|
"PREFIXED": "MY_${ENV_NAME}_SERVER",
|
|
"MISSING": "value_${NONEXISTENT}_here",
|
|
"STATIC": "no_interpolation",
|
|
}
|
|
},
|
|
)
|
|
|
|
original_values = {
|
|
key: os.environ.get(key)
|
|
for key in ["API_URL", "DATABASE", "PREFIXED", "MISSING", "STATIC"]
|
|
}
|
|
|
|
try:
|
|
config.deployment.apply_runtime_settings()
|
|
|
|
# Check interpolated values
|
|
assert os.environ["API_URL"] == "https://api.example.com/v1"
|
|
assert os.environ["DATABASE"] == "postgres://production.db"
|
|
assert os.environ["PREFIXED"] == "MY_production_SERVER"
|
|
# Missing variables should keep the placeholder
|
|
assert os.environ["MISSING"] == "value_${NONEXISTENT}_here"
|
|
# Static values should remain unchanged
|
|
assert os.environ["STATIC"] == "no_interpolation"
|
|
|
|
finally:
|
|
# Clean up
|
|
os.environ.pop("BASE_URL", None)
|
|
os.environ.pop("ENV_NAME", None)
|
|
for key, value in original_values.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
|
|
|
|
class TestMCPServerConfig:
|
|
"""Test MCPServerConfig root configuration."""
|
|
|
|
def test_minimal_config(self):
|
|
"""Test creating a config with only required fields."""
|
|
config = MCPServerConfig(source={"path": "server.py"})
|
|
assert isinstance(config.source, FileSystemSource)
|
|
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, UVEnvironment)
|
|
assert isinstance(config.deployment, Deployment)
|
|
# Check they have no values set
|
|
assert not config.environment._must_run_with_uv()
|
|
assert all(
|
|
getattr(config.deployment, field, None) is None
|
|
for field in Deployment.model_fields
|
|
)
|
|
|
|
def test_nested_structure(self):
|
|
"""Test the nested configuration structure."""
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"},
|
|
environment={
|
|
"python": "3.12",
|
|
"dependencies": ["fastmcp"],
|
|
},
|
|
deployment={
|
|
"transport": "stdio",
|
|
"log_level": "INFO",
|
|
},
|
|
)
|
|
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "server.py"
|
|
assert config.source.entrypoint is None
|
|
assert isinstance(config.environment, UVEnvironment)
|
|
assert isinstance(config.deployment, Deployment)
|
|
|
|
def test_from_file(self, tmp_path):
|
|
"""Test loading config from JSON file with nested structure."""
|
|
config_data = {
|
|
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
|
|
"source": {"path": "src/server.py", "entrypoint": "app"},
|
|
"environment": {"python": "3.12", "dependencies": ["requests"]},
|
|
"deployment": {"transport": "http", "port": 8000},
|
|
}
|
|
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
config = MCPServerConfig.from_file(config_file)
|
|
|
|
# When loaded from JSON with entrypoint format, it becomes EntrypointConfig
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "src/server.py"
|
|
assert config.source.entrypoint == "app"
|
|
assert config.environment.python == "3.12"
|
|
assert config.environment.dependencies == ["requests"]
|
|
assert config.deployment.transport == "http"
|
|
assert config.deployment.port == 8000
|
|
|
|
def test_from_file_with_string_entrypoint(self, tmp_path):
|
|
"""Test loading config with dict source format."""
|
|
config_data = {
|
|
"source": {"path": "server.py", "entrypoint": "mcp"},
|
|
"environment": {"dependencies": ["fastmcp"]},
|
|
}
|
|
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
config = MCPServerConfig.from_file(config_file)
|
|
# String entrypoint with : should be converted to EntrypointConfig
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "server.py"
|
|
assert config.source.entrypoint == "mcp"
|
|
|
|
def test_string_entrypoint_with_entrypoint_and_environment(self, tmp_path):
|
|
"""Test that file.py:entrypoint syntax works with environment config."""
|
|
config_data = {
|
|
"source": {"path": "src/server.py", "entrypoint": "app"},
|
|
"environment": {"python": "3.12", "dependencies": ["fastmcp", "requests"]},
|
|
"deployment": {"transport": "http", "port": 8000},
|
|
}
|
|
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
config = MCPServerConfig.from_file(config_file)
|
|
|
|
# Should be parsed into EntrypointConfig
|
|
assert isinstance(config.source, FileSystemSource)
|
|
assert config.source.path == "src/server.py"
|
|
assert config.source.entrypoint == "app"
|
|
|
|
# Environment config should still work
|
|
assert config.environment.python == "3.12"
|
|
assert config.environment.dependencies == ["fastmcp", "requests"]
|
|
|
|
# Deployment config should still work
|
|
assert config.deployment.transport == "http"
|
|
assert config.deployment.port == 8000
|
|
|
|
def test_find_config_in_current_dir(self, tmp_path):
|
|
"""Test finding config in current directory."""
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps({"source": {"path": "server.py"}}))
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
found = MCPServerConfig.find_config()
|
|
assert found == config_file
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
def test_find_config_not_in_parent_dir(self, tmp_path):
|
|
"""Test that config is NOT found in parent directory."""
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps({"source": {"path": "server.py"}}))
|
|
|
|
subdir = tmp_path / "subdir"
|
|
subdir.mkdir()
|
|
|
|
# Should NOT find config in parent directory
|
|
found = MCPServerConfig.find_config(subdir)
|
|
assert found is None
|
|
|
|
def test_find_config_in_specified_dir(self, tmp_path):
|
|
"""Test finding config in the specified directory."""
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps({"source": {"path": "server.py"}}))
|
|
|
|
# Should find config when looking in the directory that contains it
|
|
found = MCPServerConfig.find_config(tmp_path)
|
|
assert found == config_file
|
|
|
|
def test_find_config_not_found(self, tmp_path):
|
|
"""Test when config is not found."""
|
|
found = MCPServerConfig.find_config(tmp_path)
|
|
assert found is None
|
|
|
|
def test_invalid_transport(self, tmp_path):
|
|
"""Test loading config with invalid transport value."""
|
|
config_data = {
|
|
"source": {"path": "server.py"},
|
|
"deployment": {"transport": "invalid_transport"},
|
|
}
|
|
|
|
config_file = tmp_path / "fastmcp.json"
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
with pytest.raises(ValidationError):
|
|
MCPServerConfig.from_file(config_file)
|
|
|
|
def test_optional_sections(self):
|
|
"""Test that all config sections are optional except source."""
|
|
# Only source is required
|
|
config = MCPServerConfig(source={"path": "server.py"})
|
|
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, UVEnvironment)
|
|
assert isinstance(config.deployment, Deployment)
|
|
|
|
# Only environment with values
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"}, environment={"python": "3.12"}
|
|
)
|
|
assert config.environment.python == "3.12"
|
|
assert isinstance(config.deployment, Deployment)
|
|
assert all(
|
|
getattr(config.deployment, field, None) is None
|
|
for field in Deployment.model_fields
|
|
)
|
|
|
|
# Only deployment with values
|
|
config = MCPServerConfig(
|
|
source={"path": "server.py"}, deployment={"transport": "http"}
|
|
)
|
|
assert isinstance(config.environment, UVEnvironment)
|
|
# Check all fields except 'type' which has a default value
|
|
assert all(
|
|
getattr(config.environment, field, None) is None
|
|
for field in UVEnvironment.model_fields
|
|
if field != "type"
|
|
)
|
|
assert config.deployment.transport == "http"
|
|
|
|
|
|
class TestMCPServerConfigRoundtrip:
|
|
"""Test that MCPServerConfig survives model_dump() -> reconstruct pattern.
|
|
|
|
This is used by the CLI to apply overrides immutably.
|
|
"""
|
|
|
|
def test_roundtrip_preserves_schema(self):
|
|
"""Ensure schema_ field survives dump/reconstruct cycle."""
|
|
config = MCPServerConfig(source=FileSystemSource(path="server.py"))
|
|
config_dict = config.model_dump()
|
|
reconstructed = MCPServerConfig(**config_dict)
|
|
assert reconstructed.schema_ == config.schema_
|
|
|
|
def test_roundtrip_with_all_fields(self):
|
|
"""Full config survives dump/reconstruct."""
|
|
config = MCPServerConfig(
|
|
source=FileSystemSource(path="server.py", entrypoint="app"),
|
|
environment=UVEnvironment(python="3.11"),
|
|
deployment=Deployment(transport="http", port=8080),
|
|
)
|
|
config_dict = config.model_dump()
|
|
reconstructed = MCPServerConfig(**config_dict)
|
|
assert reconstructed.source.path == "server.py"
|
|
assert reconstructed.environment.python == "3.11"
|
|
assert reconstructed.deployment.port == 8080
|