initial commit after one day coding agent session
This commit is contained in:
commit
372af75b90
88 changed files with 22694 additions and 0 deletions
439
tests/config/test_loader.py
Normal file
439
tests/config/test_loader.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""
|
||||
Tests for configuration loader module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all loading and substitution requirements from todo.md section 1.1.2.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
|
||||
def test_load_from_valid_yaml_file(tmp_path):
|
||||
"""Test loading configuration from a valid YAML file."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
||||
podman_socket: "/run/user/1000/podman/podman.sock"
|
||||
|
||||
execution:
|
||||
default_backend: "simple"
|
||||
default_timeout: 300
|
||||
max_timeout: 1800
|
||||
default_memory: "512m"
|
||||
max_memory: "2g"
|
||||
default_cpu_quota: 50000
|
||||
max_cpu_quota: 100000
|
||||
|
||||
images:
|
||||
python_3_11: "mcp-forge/python:3.11"
|
||||
python_3_12: "mcp-forge/python:3.12"
|
||||
jupyter: "mcp-forge/jupyter:latest"
|
||||
auto_pull: true
|
||||
pull_interval: 86400
|
||||
|
||||
sessions:
|
||||
idle_timeout: 3600
|
||||
max_concurrent: 10
|
||||
cleanup_interval: 300
|
||||
|
||||
volumes:
|
||||
base_path: "/mcp-forge/volumes"
|
||||
session_quota: "1g"
|
||||
max_session_quota: "10g"
|
||||
|
||||
security:
|
||||
audit_log: "/var/log/mcp-forge/audit.log"
|
||||
enforce_resource_limits: true
|
||||
allow_network: false
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/var/cache/mcp-forge/uv"
|
||||
max_packages_per_build: 50
|
||||
max_build_time: 600
|
||||
max_image_size: 2147483648
|
||||
max_concurrent_builds: 3
|
||||
build_rate_limit: {}
|
||||
auto_cleanup: {}
|
||||
templates: {}
|
||||
package_validation:
|
||||
use_allowlist: true
|
||||
allowlist_path: "/etc/mcp-forge/allowlist.txt"
|
||||
blocklist_path: "/etc/mcp-forge/blocklist.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools:
|
||||
git:
|
||||
command: "uvx"
|
||||
args: ["mcp-server-git"]
|
||||
env: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.server.host == "0.0.0.0"
|
||||
assert config.server.port == 3000
|
||||
assert config.execution.default_backend == "simple"
|
||||
|
||||
|
||||
def test_load_from_non_existent_file_raises_file_not_found_error():
|
||||
"""Test that loading from non-existent file raises FileNotFoundError."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config(Path("/non/existent/config.yaml"))
|
||||
|
||||
|
||||
def test_invalid_yaml_raises_yaml_error(tmp_path):
|
||||
"""Test that invalid YAML syntax raises YAMLError."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("""
|
||||
invalid yaml:
|
||||
- unmatched [bracket
|
||||
key without value
|
||||
""")
|
||||
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_environment_variable_substitution_in_strings(tmp_path, monkeypatch):
|
||||
"""Test that ${VAR} is replaced with environment variable value."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
monkeypatch.setenv("TEST_HOST", "test.example.com")
|
||||
monkeypatch.setenv("TEST_PORT", "4000")
|
||||
monkeypatch.setenv("PODMAN_SOCKET", "/run/test/podman.sock")
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "${TEST_HOST}"
|
||||
port: ${TEST_PORT}
|
||||
podman_socket: "${PODMAN_SOCKET}"
|
||||
|
||||
execution:
|
||||
default_backend: "simple"
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.server.host == "test.example.com"
|
||||
assert config.server.port == 4000
|
||||
assert str(config.server.podman_socket) == "/run/test/podman.sock"
|
||||
|
||||
|
||||
def test_nested_environment_variable_substitution(tmp_path, monkeypatch):
|
||||
"""Test that environment variable substitution works in nested structures."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
monkeypatch.setenv("AUDIT_LOG_PATH", "/var/log/audit.log")
|
||||
monkeypatch.setenv("UV_CACHE", "/var/cache/uv")
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "${AUDIT_LOG_PATH}"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "${UV_CACHE}"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert str(config.security.audit_log) == "/var/log/audit.log"
|
||||
assert str(config.environment_builder.uv_cache_path) == "/var/cache/uv"
|
||||
|
||||
|
||||
def test_missing_environment_variable_raises_clear_error(tmp_path):
|
||||
"""Test that missing environment variable raises error with variable name."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "${MISSING_VAR}"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
images: {}
|
||||
sessions: {}
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_config(config_file)
|
||||
|
||||
assert "MISSING_VAR" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_config_from_dict():
|
||||
"""Test loading configuration from dictionary (for testing)."""
|
||||
from mcp_forge.config.loader import load_config_from_dict
|
||||
|
||||
config_dict = {
|
||||
"server": {
|
||||
"host": "localhost",
|
||||
"port": 3000,
|
||||
"podman_socket": "/run/podman.sock"
|
||||
},
|
||||
"execution": {},
|
||||
"images": {},
|
||||
"sessions": {},
|
||||
"volumes": {"base_path": "/volumes"},
|
||||
"security": {"audit_log": "/audit.log"},
|
||||
"environment_builder": {
|
||||
"enabled": True,
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
"mcp_tools": {}
|
||||
}
|
||||
|
||||
config = load_config_from_dict(config_dict)
|
||||
|
||||
assert config.server.host == "localhost"
|
||||
assert config.server.port == 3000
|
||||
|
||||
|
||||
def test_load_config_from_dict_with_env_var_substitution(monkeypatch):
|
||||
"""Test that load_config_from_dict also performs env var substitution."""
|
||||
from mcp_forge.config.loader import load_config_from_dict
|
||||
|
||||
monkeypatch.setenv("TEST_HOST", "example.com")
|
||||
|
||||
config_dict = {
|
||||
"server": {
|
||||
"host": "${TEST_HOST}",
|
||||
"port": 3000,
|
||||
"podman_socket": "/run/podman.sock"
|
||||
},
|
||||
"execution": {},
|
||||
"images": {},
|
||||
"sessions": {},
|
||||
"volumes": {"base_path": "/volumes"},
|
||||
"security": {"audit_log": "/audit.log"},
|
||||
"environment_builder": {
|
||||
"enabled": True,
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
"mcp_tools": {}
|
||||
}
|
||||
|
||||
config = load_config_from_dict(config_dict)
|
||||
|
||||
assert config.server.host == "example.com"
|
||||
|
||||
|
||||
def test_partial_configuration_uses_defaults(tmp_path):
|
||||
"""Test that missing configuration sections use schema defaults."""
|
||||
from mcp_forge.config.loader import load_config
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_content = """
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 3000
|
||||
podman_socket: "/run/podman.sock"
|
||||
|
||||
execution: {}
|
||||
|
||||
images: {}
|
||||
|
||||
sessions: {}
|
||||
|
||||
volumes:
|
||||
base_path: "/volumes"
|
||||
|
||||
security:
|
||||
audit_log: "/audit.log"
|
||||
|
||||
environment_builder:
|
||||
enabled: true
|
||||
uv_cache_path: "/cache"
|
||||
package_validation:
|
||||
allowlist_path: "/allow.txt"
|
||||
blocklist_path: "/block.txt"
|
||||
require_approval_patterns: []
|
||||
|
||||
mcp_tools: {}
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Check defaults from ExecutionConfig
|
||||
assert config.execution.default_backend == "simple"
|
||||
assert config.execution.default_timeout == 300
|
||||
assert config.execution.max_timeout == 1800
|
||||
|
||||
# Check defaults from ImageConfig
|
||||
assert config.images.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.images.auto_pull is True
|
||||
|
||||
|
||||
def test_substitute_env_vars_recursive():
|
||||
"""Test that substitute_env_vars works recursively on nested structures."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
import os
|
||||
|
||||
os.environ["TEST_VALUE"] = "substituted"
|
||||
|
||||
data = {
|
||||
"simple": "${TEST_VALUE}",
|
||||
"nested": {
|
||||
"deep": "${TEST_VALUE}",
|
||||
"list": ["${TEST_VALUE}", "plain"]
|
||||
}
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["simple"] == "substituted"
|
||||
assert result["nested"]["deep"] == "substituted"
|
||||
assert result["nested"]["list"][0] == "substituted"
|
||||
assert result["nested"]["list"][1] == "plain"
|
||||
|
||||
|
||||
def test_substitute_env_vars_with_integer_conversion(monkeypatch):
|
||||
"""Test that numeric strings in env vars can be converted to integers."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("PORT_NUM", "8080")
|
||||
|
||||
data = {
|
||||
"port": "${PORT_NUM}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
# Should still be a string after substitution; type conversion handled by Pydantic
|
||||
assert result["port"] == "8080"
|
||||
|
||||
|
||||
def test_no_eval_or_exec_in_substitution():
|
||||
"""Test that no eval() or exec() is used - only safe string substitution."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
import os
|
||||
|
||||
# Try to inject malicious code - should be treated as literal string
|
||||
os.environ["MALICIOUS"] = "__import__('os').system('echo hacked')"
|
||||
|
||||
data = {
|
||||
"value": "${MALICIOUS}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
# Should be the literal string, not executed
|
||||
assert result["value"] == "__import__('os').system('echo hacked')"
|
||||
# And it should NOT have been executed (we can't test side effects easily,
|
||||
# but the substitution logic shouldn't use eval/exec)
|
||||
|
||||
|
||||
def test_env_var_with_special_characters(monkeypatch):
|
||||
"""Test that environment variables with special characters are handled correctly."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("SPECIAL_PATH", "/path/with-dashes_and_underscores/123")
|
||||
|
||||
data = {
|
||||
"path": "${SPECIAL_PATH}"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["path"] == "/path/with-dashes_and_underscores/123"
|
||||
|
||||
|
||||
def test_multiple_env_vars_in_single_string(monkeypatch):
|
||||
"""Test that multiple environment variables can be substituted in one string."""
|
||||
from mcp_forge.config.loader import substitute_env_vars
|
||||
|
||||
monkeypatch.setenv("BASE_PATH", "/opt/mcp-forge")
|
||||
monkeypatch.setenv("SUBDIR", "logs")
|
||||
|
||||
data = {
|
||||
"log_path": "${BASE_PATH}/${SUBDIR}/audit.log"
|
||||
}
|
||||
|
||||
result = substitute_env_vars(data)
|
||||
|
||||
assert result["log_path"] == "/opt/mcp-forge/logs/audit.log"
|
||||
141
tests/config/test_mcp_tool_config.py
Normal file
141
tests/config/test_mcp_tool_config.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Tests for MCPToolConfig schema with HTTP/SSE transport."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
|
||||
def test_stdio_transport_config_valid():
|
||||
"""Test valid stdio transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="stdio",
|
||||
command="python",
|
||||
args=["-m", "server"],
|
||||
env={"KEY": "value"}
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
assert config.command == "python"
|
||||
assert config.args == ["-m", "server"]
|
||||
assert config.env == {"KEY": "value"}
|
||||
|
||||
|
||||
def test_http_transport_config_valid():
|
||||
"""Test valid HTTP transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp",
|
||||
headers={"Authorization": "Bearer token"}
|
||||
)
|
||||
|
||||
assert config.transport == "http"
|
||||
assert config.url == "http://localhost:8006/mcp"
|
||||
assert config.headers == {"Authorization": "Bearer token"}
|
||||
|
||||
|
||||
def test_sse_transport_config_valid():
|
||||
"""Test valid SSE transport configuration."""
|
||||
config = MCPToolConfig(
|
||||
transport="sse",
|
||||
url="http://localhost:9000/events",
|
||||
headers={"X-Custom": "value"}
|
||||
)
|
||||
|
||||
assert config.transport == "sse"
|
||||
assert config.url == "http://localhost:9000/events"
|
||||
assert config.headers == {"X-Custom": "value"}
|
||||
|
||||
|
||||
def test_stdio_without_command_invalid():
|
||||
"""Test that stdio transport requires command."""
|
||||
with pytest.raises(ValidationError, match="command is required for stdio transport"):
|
||||
MCPToolConfig(
|
||||
transport="stdio",
|
||||
args=["-m", "server"]
|
||||
)
|
||||
|
||||
|
||||
def test_http_without_url_invalid():
|
||||
"""Test that HTTP transport requires URL."""
|
||||
with pytest.raises(ValidationError, match="url is required for http transport"):
|
||||
MCPToolConfig(
|
||||
transport="http",
|
||||
headers={"Authorization": "Bearer token"}
|
||||
)
|
||||
|
||||
|
||||
def test_sse_without_url_invalid():
|
||||
"""Test that SSE transport requires URL."""
|
||||
with pytest.raises(ValidationError, match="url is required for sse transport"):
|
||||
MCPToolConfig(
|
||||
transport="sse",
|
||||
headers={"X-Custom": "value"}
|
||||
)
|
||||
|
||||
|
||||
def test_default_transport_is_stdio():
|
||||
"""Test that default transport is stdio."""
|
||||
config = MCPToolConfig(
|
||||
command="python",
|
||||
args=["-m", "server"]
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
|
||||
|
||||
def test_http_config_with_empty_headers():
|
||||
"""Test HTTP config with no headers."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp"
|
||||
)
|
||||
|
||||
assert config.headers == {}
|
||||
|
||||
|
||||
def test_stdio_config_with_empty_env():
|
||||
"""Test stdio config with no env vars."""
|
||||
config = MCPToolConfig(
|
||||
transport="stdio",
|
||||
command="python"
|
||||
)
|
||||
|
||||
assert config.env == {}
|
||||
assert config.args == []
|
||||
|
||||
|
||||
def test_invalid_transport_type():
|
||||
"""Test that invalid transport type is rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
MCPToolConfig(
|
||||
transport="invalid",
|
||||
command="python"
|
||||
)
|
||||
|
||||
|
||||
def test_stdio_config_minimal():
|
||||
"""Test minimal stdio config with just command."""
|
||||
config = MCPToolConfig(
|
||||
command="python"
|
||||
)
|
||||
|
||||
assert config.transport == "stdio"
|
||||
assert config.command == "python"
|
||||
assert config.args == []
|
||||
assert config.env == {}
|
||||
|
||||
|
||||
def test_http_config_with_multiple_headers():
|
||||
"""Test HTTP config with multiple headers."""
|
||||
config = MCPToolConfig(
|
||||
transport="http",
|
||||
url="http://localhost:8006/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer token123",
|
||||
"X-Custom-Header": "value",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
assert len(config.headers) == 3
|
||||
assert config.headers["Authorization"] == "Bearer token123"
|
||||
403
tests/config/test_schema.py
Normal file
403
tests/config/test_schema.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""
|
||||
Tests for configuration schema module.
|
||||
|
||||
Following TDD approach - these tests are written before implementation.
|
||||
Tests cover all validation requirements from todo.md section 1.1.1.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def test_valid_server_config_loads_successfully():
|
||||
"""Test that a valid ServerConfig loads without errors."""
|
||||
from mcp_forge.config.schema import ServerConfig
|
||||
|
||||
config = ServerConfig(
|
||||
host="0.0.0.0",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/user/1000/podman/podman.sock")
|
||||
)
|
||||
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.port == 3000
|
||||
assert config.podman_socket == Path("/run/user/1000/podman/podman.sock")
|
||||
|
||||
|
||||
def test_server_config_invalid_port_raises_validation_error():
|
||||
"""Test that invalid port numbers raise ValidationError."""
|
||||
from mcp_forge.config.schema import ServerConfig
|
||||
|
||||
# Port too high
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=70000,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
# Port too low
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=0,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
# Negative port
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ServerConfig(
|
||||
host="localhost",
|
||||
port=-1,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
)
|
||||
assert "port" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_execution_config_defaults_are_applied():
|
||||
"""Test that ExecutionConfig has correct default values."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
config = ExecutionConfig()
|
||||
|
||||
assert config.default_backend == "simple"
|
||||
assert config.default_timeout == 300
|
||||
assert config.max_timeout == 1800
|
||||
assert config.default_memory == "512m"
|
||||
assert config.max_memory == "2g"
|
||||
assert config.default_cpu_quota == 50000
|
||||
assert config.max_cpu_quota == 100000
|
||||
|
||||
|
||||
def test_execution_config_max_timeout_validation():
|
||||
"""Test that max_timeout must be >= default_timeout."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# Valid: max >= default
|
||||
config = ExecutionConfig(default_timeout=300, max_timeout=600)
|
||||
assert config.max_timeout == 600
|
||||
|
||||
# Invalid: max < default
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExecutionConfig(default_timeout=600, max_timeout=300)
|
||||
assert "max_timeout" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_image_config_defaults():
|
||||
"""Test ImageConfig default values."""
|
||||
from mcp_forge.config.schema import ImageConfig
|
||||
|
||||
config = ImageConfig()
|
||||
|
||||
assert config.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.python_3_12 == "mcp-forge/python:3.12"
|
||||
assert config.jupyter == "mcp-forge/jupyter:latest"
|
||||
assert config.auto_pull is True
|
||||
assert config.pull_interval == 86400
|
||||
|
||||
|
||||
def test_session_config_defaults():
|
||||
"""Test SessionConfig default values."""
|
||||
from mcp_forge.config.schema import SessionConfig
|
||||
|
||||
config = SessionConfig()
|
||||
|
||||
assert config.idle_timeout == 3600
|
||||
assert config.max_concurrent == 10
|
||||
assert config.cleanup_interval == 300
|
||||
|
||||
|
||||
def test_volume_config_with_base_path():
|
||||
"""Test VolumeConfig with required base_path."""
|
||||
from mcp_forge.config.schema import VolumeConfig
|
||||
|
||||
config = VolumeConfig(base_path=Path("/mcp-forge/volumes"))
|
||||
|
||||
assert config.base_path == Path("/mcp-forge/volumes")
|
||||
assert config.session_quota == "1g"
|
||||
assert config.max_session_quota == "10g"
|
||||
|
||||
|
||||
def test_security_config_defaults():
|
||||
"""Test SecurityConfig default values."""
|
||||
from mcp_forge.config.schema import SecurityConfig
|
||||
|
||||
config = SecurityConfig(audit_log=Path("/var/log/mcp-forge/audit.log"))
|
||||
|
||||
assert config.audit_log == Path("/var/log/mcp-forge/audit.log")
|
||||
assert config.enforce_resource_limits is True
|
||||
assert config.allow_network is False
|
||||
|
||||
|
||||
def test_package_validation_config():
|
||||
"""Test PackageValidationConfig structure."""
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
config = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/mcp-forge/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/mcp-forge/blocklist.txt"),
|
||||
require_approval_patterns=["*crypto*", "*network*"]
|
||||
)
|
||||
|
||||
assert config.use_allowlist is True
|
||||
assert config.allowlist_path == Path("/etc/mcp-forge/allowlist.txt")
|
||||
assert config.blocklist_path == Path("/etc/mcp-forge/blocklist.txt")
|
||||
assert "*crypto*" in config.require_approval_patterns
|
||||
|
||||
|
||||
def test_environment_builder_config():
|
||||
"""Test EnvironmentBuilderConfig structure and defaults."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/blocklist.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/var/cache/mcp-forge/uv"),
|
||||
package_validation=pkg_validation
|
||||
)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.uv_cache_path == Path("/var/cache/mcp-forge/uv")
|
||||
assert config.max_packages_per_build == 50
|
||||
assert config.max_build_time == 600
|
||||
assert config.max_image_size == 2147483648
|
||||
assert config.max_concurrent_builds == 3
|
||||
|
||||
|
||||
def test_mcp_tool_config():
|
||||
"""Test MCPToolConfig structure."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(
|
||||
command="uvx",
|
||||
args=["mcp-server-git"],
|
||||
env={"GIT_AUTHOR": "test"}
|
||||
)
|
||||
|
||||
assert config.command == "uvx"
|
||||
assert config.args == ["mcp-server-git"]
|
||||
assert config.env == {"GIT_AUTHOR": "test"}
|
||||
|
||||
|
||||
def test_mcp_tool_config_empty_env_defaults():
|
||||
"""Test MCPToolConfig with empty env defaults to empty dict."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(command="test", args=[])
|
||||
|
||||
assert config.env == {}
|
||||
|
||||
|
||||
def test_forge_config_full_structure():
|
||||
"""Test complete ForgeConfig with all nested structures."""
|
||||
from mcp_forge.config.schema import (
|
||||
ForgeConfig, ServerConfig, ExecutionConfig, ImageConfig,
|
||||
SessionConfig, VolumeConfig, SecurityConfig,
|
||||
EnvironmentBuilderConfig, PackageValidationConfig, MCPToolConfig
|
||||
)
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/etc/allowlist.txt"),
|
||||
blocklist_path=Path("/etc/blocklist.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=3000,
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
),
|
||||
execution=ExecutionConfig(),
|
||||
images=ImageConfig(),
|
||||
sessions=SessionConfig(),
|
||||
volumes=VolumeConfig(base_path=Path("/mcp-forge/volumes")),
|
||||
security=SecurityConfig(audit_log=Path("/var/log/audit.log")),
|
||||
environment_builder=EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/var/cache/uv"),
|
||||
package_validation=pkg_validation
|
||||
),
|
||||
mcp_tools={
|
||||
"git": MCPToolConfig(command="uvx", args=["mcp-server-git"])
|
||||
}
|
||||
)
|
||||
|
||||
assert config.server.host == "localhost"
|
||||
assert config.execution.default_backend == "simple"
|
||||
assert config.images.python_3_11 == "mcp-forge/python:3.11"
|
||||
assert config.sessions.max_concurrent == 10
|
||||
assert config.volumes.base_path == Path("/mcp-forge/volumes")
|
||||
assert config.security.enforce_resource_limits is True
|
||||
assert config.environment_builder.enabled is True
|
||||
assert "git" in config.mcp_tools
|
||||
|
||||
|
||||
def test_nested_configuration_validation_error_includes_field_path():
|
||||
"""Test that ValidationError for nested config includes full field path."""
|
||||
from mcp_forge.config.schema import ForgeConfig, ServerConfig
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ForgeConfig(
|
||||
server=ServerConfig(
|
||||
host="localhost",
|
||||
port=99999, # Invalid port
|
||||
podman_socket=Path("/run/podman.sock")
|
||||
),
|
||||
execution={},
|
||||
images={},
|
||||
sessions={},
|
||||
volumes={"base_path": "/volumes"},
|
||||
security={"audit_log": "/audit.log"},
|
||||
environment_builder={
|
||||
"uv_cache_path": "/cache",
|
||||
"package_validation": {
|
||||
"allowlist_path": "/allow.txt",
|
||||
"blocklist_path": "/block.txt",
|
||||
"require_approval_patterns": []
|
||||
}
|
||||
},
|
||||
mcp_tools={}
|
||||
)
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
# Should include nested path like "server.port"
|
||||
assert "port" in error_str.lower()
|
||||
|
||||
|
||||
def test_execution_config_backend_literal_validation():
|
||||
"""Test that default_backend only accepts 'simple' or 'jupyter'."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# Valid values
|
||||
config1 = ExecutionConfig(default_backend="simple")
|
||||
assert config1.default_backend == "simple"
|
||||
|
||||
config2 = ExecutionConfig(default_backend="jupyter")
|
||||
assert config2.default_backend == "jupyter"
|
||||
|
||||
# Invalid value
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ExecutionConfig(default_backend="invalid")
|
||||
assert "default_backend" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_environment_builder_config_rate_limit_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts rate_limit dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
build_rate_limit={"max_requests": 10, "period_seconds": 3600}
|
||||
)
|
||||
|
||||
assert config.build_rate_limit == {"max_requests": 10, "period_seconds": 3600}
|
||||
|
||||
|
||||
def test_environment_builder_config_auto_cleanup_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts auto_cleanup dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
auto_cleanup={"enabled": True, "max_age_days": 30}
|
||||
)
|
||||
|
||||
assert config.auto_cleanup == {"enabled": True, "max_age_days": 30}
|
||||
|
||||
|
||||
def test_environment_builder_config_templates_dict():
|
||||
"""Test that EnvironmentBuilderConfig accepts templates dict."""
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig, PackageValidationConfig
|
||||
|
||||
pkg_validation = PackageValidationConfig(
|
||||
use_allowlist=True,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=[]
|
||||
)
|
||||
|
||||
config = EnvironmentBuilderConfig(
|
||||
enabled=True,
|
||||
uv_cache_path=Path("/cache"),
|
||||
package_validation=pkg_validation,
|
||||
templates={
|
||||
"data-science": {"packages": ["numpy", "pandas", "matplotlib"]},
|
||||
"web": {"packages": ["fastapi", "uvicorn"]}
|
||||
}
|
||||
)
|
||||
|
||||
assert "data-science" in config.templates
|
||||
assert "web" in config.templates
|
||||
assert config.templates["data-science"]["packages"] == ["numpy", "pandas", "matplotlib"]
|
||||
|
||||
|
||||
def test_config_constraint_validation_memory_strings():
|
||||
"""Test that config validates memory strings are comparable."""
|
||||
from mcp_forge.config.schema import ExecutionConfig
|
||||
|
||||
# This should succeed - just testing structure, not actual parsing yet
|
||||
config = ExecutionConfig(
|
||||
default_memory="512m",
|
||||
max_memory="2g"
|
||||
)
|
||||
|
||||
assert config.default_memory == "512m"
|
||||
assert config.max_memory == "2g"
|
||||
|
||||
|
||||
def test_mcp_tool_config_list_of_args():
|
||||
"""Test MCPToolConfig args is a list."""
|
||||
from mcp_forge.config.schema import MCPToolConfig
|
||||
|
||||
config = MCPToolConfig(
|
||||
command="python",
|
||||
args=["-m", "mymodule", "--flag"]
|
||||
)
|
||||
|
||||
assert isinstance(config.args, list)
|
||||
assert config.args == ["-m", "mymodule", "--flag"]
|
||||
|
||||
|
||||
def test_package_validation_require_approval_patterns_list():
|
||||
"""Test PackageValidationConfig require_approval_patterns is a list."""
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
config = PackageValidationConfig(
|
||||
use_allowlist=False,
|
||||
allowlist_path=Path("/allow.txt"),
|
||||
blocklist_path=Path("/block.txt"),
|
||||
require_approval_patterns=["*crypto*", "*security*", "paramiko"]
|
||||
)
|
||||
|
||||
assert isinstance(config.require_approval_patterns, list)
|
||||
assert len(config.require_approval_patterns) == 3
|
||||
Loading…
Add table
Add a link
Reference in a new issue