Spaces:
Running
Running
| """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.fastmcp_config import ( | |
| DeploymentConfig, | |
| EntrypointConfig, | |
| EnvironmentConfig, | |
| FastMCPConfig, | |
| ) | |
| class TestEntrypointConfig: | |
| """Test EntrypointConfig class.""" | |
| def test_string_entrypoint(self): | |
| """Test that string entrypoint is converted to EntrypointConfig.""" | |
| config = FastMCPConfig(entrypoint="server.py") | |
| # With the new validator, this should be converted to EntrypointConfig | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| assert config.entrypoint.object is None | |
| # get_entrypoint should return the same object | |
| entrypoint = config.get_entrypoint() | |
| assert isinstance(entrypoint, EntrypointConfig) | |
| assert entrypoint.file == "server.py" | |
| assert entrypoint.object is None | |
| def test_string_entrypoint_with_object(self): | |
| """Test string entrypoint with :object syntax.""" | |
| config = FastMCPConfig(entrypoint="server.py:app") | |
| # With the new validator, this should be converted to EntrypointConfig | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| assert config.entrypoint.object == "app" | |
| # get_entrypoint should return the same object | |
| entrypoint = config.get_entrypoint() | |
| assert isinstance(entrypoint, EntrypointConfig) | |
| assert entrypoint.file == "server.py" | |
| assert entrypoint.object == "app" | |
| def test_object_entrypoint(self): | |
| """Test EntrypointConfig object format.""" | |
| config = FastMCPConfig( | |
| entrypoint=EntrypointConfig(file="src/server.py", object="mcp") | |
| ) | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "src/server.py" | |
| assert config.entrypoint.object == "mcp" | |
| def test_get_entrypoint_path_resolution(self, tmp_path): | |
| """Test that get_entrypoint resolves paths relative to config file.""" | |
| config_dir = tmp_path / "config" | |
| config_dir.mkdir() | |
| server_dir = tmp_path / "src" | |
| server_dir.mkdir() | |
| server_file = server_dir / "server.py" | |
| server_file.write_text("# server") | |
| config = FastMCPConfig(entrypoint="../src/server.py") | |
| entrypoint = config.get_entrypoint(config_dir / "fastmcp.json") | |
| # Should resolve to absolute path | |
| assert Path(entrypoint.file).is_absolute() | |
| assert Path(entrypoint.file) == server_file.resolve() | |
| class TestEnvironmentConfig: | |
| """Test EnvironmentConfig class.""" | |
| def test_environment_config_fields(self): | |
| """Test all EnvironmentConfig fields.""" | |
| config = FastMCPConfig( | |
| entrypoint="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"] | |
| assert env.requirements == "requirements.txt" | |
| assert env.project == "." | |
| assert env.editable == "../my-package" | |
| def test_needs_uv(self): | |
| """Test needs_uv() method.""" | |
| # No environment config - doesn't need UV | |
| config = FastMCPConfig(entrypoint="server.py") | |
| assert not config.environment.needs_uv() | |
| # Empty environment - doesn't need UV | |
| config = FastMCPConfig(entrypoint="server.py", environment={}) | |
| assert not config.environment.needs_uv() | |
| # With dependencies - needs UV | |
| config = FastMCPConfig( | |
| entrypoint="server.py", environment={"dependencies": ["requests"]} | |
| ) | |
| assert config.environment.needs_uv() | |
| # With Python version - needs UV | |
| config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) | |
| assert config.environment.needs_uv() | |
| def test_build_uv_args(self): | |
| """Test build_uv_args() method.""" | |
| config = FastMCPConfig( | |
| entrypoint="server.py", | |
| environment={ | |
| "python": "3.12", | |
| "dependencies": ["requests", "numpy"], | |
| "requirements": "requirements.txt", | |
| "project": ".", | |
| }, | |
| ) | |
| args = config.environment.build_uv_args(["fastmcp", "run", "server.py"]) | |
| assert args[0] == "run" | |
| assert "--python" in args | |
| assert "3.12" in args | |
| assert "--project" in args | |
| assert "--with" in args | |
| assert "fastmcp" in args | |
| assert "requests" in args | |
| assert "numpy" in args | |
| assert "--with-requirements" in args | |
| assert "requirements.txt" in args | |
| assert "fastmcp" in args[-3:] | |
| assert "run" in args[-2:] | |
| assert "server.py" in args[-1:] | |
| def test_merge_with_cli_args(self): | |
| """Test merge_with_cli_args() method.""" | |
| config = FastMCPConfig( | |
| entrypoint="server.py", | |
| environment={ | |
| "python": "3.11", | |
| "dependencies": ["requests"], | |
| }, | |
| ) | |
| # CLI args should take precedence | |
| merged = config.environment.merge_with_cli_args( | |
| python="3.12", # Override | |
| with_packages=["numpy"], # Add to dependencies | |
| with_requirements=None, | |
| project=None, | |
| ) | |
| assert merged["python"] == "3.12" # CLI override | |
| assert set(merged["with_packages"]) == {"requests", "numpy"} # Merged | |
| assert merged["with_requirements"] is None | |
| assert merged["project"] is None | |
| def test_run_with_uv(self): | |
| """Test run_with_uv() subprocess execution.""" | |
| config = FastMCPConfig( | |
| entrypoint="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 TestDeploymentConfig: | |
| """Test DeploymentConfig class.""" | |
| def test_deployment_config_fields(self): | |
| """Test all DeploymentConfig fields.""" | |
| config = FastMCPConfig( | |
| entrypoint="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_merge_with_cli_args(self): | |
| """Test DeploymentConfig merge_with_cli_args() method.""" | |
| config = FastMCPConfig( | |
| entrypoint="server.py", | |
| deployment={ | |
| "transport": "stdio", | |
| "port": 3000, | |
| "log_level": "INFO", | |
| }, | |
| ) | |
| # CLI args should take precedence | |
| merged = config.deployment.merge_with_cli_args( | |
| transport="http", # Override | |
| host="localhost", # New value | |
| port=None, # Keep config value | |
| path=None, | |
| log_level="DEBUG", # Override | |
| server_args=["--test"], | |
| ) | |
| assert merged["transport"] == "http" # CLI override | |
| assert merged["host"] == "localhost" # CLI value | |
| assert merged["port"] == 3000 # Config value (CLI was None) | |
| assert merged["log_level"] == "DEBUG" # CLI override | |
| assert merged["server_args"] == ["--test"] # CLI value | |
| 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 = FastMCPConfig( | |
| entrypoint="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 = FastMCPConfig( | |
| entrypoint="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 TestFastMCPConfig: | |
| """Test FastMCPConfig root configuration.""" | |
| def test_minimal_config(self): | |
| """Test creating a config with only required fields.""" | |
| config = FastMCPConfig(entrypoint="server.py") | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| assert config.entrypoint.object is None | |
| # Environment and deployment are now always present but empty | |
| assert isinstance(config.environment, EnvironmentConfig) | |
| assert isinstance(config.deployment, DeploymentConfig) | |
| # Check they have no values set | |
| assert not config.environment.needs_uv() | |
| assert all( | |
| getattr(config.deployment, field, None) is None | |
| for field in DeploymentConfig.model_fields | |
| ) | |
| def test_nested_structure(self): | |
| """Test the nested configuration structure.""" | |
| config = FastMCPConfig( | |
| entrypoint="server.py", | |
| environment={ | |
| "python": "3.12", | |
| "dependencies": ["fastmcp"], | |
| }, | |
| deployment={ | |
| "transport": "stdio", | |
| "log_level": "INFO", | |
| }, | |
| ) | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| assert config.entrypoint.object is None | |
| assert isinstance(config.environment, EnvironmentConfig) | |
| assert isinstance(config.deployment, DeploymentConfig) | |
| def test_from_file(self, tmp_path): | |
| """Test loading config from JSON file with nested structure.""" | |
| config_data = { | |
| "$schema": "https://gofastmcp.com/schemas/fastmcp_config/v1.json", | |
| "entrypoint": {"file": "src/server.py", "object": "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 = FastMCPConfig.from_file(config_file) | |
| # When loaded from JSON with object format, it becomes EntrypointConfig | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "src/server.py" | |
| assert config.entrypoint.object == "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 string entrypoint.""" | |
| config_data = { | |
| "entrypoint": "server.py:mcp", | |
| "environment": {"dependencies": ["fastmcp"]}, | |
| } | |
| config_file = tmp_path / "fastmcp.json" | |
| config_file.write_text(json.dumps(config_data)) | |
| config = FastMCPConfig.from_file(config_file) | |
| # String entrypoint with : should be converted to EntrypointConfig | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| assert config.entrypoint.object == "mcp" | |
| # get_entrypoint should return the same | |
| entrypoint = config.get_entrypoint() | |
| assert entrypoint.file == "server.py" | |
| assert entrypoint.object == "mcp" | |
| def test_string_entrypoint_with_object_and_environment(self, tmp_path): | |
| """Test that file.py:object syntax works with environment config.""" | |
| config_data = { | |
| "entrypoint": "src/server.py: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 = FastMCPConfig.from_file(config_file) | |
| # Should be parsed into EntrypointConfig | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "src/server.py" | |
| assert config.entrypoint.object == "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({"entrypoint": "server.py"})) | |
| original_cwd = os.getcwd() | |
| try: | |
| os.chdir(tmp_path) | |
| found = FastMCPConfig.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({"entrypoint": "server.py"})) | |
| subdir = tmp_path / "subdir" | |
| subdir.mkdir() | |
| # Should NOT find config in parent directory | |
| found = FastMCPConfig.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({"entrypoint": "server.py"})) | |
| # Should find config when looking in the directory that contains it | |
| found = FastMCPConfig.find_config(tmp_path) | |
| assert found == config_file | |
| def test_find_config_not_found(self, tmp_path): | |
| """Test when config is not found.""" | |
| found = FastMCPConfig.find_config(tmp_path) | |
| assert found is None | |
| def test_invalid_transport(self, tmp_path): | |
| """Test loading config with invalid transport value.""" | |
| config_data = { | |
| "entrypoint": "server.py", | |
| "deployment": {"transport": "invalid_transport"}, | |
| } | |
| config_file = tmp_path / "fastmcp.json" | |
| config_file.write_text(json.dumps(config_data)) | |
| with pytest.raises(ValidationError): | |
| FastMCPConfig.from_file(config_file) | |
| def test_optional_sections(self): | |
| """Test that all config sections are optional except entrypoint.""" | |
| # Only entrypoint is required | |
| config = FastMCPConfig(entrypoint="server.py") | |
| assert isinstance(config.entrypoint, EntrypointConfig) | |
| assert config.entrypoint.file == "server.py" | |
| # Environment and deployment are now always present but may be empty | |
| assert isinstance(config.environment, EnvironmentConfig) | |
| assert isinstance(config.deployment, DeploymentConfig) | |
| # Only environment with values | |
| config = FastMCPConfig(entrypoint="server.py", environment={"python": "3.12"}) | |
| assert config.environment.python == "3.12" | |
| assert isinstance(config.deployment, DeploymentConfig) | |
| assert all( | |
| getattr(config.deployment, field, None) is None | |
| for field in DeploymentConfig.model_fields | |
| ) | |
| # Only deployment with values | |
| config = FastMCPConfig(entrypoint="server.py", deployment={"transport": "http"}) | |
| assert isinstance(config.environment, EnvironmentConfig) | |
| assert all( | |
| getattr(config.environment, field, None) is None | |
| for field in EnvironmentConfig.model_fields | |
| ) | |
| assert config.deployment.transport == "http" | |