initial commit after one day coding agent session

This commit is contained in:
Hans Aschauer 2026-02-07 07:45:57 +01:00
commit 372af75b90
88 changed files with 22694 additions and 0 deletions

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,346 @@
"""Tests for environment builder orchestration."""
import pytest
from pathlib import Path
from unittest.mock import Mock, patch
from datetime import datetime, timedelta
from mcp_forge.builder.environment_builder import (
EnvironmentBuilder,
BuildRateLimiter,
)
from mcp_forge.builder.package_validator import SecurityError
from mcp_forge.builder.image_builder import BuildResult
from mcp_forge.config.schema import EnvironmentBuilderConfig
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
@pytest.fixture
def builder_config():
"""Mock environment builder configuration."""
config = Mock(spec=EnvironmentBuilderConfig)
config.build_timeout = 600
config.max_build_timeout = 1800
config.max_image_size = "2g"
config.max_packages = 50
config.uv_cache_path = Path("/tmp/uv-cache")
config.build_rate_limit = {"requests": 5, "period": 3600}
config.max_concurrent_builds = 3
config.base_images = {"python:3.11-slim": True}
config.templates = {
"data-science": {
"packages": ["numpy", "pandas", "matplotlib"],
"description": "Data science environment"
}
}
# Add package_validation config for PackageValidator
config.package_validation = Path("/tmp/package-validation.yaml")
return config
@pytest.fixture
def mock_podman_client():
"""Mock Podman client."""
return Mock(spec=PodmanClient)
@pytest.fixture
def mock_audit_logger():
"""Mock audit logger."""
return Mock(spec=AuditLogger)
@pytest.fixture
def builder(builder_config, mock_podman_client, mock_audit_logger):
"""Environment builder instance."""
# Mock the sub-components during initialization
with patch('mcp_forge.builder.environment_builder.PackageValidator'), \
patch('mcp_forge.builder.environment_builder.UVInstaller'), \
patch('mcp_forge.builder.environment_builder.ImageBuilder'):
builder = EnvironmentBuilder(
config=builder_config,
podman_client=mock_podman_client,
audit_logger=mock_audit_logger
)
# Replace with mocks for tests
builder.package_validator = Mock()
builder.uv_installer = Mock()
builder.image_builder = Mock()
return builder
def test_build_custom_environment_success(builder, tmp_path):
"""Test successful custom environment build."""
# Mock all sub-components
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
mock_result = BuildResult(
success=True,
image_name="mcp-forge/custom:test-env",
image_id="sha256:abc123",
build_time=45.2,
size_bytes=500_000_000,
cache_hit=False,
installed_packages=["numpy==1.24.0"]
)
builder.image_builder.build_image = Mock(return_value=mock_result)
result = builder.build_custom_environment(
name="test-env",
packages=["numpy>=1.24.0"],
user_id="user123"
)
assert result.success is True
assert result.image_name == "mcp-forge/custom:test-env"
builder.package_validator.validate_packages.assert_called_once()
builder.image_builder.build_image.assert_called_once()
def test_build_validates_package_count(builder):
"""Test that build validates package count against maximum."""
builder.config.max_packages = 10
packages = [f"package{i}" for i in range(20)]
with pytest.raises(ValueError, match="exceeds maximum"):
builder.build_custom_environment(
name="test-env",
packages=packages,
user_id="user123"
)
def test_build_validates_packages_with_validator(builder):
"""Test that build uses package validator."""
builder.package_validator.validate_packages = Mock(
side_effect=SecurityError("Blocked package")
)
with pytest.raises(SecurityError, match="Blocked package"):
builder.build_custom_environment(
name="test-env",
packages=["forbidden-package"],
user_id="user123"
)
def test_build_enforces_rate_limit(builder):
"""Test that build enforces rate limiting."""
builder.package_validator.validate_packages = Mock()
# Exhaust rate limit
for _ in range(5):
builder.rate_limiter.check_rate_limit("user123")
# Next request should fail
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
builder.build_custom_environment(
name="test-env",
packages=["numpy"],
user_id="user123"
)
def test_build_enforces_concurrent_builds_limit(builder, tmp_path):
"""Test that build enforces concurrent builds limit."""
builder.config.max_concurrent_builds = 2
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
# Simulate 2 active builds
builder.active_builds.add("build1")
builder.active_builds.add("build2")
with pytest.raises(RuntimeError, match="Maximum concurrent builds"):
builder.build_custom_environment(
name="test-env",
packages=["numpy"],
user_id="user123"
)
def test_build_cleans_up_build_context(builder, tmp_path):
"""Test that build cleans up build context even on failure."""
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
builder.image_builder.build_image = Mock(side_effect=RuntimeError("Build failed"))
with pytest.raises(RuntimeError, match="Build failed"):
builder.build_custom_environment(
name="test-env",
packages=["numpy"],
user_id="user123"
)
# Build context should have been registered for cleanup
# (actual cleanup would happen in finally block)
def test_build_validates_environment_name(builder):
"""Test that build validates environment name format."""
with pytest.raises(ValueError, match="must contain only alphanumeric"):
builder.build_custom_environment(
name="test env!", # Invalid: spaces and special chars
packages=["numpy"],
user_id="user123"
)
def test_build_from_template_expands_packages(builder, tmp_path):
"""Test building from template expands package list."""
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
mock_result = BuildResult(
success=True,
image_name="mcp-forge/custom:data-sci",
image_id="sha256:abc123",
build_time=45.2,
size_bytes=500_000_000,
cache_hit=False,
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0"]
)
builder.image_builder.build_image = Mock(return_value=mock_result)
result = builder.build_from_template(
template_name="data-science",
name="data-sci",
user_id="user123"
)
assert result.success is True
# Should have called validator with template packages
builder.package_validator.validate_packages.assert_called_once()
call_args = builder.package_validator.validate_packages.call_args[0][0]
assert "numpy" in call_args
assert "pandas" in call_args
assert "matplotlib" in call_args
def test_build_from_template_with_additional_packages(builder, tmp_path):
"""Test building from template with additional packages."""
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
mock_result = BuildResult(
success=True,
image_name="mcp-forge/custom:data-sci",
image_id="sha256:abc123",
build_time=45.2,
size_bytes=500_000_000,
cache_hit=False,
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0", "scipy==1.10.0"]
)
builder.image_builder.build_image = Mock(return_value=mock_result)
result = builder.build_from_template(
template_name="data-science",
additional_packages=["scipy"],
name="data-sci",
user_id="user123"
)
assert result.success is True
call_args = builder.package_validator.validate_packages.call_args[0][0]
assert "scipy" in call_args
def test_build_from_template_validates_template_exists(builder):
"""Test that template build validates template exists."""
with pytest.raises(ValueError, match="Template.*not found"):
builder.build_from_template(
template_name="nonexistent",
user_id="user123"
)
def test_list_templates_returns_config_templates(builder):
"""Test that list_templates returns configured templates."""
templates = builder.list_templates()
assert "data-science" in templates
assert templates["data-science"]["packages"] == ["numpy", "pandas", "matplotlib"]
def test_rate_limiter_allows_within_limit():
"""Test that rate limiter allows requests within limit."""
limiter = BuildRateLimiter(max_requests=5, period_seconds=3600)
# Should allow 5 requests
for _ in range(5):
limiter.check_rate_limit("user123")
# 6th request should fail
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
limiter.check_rate_limit("user123")
def test_rate_limiter_cleans_up_old_requests():
"""Test that rate limiter cleans up old requests."""
limiter = BuildRateLimiter(max_requests=5, period_seconds=1)
# Make 5 requests
for _ in range(5):
limiter.check_rate_limit("user123")
# Wait for period to expire
import time
time.sleep(1.1)
# Should allow new request after period
limiter.check_rate_limit("user123")
def test_rate_limiter_tracks_per_user():
"""Test that rate limiter tracks requests per user."""
limiter = BuildRateLimiter(max_requests=2, period_seconds=3600)
# User1 makes 2 requests
limiter.check_rate_limit("user1")
limiter.check_rate_limit("user1")
# User2 should still be allowed
limiter.check_rate_limit("user2")
limiter.check_rate_limit("user2")
# Both users now at limit
with pytest.raises(RuntimeError):
limiter.check_rate_limit("user1")
with pytest.raises(RuntimeError):
limiter.check_rate_limit("user2")
def test_build_registers_and_unregisters_active_builds(builder, tmp_path):
"""Test that builds are registered and unregistered correctly."""
builder.package_validator.validate_packages = Mock()
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
mock_result = BuildResult(
success=True,
image_name="mcp-forge/custom:test-env",
image_id="sha256:abc123",
build_time=45.2,
size_bytes=500_000_000,
cache_hit=False,
installed_packages=["numpy==1.24.0"]
)
builder.image_builder.build_image = Mock(return_value=mock_result)
# Before build
assert "test-env" not in builder.active_builds
builder.build_custom_environment(
name="test-env",
packages=["numpy"],
user_id="user123"
)
# After build
assert "test-env" not in builder.active_builds

View file

@ -0,0 +1,355 @@
"""Tests for Image Builder module."""
import pytest
from pathlib import Path
from unittest.mock import Mock
from mcp_forge.builder.image_builder import ImageBuilder, BuildResult
from mcp_forge.podman.client import PodmanClient
from mcp_forge.config.schema import EnvironmentBuilderConfig
from mcp_forge.security.audit import AuditLogger
@pytest.fixture
def mock_podman_client():
"""Mock PodmanClient."""
mock = Mock(spec=PodmanClient)
# Configure nested mocks for images and containers
mock.images = Mock()
mock.containers = Mock()
return mock
@pytest.fixture
def builder_config():
"""Mock EnvironmentBuilderConfig."""
config = Mock(spec=EnvironmentBuilderConfig)
config.enabled = True
config.uv_cache_path = Path("/tmp/uv_cache")
config.max_packages_per_build = 50
config.build_timeout = 600
config.max_build_timeout = 1800
config.max_image_size = "2g"
return config
@pytest.fixture
def mock_audit_logger():
"""Mock AuditLogger."""
return Mock(spec=AuditLogger)
@pytest.fixture
def builder(mock_podman_client, builder_config, mock_audit_logger):
"""ImageBuilder instance."""
return ImageBuilder(mock_podman_client, builder_config, mock_audit_logger)
@pytest.fixture
def build_context(tmp_path):
"""Create temporary build context."""
context = tmp_path / "build_context"
context.mkdir()
# Create Containerfile
containerfile = context / "Containerfile"
containerfile.write_text("FROM python:3.11-slim\n")
# Create requirements.txt
requirements = context / "requirements.txt"
requirements.write_text("numpy>=1.24.0\npandas\n")
return context
def test_build_image_success(builder, build_context, mock_podman_client):
"""Test successful image build."""
# Mock successful build
mock_image = Mock()
mock_image.id = "sha256:abc123"
mock_image.attrs = {"Size": 500_000_000}
mock_podman_client.images.build.return_value = (mock_image, [])
mock_podman_client.images.get.return_value = mock_image # Mock images.get() call
# Mock pip list output
mock_container = Mock()
mock_container.exec_run.return_value = (
0,
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
)
mock_podman_client.containers.run.return_value = mock_container
packages = ["numpy>=1.24.0", "pandas"]
result = builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=packages
)
assert result.success is True
assert result.image_name == "mcp-forge/custom:test-env"
assert result.image_id == "sha256:abc123"
assert result.size_bytes == 500_000_000
assert len(result.installed_packages) == 2
def test_build_image_with_custom_timeout(builder, build_context, mock_podman_client):
"""Test build with custom timeout."""
mock_image = Mock()
mock_image.id = "sha256:abc123"
mock_image.attrs = {"Size": 100_000_000}
mock_podman_client.images.build.return_value = (mock_image, [])
mock_podman_client.images.get.return_value = mock_image
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=[],
timeout=1200
)
# Verify timeout was passed to build
call_kwargs = mock_podman_client.images.build.call_args[1]
assert call_kwargs["timeout"] == 1200
def test_build_image_validates_timeout_against_max(builder, build_context, builder_config):
"""Test that build validates timeout against maximum."""
builder_config.max_build_timeout = 1800
with pytest.raises(ValueError, match="Timeout 3600 exceeds maximum"):
builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=[],
timeout=3600
)
def test_build_image_uses_default_timeout(builder, build_context, builder_config, mock_podman_client):
"""Test that build uses config default timeout when not specified."""
builder_config.build_timeout = 600
mock_image = Mock()
mock_image.id = "sha256:abc123"
mock_image.attrs = {"Size": 100_000_000}
mock_podman_client.images.build.return_value = (mock_image, [])
mock_podman_client.images.get.return_value = mock_image
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=[]
)
call_kwargs = mock_podman_client.images.build.call_args[1]
assert call_kwargs["timeout"] == 600
def test_generate_tag_valid_name(builder):
"""Test tag generation with valid name."""
tag = builder.generate_tag("my-env-123")
assert tag == "mcp-forge/custom:my-env-123"
def test_generate_tag_invalid_name_raises_error(builder):
"""Test that invalid names raise ValueError."""
with pytest.raises(ValueError, match="alphanumeric"):
builder.generate_tag("my env")
with pytest.raises(ValueError, match="alphanumeric"):
builder.generate_tag("my_env!")
def test_validate_image_size_within_limit(builder, mock_podman_client, builder_config):
"""Test image size validation passes when within limit."""
builder_config.max_image_size = "2g"
mock_image = Mock()
mock_image.attrs = {"Size": 1_000_000_000} # 1GB
mock_podman_client.images.get.return_value = mock_image
size = builder.validate_image_size("sha256:abc123")
assert size == 1_000_000_000
def test_validate_image_size_exceeds_limit(builder, mock_podman_client, builder_config):
"""Test image size validation fails when exceeds limit."""
builder_config.max_image_size = "1g"
mock_image = Mock()
mock_image.attrs = {"Size": 2_000_000_000} # 2GB
mock_podman_client.images.get.return_value = mock_image
with pytest.raises(ValueError, match="exceeds maximum"):
builder.validate_image_size("sha256:abc123")
def test_extract_installed_packages(builder, mock_podman_client):
"""Test extracting installed packages from image."""
mock_container = Mock()
mock_container.exec_run.return_value = (
0,
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
)
mock_podman_client.containers.run.return_value = mock_container
packages = builder.extract_installed_packages("sha256:abc123")
assert len(packages) == 2
assert "numpy==1.24.0" in packages
assert "pandas==2.0.0" in packages
def test_extract_installed_packages_handles_error(builder, mock_podman_client):
"""Test that package extraction handles errors gracefully."""
mock_container = Mock()
mock_container.exec_run.return_value = (1, b"Error")
mock_podman_client.containers.run.return_value = mock_container
packages = builder.extract_installed_packages("sha256:abc123")
assert packages == []
def test_calculate_cache_hash(builder):
"""Test cache hash calculation."""
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
hash1 = builder.calculate_cache_hash(packages)
hash2 = builder.calculate_cache_hash(packages)
# Same packages should produce same hash
assert hash1 == hash2
# Different packages should produce different hash
different_packages = ["numpy>=1.24.0", "scipy"]
hash3 = builder.calculate_cache_hash(different_packages)
assert hash1 != hash3
def test_calculate_cache_hash_order_independent(builder):
"""Test that cache hash is order-independent."""
packages1 = ["numpy", "pandas", "scipy"]
packages2 = ["scipy", "numpy", "pandas"]
hash1 = builder.calculate_cache_hash(packages1)
hash2 = builder.calculate_cache_hash(packages2)
# Order shouldn't matter
assert hash1 == hash2
def test_build_image_logs_audit_event(builder, build_context, mock_podman_client, mock_audit_logger):
"""Test that build logs audit event."""
mock_image = Mock()
mock_image.id = "sha256:abc123"
mock_image.attrs = {"Size": 100_000_000}
mock_podman_client.images.build.return_value = (mock_image, [])
mock_podman_client.images.get.return_value = mock_image
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=["numpy"]
)
# Verify audit log was called
assert mock_audit_logger.log.called
def test_build_image_returns_build_time(builder, build_context, mock_podman_client):
"""Test that build result includes build time."""
mock_image = Mock()
mock_image.id = "sha256:abc123"
mock_image.attrs = {"Size": 100_000_000}
mock_podman_client.images.build.return_value = (mock_image, [])
mock_podman_client.images.get.return_value = mock_image
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
result = builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=[]
)
assert result.build_time > 0
def test_build_image_handles_build_failure(builder, build_context, mock_podman_client):
"""Test that build handles Podman build failures."""
mock_podman_client.images.build.side_effect = Exception("Build failed")
with pytest.raises(RuntimeError, match="Build failed"):
builder.build_image(
name="test-env",
build_context=build_context,
base_image="python:3.11-slim",
packages=[]
)
def test_build_result_to_dict(builder):
"""Test BuildResult serialization."""
result = BuildResult(
success=True,
image_name="mcp-forge/custom:test",
image_id="sha256:abc123",
build_time=10.5,
size_bytes=500_000_000,
cache_hit=False,
installed_packages=["numpy==1.24.0", "pandas==2.0.0"]
)
result_dict = result.to_dict()
assert result_dict["success"] is True
assert result_dict["image_name"] == "mcp-forge/custom:test"
assert result_dict["image_id"] == "sha256:abc123"
assert result_dict["build_time"] == 10.5
assert result_dict["size_bytes"] == 500_000_000
assert result_dict["cache_hit"] is False
assert len(result_dict["installed_packages"]) == 2
def test_build_image_validates_build_context_exists(builder, tmp_path):
"""Test that build validates build context exists."""
nonexistent = tmp_path / "nonexistent"
with pytest.raises(ValueError, match="Build context does not exist"):
builder.build_image(
name="test-env",
build_context=nonexistent,
base_image="python:3.11-slim",
packages=[]
)
def test_build_image_validates_containerfile_exists(builder, tmp_path):
"""Test that build validates Containerfile exists."""
context = tmp_path / "context"
context.mkdir()
# No Containerfile created
with pytest.raises(ValueError, match="Containerfile not found"):
builder.build_image(
name="test-env",
build_context=context,
base_image="python:3.11-slim",
packages=[]
)

View file

@ -0,0 +1,271 @@
"""Tests for Package Validator module."""
import pytest
from unittest.mock import Mock
from mcp_forge.builder.package_validator import (
PackageValidator,
ApprovalRequiredError,
SecurityError
)
from mcp_forge.config.schema import PackageValidationConfig
@pytest.fixture
def allowlist_file(tmp_path):
"""Create temporary allowlist file."""
allowlist = tmp_path / "allowlist.txt"
allowlist.write_text("""
# Standard data science packages
numpy
pandas
scipy
scikit-learn
matplotlib
# Web and API
requests
httpx
aiohttp
# Utilities
pyyaml
python-dateutil
""".strip())
return allowlist
@pytest.fixture
def blocklist_file(tmp_path):
"""Create temporary blocklist file."""
blocklist = tmp_path / "blocklist.txt"
blocklist.write_text("""
# Security concerns
os-crypto
subprocess-wrapper
shell-exec
# Known malicious
malicious-package
evil-lib
""".strip())
return blocklist
@pytest.fixture
def validation_config(allowlist_file, blocklist_file, tmp_path):
"""Mock PackageValidationConfig."""
config = Mock(spec=PackageValidationConfig)
config.use_allowlist = True
config.allowlist_path = allowlist_file
config.blocklist_path = blocklist_file
config.require_approval_patterns = [
"^torch.*", # PyTorch packages
"^tensorflow.*", # TensorFlow packages
".*-gpu$", # GPU variants
]
return config
@pytest.fixture
def validator(validation_config):
"""PackageValidator instance."""
return PackageValidator(validation_config)
def test_extract_package_name_simple(validator):
"""Test extracting package name from simple spec."""
assert validator.extract_package_name("numpy") == "numpy"
assert validator.extract_package_name("pandas") == "pandas"
def test_extract_package_name_with_version(validator):
"""Test extracting package name with version specifiers."""
assert validator.extract_package_name("numpy>=1.24.0") == "numpy"
assert validator.extract_package_name("pandas==2.0.0") == "pandas"
assert validator.extract_package_name("requests<=2.28.0") == "requests"
assert validator.extract_package_name("scikit-learn~=1.3.0") == "scikit-learn"
def test_extract_package_name_with_extras(validator):
"""Test extracting package name with extras."""
assert validator.extract_package_name("requests[security]") == "requests"
assert validator.extract_package_name("pandas[excel,sql]") == "pandas"
def test_extract_package_name_complex(validator):
"""Test extracting package name from complex specs."""
assert validator.extract_package_name("numpy>=1.24.0,<2.0.0") == "numpy"
assert validator.extract_package_name("requests[security]>=2.28.0") == "requests"
def test_allowlisted_package_passes(validator):
"""Test that allowlisted packages pass validation."""
validator.validate_package("numpy")
validator.validate_package("pandas>=2.0.0")
validator.validate_package("requests[security]")
# Should not raise
def test_blocklisted_package_raises_error(validator):
"""Test that blocklisted packages raise SecurityError."""
with pytest.raises(SecurityError, match="malicious-package"):
validator.validate_package("malicious-package")
with pytest.raises(SecurityError, match="evil-lib"):
validator.validate_package("evil-lib>=1.0.0")
def test_unknown_package_with_allowlist_raises_error(validator):
"""Test that unknown packages raise error when allowlist is enabled."""
with pytest.raises(SecurityError, match="unknown-package"):
validator.validate_package("unknown-package")
def test_package_requiring_approval_raises_error(validator):
"""Test that packages matching approval patterns raise ApprovalRequiredError."""
with pytest.raises(ApprovalRequiredError, match="torch"):
validator.validate_package("torch")
with pytest.raises(ApprovalRequiredError, match="tensorflow"):
validator.validate_package("tensorflow-gpu")
with pytest.raises(ApprovalRequiredError, match="gpu"):
validator.validate_package("cupy-gpu")
def test_validate_packages_list(validator):
"""Test validating multiple packages at once."""
packages = ["numpy>=1.24.0", "pandas", "requests"]
validator.validate_packages(packages)
# Should not raise
def test_validate_packages_enforces_max_limit(validator):
"""Test that validate_packages enforces maximum package count."""
packages = ["numpy", "pandas", "scipy", "matplotlib"]
with pytest.raises(ValueError, match="Maximum 3 packages"):
validator.validate_packages(packages, max_packages=3)
def test_validate_packages_with_mixed_results(validator):
"""Test that validation stops at first error."""
packages = ["numpy", "malicious-package", "pandas"]
with pytest.raises(SecurityError, match="malicious-package"):
validator.validate_packages(packages)
def test_validate_packages_with_approval_required(validator):
"""Test that validation stops at first approval requirement."""
packages = ["numpy", "torch", "pandas"]
with pytest.raises(ApprovalRequiredError, match="torch"):
validator.validate_packages(packages)
def test_allowlist_loading(allowlist_file):
"""Test that allowlist is loaded correctly from file."""
config = Mock(spec=PackageValidationConfig)
config.use_allowlist = True
config.allowlist_path = allowlist_file
config.blocklist_path = None
config.require_approval_patterns = []
validator = PackageValidator(config)
assert "numpy" in validator.allowlist
assert "pandas" in validator.allowlist
assert "requests" in validator.allowlist
# Comments and empty lines should be ignored
assert "# Standard data science packages" not in validator.allowlist
def test_blocklist_loading(blocklist_file):
"""Test that blocklist is loaded correctly from file."""
config = Mock(spec=PackageValidationConfig)
config.use_allowlist = False
config.allowlist_path = None
config.blocklist_path = blocklist_file
config.require_approval_patterns = []
validator = PackageValidator(config)
assert "malicious-package" in validator.blocklist
assert "evil-lib" in validator.blocklist
def test_disabled_allowlist_allows_all_except_blocklist(blocklist_file):
"""Test that disabling allowlist allows any package except blocklisted."""
config = Mock(spec=PackageValidationConfig)
config.use_allowlist = False
config.allowlist_path = None
config.blocklist_path = blocklist_file
config.require_approval_patterns = []
validator = PackageValidator(config)
# Unknown packages should pass
validator.validate_package("some-random-package")
# But blocklisted packages should still fail
with pytest.raises(SecurityError, match="malicious-package"):
validator.validate_package("malicious-package")
def test_approval_pattern_matching(validator):
"""Test that approval patterns match correctly."""
# torch* should match
with pytest.raises(ApprovalRequiredError):
validator.validate_package("torch")
with pytest.raises(ApprovalRequiredError):
validator.validate_package("torchvision")
# *-gpu$ should match
with pytest.raises(ApprovalRequiredError):
validator.validate_package("something-gpu")
def test_empty_package_list(validator):
"""Test validating empty package list."""
validator.validate_packages([])
# Should not raise
def test_validate_packages_without_max_limit(validator):
"""Test validating many packages without limit."""
packages = [f"package{i}" for i in range(100)]
# Should raise because packages aren't in allowlist
with pytest.raises(SecurityError):
validator.validate_packages(packages)
def test_case_sensitivity(validator):
"""Test that package names are case-sensitive."""
# numpy is in allowlist
validator.validate_package("numpy")
# NumPy (different case) should fail
with pytest.raises(SecurityError, match="NumPy"):
validator.validate_package("NumPy")
def test_whitespace_handling(validator):
"""Test that leading/trailing whitespace is handled."""
validator.validate_package(" numpy ")
validator.validate_package(" pandas>=2.0.0 ")
def test_file_not_found_handling(tmp_path):
"""Test handling of missing allowlist/blocklist files."""
config = Mock(spec=PackageValidationConfig)
config.use_allowlist = True
config.allowlist_path = tmp_path / "nonexistent.txt"
config.blocklist_path = None
config.require_approval_patterns = []
with pytest.raises(FileNotFoundError):
PackageValidator(config)

View file

@ -0,0 +1,304 @@
"""Tests for UV Package Installer module."""
import pytest
from pathlib import Path
from unittest.mock import Mock, patch, mock_open
import tempfile
import shutil
from mcp_forge.builder.uv_installer import UVInstaller
@pytest.fixture
def cache_path(tmp_path):
"""Temporary cache directory."""
cache_dir = tmp_path / "uv_cache"
return cache_dir
@pytest.fixture
def installer(cache_path):
"""UVInstaller instance."""
return UVInstaller(cache_path)
def test_init_creates_cache_directory(cache_path):
"""Test that __init__ creates cache directory."""
assert not cache_path.exists()
installer = UVInstaller(cache_path)
assert cache_path.exists()
assert cache_path.is_dir()
def test_init_with_existing_cache(cache_path):
"""Test initialization with existing cache directory."""
cache_path.mkdir(parents=True)
installer = UVInstaller(cache_path)
assert cache_path.exists()
def test_generate_requirements_single_package(installer):
"""Test generating requirements.txt with single package."""
packages = ["numpy>=1.24.0"]
requirements = installer.generate_requirements(packages)
assert requirements == "numpy>=1.24.0"
def test_generate_requirements_multiple_packages(installer):
"""Test generating requirements.txt with multiple packages."""
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
requirements = installer.generate_requirements(packages)
lines = requirements.strip().split('\n')
assert len(lines) == 3
assert "numpy>=1.24.0" in lines
assert "pandas==2.0.0" in lines
assert "requests" in lines
def test_generate_requirements_empty_list(installer):
"""Test generating requirements.txt with empty list."""
packages = []
requirements = installer.generate_requirements(packages)
assert requirements == ""
def test_generate_containerfile_structure(installer):
"""Test Containerfile has correct structure."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy>=1.24.0", "pandas"]
containerfile = installer.generate_containerfile(base_image, packages)
# Check key sections are present
assert f"FROM {base_image}" in containerfile
assert "pip install" in containerfile and "uv" in containerfile
assert "COPY" in containerfile and "requirements.txt" in containerfile
assert "RUN uv pip install" in containerfile
assert "WORKDIR" in containerfile
def test_generate_containerfile_with_python_version(installer):
"""Test Containerfile generation with specific Python version."""
base_image = "docker.io/python:3.12-slim"
packages = ["numpy"]
containerfile = installer.generate_containerfile(
base_image, packages, python_version="3.12"
)
assert "3.12" in containerfile or "python:3.12" in base_image
def test_generate_containerfile_uses_requirements(installer):
"""Test Containerfile copies and uses requirements.txt."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy", "pandas"]
containerfile = installer.generate_containerfile(base_image, packages)
# Should copy requirements.txt for layer caching
assert "COPY" in containerfile and "requirements.txt" in containerfile
assert "-r requirements.txt" in containerfile
def test_generate_containerfile_creates_user(installer):
"""Test Containerfile creates non-root user."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy"]
containerfile = installer.generate_containerfile(base_image, packages)
# Should create user for security
assert "useradd" in containerfile.lower() or "USER" in containerfile
def test_create_build_context_creates_directory(installer):
"""Test that build context directory is created."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy", "pandas"]
context_path = installer.create_build_context(base_image, packages)
try:
assert context_path.exists()
assert context_path.is_dir()
finally:
if context_path.exists():
shutil.rmtree(context_path)
def test_create_build_context_contains_containerfile(installer):
"""Test that build context contains Containerfile."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy"]
context_path = installer.create_build_context(base_image, packages)
try:
containerfile_path = context_path / "Containerfile"
assert containerfile_path.exists()
content = containerfile_path.read_text()
assert f"FROM {base_image}" in content
finally:
if context_path.exists():
shutil.rmtree(context_path)
def test_create_build_context_contains_requirements(installer):
"""Test that build context contains requirements.txt."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy>=1.24.0", "pandas"]
context_path = installer.create_build_context(base_image, packages)
try:
requirements_path = context_path / "requirements.txt"
assert requirements_path.exists()
content = requirements_path.read_text()
assert "numpy>=1.24.0" in content
assert "pandas" in content
finally:
if context_path.exists():
shutil.rmtree(context_path)
def test_create_build_context_returns_temp_directory(installer):
"""Test that build context is in temp directory."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy"]
context_path = installer.create_build_context(base_image, packages)
try:
# Should be in system temp directory
temp_dir = Path(tempfile.gettempdir())
assert temp_dir in context_path.parents
finally:
if context_path.exists():
shutil.rmtree(context_path)
def test_get_cache_volume_mount_returns_dict(installer):
"""Test cache volume mount returns proper dict."""
mount_config = installer.get_cache_volume_mount()
assert isinstance(mount_config, dict)
assert "bind" in mount_config
assert "mode" in mount_config
assert mount_config["mode"] == "rw"
def test_get_cache_volume_mount_includes_cache_path(installer, cache_path):
"""Test cache volume mount includes cache path."""
mount_config = installer.get_cache_volume_mount()
# The bind target should reference UV cache location
assert "bind" in mount_config
assert "/cache" in mount_config["bind"] or "uv" in mount_config["bind"].lower()
def test_generate_containerfile_security_practices(installer):
"""Test Containerfile follows security best practices."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy"]
containerfile = installer.generate_containerfile(base_image, packages)
# Should not run as root
assert "USER" in containerfile
# Should set working directory
assert "WORKDIR" in containerfile
def test_generate_requirements_preserves_version_specs(installer):
"""Test that version specifiers are preserved exactly."""
packages = [
"numpy>=1.24.0,<2.0.0",
"pandas==2.0.0",
"requests~=2.28.0",
"scipy!=1.10.0"
]
requirements = installer.generate_requirements(packages)
for package in packages:
assert package in requirements
def test_generate_containerfile_with_extras(installer):
"""Test Containerfile works with package extras."""
base_image = "docker.io/python:3.11-slim"
packages = ["requests[security]>=2.28.0", "pandas[excel]"]
containerfile = installer.generate_containerfile(base_image, packages)
# Should handle extras in requirements
assert "COPY" in containerfile and "requirements.txt" in containerfile
assert "RUN uv pip install" in containerfile
def test_create_build_context_with_custom_python_version(installer):
"""Test build context creation with custom Python version."""
base_image = "docker.io/python:3.12-slim"
packages = ["numpy"]
context_path = installer.create_build_context(
base_image, packages, python_version="3.12"
)
try:
containerfile = (context_path / "Containerfile").read_text()
assert "3.12" in containerfile or "python:3.12" in containerfile
finally:
if context_path.exists():
shutil.rmtree(context_path)
def test_generate_containerfile_optimizes_layer_caching(installer):
"""Test that Containerfile structure optimizes Docker layer caching."""
base_image = "docker.io/python:3.11-slim"
packages = ["numpy", "pandas", "scipy"]
containerfile = installer.generate_containerfile(base_image, packages)
lines = containerfile.split('\n')
# UV install should come before requirements copy
uv_install_idx = None
requirements_idx = None
for i, line in enumerate(lines):
if 'uv' in line.lower() and ('pip install' in line.lower() or 'ADD' in line):
uv_install_idx = i
if 'COPY' in line and 'requirements.txt' in line:
requirements_idx = i
# UV installation should be cached separately
assert uv_install_idx is not None
# Requirements copy should happen for cache busting
assert requirements_idx is not None
def test_empty_packages_list_creates_valid_containerfile(installer):
"""Test that empty package list still creates valid Containerfile."""
base_image = "docker.io/python:3.11-slim"
packages = []
containerfile = installer.generate_containerfile(base_image, packages)
# Should still have base structure
assert f"FROM {base_image}" in containerfile
assert "WORKDIR" in containerfile

439
tests/config/test_loader.py Normal file
View 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"

View 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
View 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

View file

View file

View file

@ -0,0 +1,422 @@
"""Tests for Jupyter Backend module."""
import pytest
from unittest.mock import Mock, MagicMock, patch
from pathlib import Path
from mcp_forge.execution.jupyter.backend import JupyterBackend
from mcp_forge.execution.jupyter.sessions import SessionManager, Session, SessionState
from mcp_forge.execution.simple.executor import ExecutionResult
from mcp_forge.config.schema import ForgeConfig, ExecutionConfig, ImageConfig, SessionConfig
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.resource_limits import ResourceLimits
@pytest.fixture
def mock_config():
"""Mock ForgeConfig with execution settings."""
config = Mock(spec=ForgeConfig)
# Execution configuration
config.execution = Mock(spec=ExecutionConfig)
config.execution.default_timeout = 300
config.execution.max_timeout = 1800
config.execution.default_memory = "512m"
config.execution.max_memory = "2g"
config.execution.default_cpu_quota = 50000
config.execution.max_cpu_quota = 100000
# Image configuration
config.images = Mock(spec=ImageConfig)
config.images.jupyter = "mcp-forge/jupyter:latest"
# Session configuration
config.sessions = Mock(spec=SessionConfig)
config.sessions.idle_timeout = 3600
config.sessions.max_concurrent = 10
config.sessions.cleanup_interval = 300
return config
@pytest.fixture
def mock_container_manager():
"""Mock SecureContainerManager."""
return Mock(spec=SecureContainerManager)
@pytest.fixture
def mock_audit_logger():
"""Mock AuditLogger."""
return Mock(spec=AuditLogger)
@pytest.fixture
def mock_session_manager():
"""Mock SessionManager."""
return Mock(spec=SessionManager)
@pytest.fixture
def backend(mock_config, mock_container_manager, mock_audit_logger):
"""JupyterBackend instance with mocked dependencies."""
with patch('mcp_forge.execution.jupyter.backend.SessionManager') as mock_sm_class:
mock_session_manager = Mock(spec=SessionManager)
mock_sm_class.return_value = mock_session_manager
backend = JupyterBackend(
config=mock_config,
container_manager=mock_container_manager,
audit_logger=mock_audit_logger
)
backend.session_manager = mock_session_manager
return backend
def test_backend_initializes_session_manager(mock_config, mock_container_manager, mock_audit_logger):
"""Test backend creates SessionManager on initialization."""
with patch('mcp_forge.execution.jupyter.backend.SessionManager') as mock_sm_class:
with patch('mcp_forge.execution.jupyter.backend.JupyterKernelManager') as mock_km_class:
mock_session_manager = Mock(spec=SessionManager)
mock_sm_class.return_value = mock_session_manager
backend = JupyterBackend(
config=mock_config,
container_manager=mock_container_manager,
audit_logger=mock_audit_logger
)
# Verify kernel manager was created
mock_km_class.assert_called_once()
# Verify session manager was created
mock_sm_class.assert_called_once()
def test_execute_creates_session_if_not_exists(backend):
"""Test execute creates new session if it doesn't exist."""
# Mock get_session to raise SessionError (session doesn't exist)
from mcp_forge.execution.jupyter.sessions import SessionError
backend.session_manager.get_session.side_effect = SessionError("Session not found")
# Mock create_session
mock_session = Mock(spec=Session)
backend.session_manager.create_session.return_value = mock_session
# Mock execute_in_session
mock_result = ExecutionResult(
success=True,
stdout="Hello",
stderr="",
result="Hello",
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
result = backend.execute("print('Hello')", session_id="test-session")
# Verify session was created
backend.session_manager.create_session.assert_called_once()
# Verify execution happened
backend.session_manager.execute_in_session.assert_called_once_with(
session_id="test-session", code="print('Hello')", timeout=300
)
assert result.success is True
def test_execute_reuses_existing_session(backend):
"""Test execute reuses existing session."""
# Mock get_session to return existing session
mock_session = Mock(spec=Session)
backend.session_manager.get_session.return_value = mock_session
# Mock execute_in_session
mock_result = ExecutionResult(
success=True,
stdout="42",
stderr="",
result=42,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
result = backend.execute("21 + 21", session_id="existing-session")
# Verify session was NOT created
backend.session_manager.create_session.assert_not_called()
# Verify session was checked
backend.session_manager.get_session.assert_called_once_with("existing-session")
# Verify execution happened
backend.session_manager.execute_in_session.assert_called_once()
assert result.result == 42
def test_execute_with_custom_timeout(backend):
"""Test execute respects custom timeout parameter."""
# Mock existing session
mock_session = Mock(spec=Session)
backend.session_manager.get_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
backend.execute("pass", session_id="test", timeout=600)
# Verify timeout was passed through
backend.session_manager.execute_in_session.assert_called_once_with(
session_id="test", code="pass", timeout=600
)
def test_execute_with_custom_memory(backend, mock_config):
"""Test execute creates session with custom memory limit."""
from mcp_forge.execution.jupyter.sessions import SessionError
backend.session_manager.get_session.side_effect = SessionError("Not found")
mock_session = Mock(spec=Session)
backend.session_manager.create_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
backend.execute("pass", session_id="test", memory="1g")
# Verify session was created with custom memory
backend.session_manager.create_session.assert_called_once()
call_args = backend.session_manager.create_session.call_args
resource_limits = call_args[1]['resource_limits']
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
def test_execute_with_custom_cpu_quota(backend):
"""Test execute creates session with custom CPU quota."""
from mcp_forge.execution.jupyter.sessions import SessionError
backend.session_manager.get_session.side_effect = SessionError("Not found")
mock_session = Mock(spec=Session)
backend.session_manager.create_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
backend.execute("pass", session_id="test", cpu_quota=75000)
# Verify session was created with custom CPU quota
backend.session_manager.create_session.assert_called_once()
call_args = backend.session_manager.create_session.call_args
resource_limits = call_args[1]['resource_limits']
assert resource_limits.cpu_quota == 75000
def test_execute_validates_timeout_against_max(backend, mock_config):
"""Test execute rejects timeout exceeding maximum."""
mock_config.execution.max_timeout = 1800
with pytest.raises(ValueError, match="Timeout 3600 exceeds maximum"):
backend.execute("pass", session_id="test", timeout=3600)
def test_execute_validates_memory_against_max(backend, mock_config):
"""Test execute rejects memory exceeding maximum."""
mock_config.execution.max_memory = "2g"
with pytest.raises(ValueError, match="Memory 4g exceeds maximum"):
backend.execute("pass", session_id="test", memory="4g")
def test_execute_validates_cpu_quota_against_max(backend, mock_config):
"""Test execute rejects CPU quota exceeding maximum."""
mock_config.execution.max_cpu_quota = 100000
with pytest.raises(ValueError, match="CPU quota 150000 exceeds maximum"):
backend.execute("pass", session_id="test", cpu_quota=150000)
def test_execute_with_volumes(backend):
"""Test execute passes volumes to session creation."""
from mcp_forge.execution.jupyter.sessions import SessionError
backend.session_manager.get_session.side_effect = SessionError("Not found")
mock_session = Mock(spec=Session)
backend.session_manager.create_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
volumes = {"/host/path": {"bind": "/container/path", "mode": "ro"}}
backend.execute("pass", session_id="test", volumes=volumes)
# Verify volumes were passed to create_session
backend.session_manager.create_session.assert_called_once()
call_args = backend.session_manager.create_session.call_args
assert call_args[1]['volumes'] == volumes
def test_document_state(backend):
"""Test document_state delegates to session manager."""
variables = {"x": "Input data", "y": "Output result"}
note = "Initial data load"
backend.document_state("test-session", variables, note=note, clear=False)
backend.session_manager.document_state.assert_called_once_with(
session_id="test-session", variables=variables, note=note, clear=False
)
def test_document_state_with_clear(backend):
"""Test document_state with clear flag."""
variables = {"new_var": "New data"}
backend.document_state("test-session", variables, clear=True)
backend.session_manager.document_state.assert_called_once_with(
session_id="test-session", variables=variables, note="", clear=True
)
def test_get_session_state(backend):
"""Test get_session_state delegates to session manager."""
mock_state = Mock(spec=SessionState)
backend.session_manager.get_session_state.return_value = mock_state
state = backend.get_session_state("test-session")
backend.session_manager.get_session_state.assert_called_once_with("test-session")
assert state == mock_state
def test_destroy_session(backend):
"""Test destroy_session delegates to session manager."""
backend.destroy_session("test-session")
backend.session_manager.destroy_session.assert_called_once_with("test-session")
def test_list_sessions(backend):
"""Test list_sessions delegates to session manager."""
mock_sessions = [
{"session_id": "session1", "kernel_id": "kernel1"},
{"session_id": "session2", "kernel_id": "kernel2"}
]
backend.session_manager.list_sessions.return_value = mock_sessions
sessions = backend.list_sessions()
backend.session_manager.list_sessions.assert_called_once()
assert sessions == mock_sessions
def test_cleanup_idle_sessions(backend):
"""Test cleanup_idle_sessions delegates to session manager."""
backend.session_manager.cleanup_idle_sessions.return_value = 2
count = backend.cleanup_idle_sessions()
backend.session_manager.cleanup_idle_sessions.assert_called_once()
assert count == 2
def test_default_resource_limits_from_config(mock_config, mock_container_manager, mock_audit_logger):
"""Test _default_resource_limits creates limits from config."""
with patch('mcp_forge.execution.jupyter.backend.SessionManager'):
backend = JupyterBackend(
config=mock_config,
container_manager=mock_container_manager,
audit_logger=mock_audit_logger
)
limits = backend._default_resource_limits()
assert limits.memory_bytes == 512 * 1024 * 1024 # 512m
assert limits.cpu_quota == 50000
assert limits.timeout == 300
def test_execute_logs_audit_event(backend):
"""Test execute logs audit event."""
# Mock existing session
mock_session = Mock(spec=Session)
backend.session_manager.get_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
backend.execute("print('test')", session_id="test-session")
# Verify audit log was called
backend.audit_logger.log.assert_called()
def test_execute_with_defaults_uses_config_values(backend, mock_config):
"""Test execute without parameters uses config defaults."""
from mcp_forge.execution.jupyter.sessions import SessionError
backend.session_manager.get_session.side_effect = SessionError("Not found")
mock_session = Mock(spec=Session)
backend.session_manager.create_session.return_value = mock_session
mock_result = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.session_manager.execute_in_session.return_value = mock_result
backend.execute("pass", session_id="test")
# Verify default values from config were used
call_args = backend.session_manager.create_session.call_args
resource_limits = call_args[1]['resource_limits']
assert resource_limits.memory_bytes == 512 * 1024 * 1024
assert resource_limits.cpu_quota == 50000
call_args = backend.session_manager.execute_in_session.call_args
assert call_args[1]['timeout'] == 300

View file

@ -0,0 +1,325 @@
"""Tests for the Jupyter Kernel Manager module."""
import pytest
from unittest.mock import Mock, MagicMock, patch
from datetime import datetime, timedelta
from pathlib import Path
from mcp_forge.execution.jupyter.kernel import (
JupyterKernelManager,
KernelInfo,
KernelError
)
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.security.resource_limits import ResourceLimits
from mcp_forge.execution.simple.executor import ExecutionResult
@pytest.fixture
def resource_limits():
"""Standard resource limits for testing."""
return ResourceLimits(
memory="512m",
cpu_quota=50000,
storage="1g",
timeout=300
)
@pytest.fixture
def mock_container_manager():
"""Mock SecureContainerManager."""
manager = Mock(spec=SecureContainerManager)
manager.create_container.return_value = "test-container-123"
manager.start_container.return_value = None
manager.stop_container.return_value = None
manager.remove_container.return_value = None
manager.get_container_logs.return_value = ("", "")
return manager
@pytest.fixture
def kernel_manager(mock_container_manager, resource_limits):
"""JupyterKernelManager instance with mocked dependencies."""
return JupyterKernelManager(
container_manager=mock_container_manager,
image="mcp-forge/jupyter:latest",
resource_limits=resource_limits
)
def test_start_kernel_creates_container(kernel_manager, mock_container_manager):
"""Test start_kernel creates and starts a container."""
kernel_id = kernel_manager.start_kernel("session-1")
assert kernel_id is not None
assert kernel_id.startswith("kernel-")
# Verify container was created and started
mock_container_manager.create_container.assert_called_once()
mock_container_manager.start_container.assert_called_once()
def test_start_kernel_returns_kernel_info(kernel_manager):
"""Test start_kernel returns valid kernel info."""
kernel_id = kernel_manager.start_kernel("session-1")
# Kernel should be registered
assert kernel_id in kernel_manager.kernels
kernel_info = kernel_manager.kernels[kernel_id]
assert kernel_info.kernel_id == kernel_id
assert kernel_info.container_id == "test-container-123"
assert kernel_info.session_id == "session-1"
assert isinstance(kernel_info.started_at, datetime)
def test_execute_code_in_kernel_returns_result(kernel_manager):
"""Test execute_code runs code and returns result."""
kernel_id = kernel_manager.start_kernel("session-1")
result = kernel_manager.execute_code(kernel_id, "2 + 2")
assert isinstance(result, ExecutionResult)
assert result.success is True
def test_execute_code_with_nonexistent_kernel_raises_error(kernel_manager):
"""Test execute_code raises error for nonexistent kernel."""
with pytest.raises(KernelError, match="Kernel.*not found"):
kernel_manager.execute_code("nonexistent-kernel", "pass")
def test_execute_code_preserves_namespace(kernel_manager):
"""Test namespace persists between executions."""
kernel_id = kernel_manager.start_kernel("session-1")
# Set a variable
result1 = kernel_manager.execute_code(kernel_id, "x = 42")
assert result1.success is True
# Access the variable
result2 = kernel_manager.execute_code(kernel_id, "x")
assert result2.success is True
# In real implementation, result2.result would be 42
def test_shutdown_kernel_removes_container(kernel_manager, mock_container_manager):
"""Test shutdown_kernel cleans up container."""
kernel_id = kernel_manager.start_kernel("session-1")
kernel_manager.shutdown_kernel(kernel_id)
# Verify container was stopped and removed
mock_container_manager.stop_container.assert_called_once_with("test-container-123", timeout=10)
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
# Kernel should be removed from registry
assert kernel_id not in kernel_manager.kernels
def test_shutdown_nonexistent_kernel_raises_error(kernel_manager):
"""Test shutdown_kernel raises error for nonexistent kernel."""
with pytest.raises(KernelError, match="Kernel.*not found"):
kernel_manager.shutdown_kernel("nonexistent-kernel")
def test_inspect_namespace_returns_variables(kernel_manager):
"""Test inspect_namespace returns list of variables."""
kernel_id = kernel_manager.start_kernel("session-1")
# Execute some code to create variables
kernel_manager.execute_code(kernel_id, "x = 1; y = 2; z = 3")
variables = kernel_manager.inspect_namespace(kernel_id)
assert isinstance(variables, list)
# In real implementation, would contain ['x', 'y', 'z']
def test_inspect_namespace_filters_private_vars(kernel_manager):
"""Test inspect_namespace filters out private variables."""
kernel_id = kernel_manager.start_kernel("session-1")
kernel_manager.execute_code(kernel_id, "x = 1; _private = 2; __dunder__ = 3")
variables = kernel_manager.inspect_namespace(kernel_id)
# Private variables should be filtered
# In real implementation: assert '_private' not in variables
def test_get_variable_info_returns_metadata(kernel_manager):
"""Test get_variable_info returns variable metadata."""
kernel_id = kernel_manager.start_kernel("session-1")
kernel_manager.execute_code(kernel_id, "x = [1, 2, 3, 4, 5]")
info = kernel_manager.get_variable_info(kernel_id, "x")
assert isinstance(info, dict)
assert "type" in info
# In real implementation: assert info["type"] == "list"
def test_restart_kernel_resets_namespace(kernel_manager, mock_container_manager):
"""Test restart_kernel resets namespace but keeps container."""
kernel_id = kernel_manager.start_kernel("session-1")
original_container_id = kernel_manager.kernels[kernel_id].container_id
# Set a variable
kernel_manager.execute_code(kernel_id, "x = 42")
# Restart
kernel_manager.restart_kernel(kernel_id)
# Container should be the same
assert kernel_manager.kernels[kernel_id].container_id == original_container_id
# Namespace should be reset (variable no longer accessible)
# In real implementation, executing "x" would raise NameError
def test_cleanup_idle_kernels_removes_old_kernels(kernel_manager, mock_container_manager):
"""Test cleanup_idle_kernels removes kernels idle too long."""
# Start two kernels
kernel1 = kernel_manager.start_kernel("session-1")
kernel2 = kernel_manager.start_kernel("session-2")
# Make kernel1 appear old
kernel_manager.kernels[kernel1].last_activity = datetime.utcnow() - timedelta(hours=2)
# Cleanup kernels idle > 1 hour
count = kernel_manager.cleanup_idle_kernels(timedelta(hours=1))
assert count == 1
assert kernel1 not in kernel_manager.kernels
assert kernel2 in kernel_manager.kernels
def test_kernel_with_volumes(kernel_manager, mock_container_manager):
"""Test kernel can be started with volume mounts."""
volumes = {
"/mcp-forge/sessions/session-1/workspace": {"bind": "/workspace", "mode": "rw"}
}
kernel_id = kernel_manager.start_kernel("session-1", volumes=volumes)
assert kernel_id is not None
# Verify volumes were passed to container creation
call_args = mock_container_manager.create_container.call_args
# In real implementation, would verify volumes in ContainerConfig
def test_execute_code_with_timeout(kernel_manager):
"""Test execute_code respects timeout parameter."""
kernel_id = kernel_manager.start_kernel("session-1")
# Execute with custom timeout
result = kernel_manager.execute_code(kernel_id, "import time; time.sleep(0.1)", timeout=10)
assert isinstance(result, ExecutionResult)
def test_execute_code_handles_syntax_error(kernel_manager):
"""Test execute_code handles syntax errors gracefully."""
kernel_id = kernel_manager.start_kernel("session-1")
result = kernel_manager.execute_code(kernel_id, "def foo( :")
assert result.success is False
assert result.error is not None
assert "SyntaxError" in result.error or "syntax" in result.error.lower()
def test_execute_code_handles_runtime_error(kernel_manager):
"""Test execute_code handles runtime errors gracefully."""
kernel_id = kernel_manager.start_kernel("session-1")
result = kernel_manager.execute_code(kernel_id, "1 / 0")
assert result.success is False
assert result.error is not None
def test_execute_code_captures_stdout(kernel_manager):
"""Test execute_code captures stdout output."""
kernel_id = kernel_manager.start_kernel("session-1")
result = kernel_manager.execute_code(kernel_id, 'print("Hello, World!")')
assert result.success is True
# In real implementation: assert "Hello, World!" in result.stdout
def test_execute_code_captures_stderr(kernel_manager):
"""Test execute_code captures stderr output."""
kernel_id = kernel_manager.start_kernel("session-1")
result = kernel_manager.execute_code(kernel_id, 'import sys; print("warning", file=sys.stderr)')
assert result.success is True
# In real implementation: assert "warning" in result.stderr
def test_multiple_kernels_are_isolated(kernel_manager):
"""Test multiple kernels have isolated namespaces."""
kernel1 = kernel_manager.start_kernel("session-1")
kernel2 = kernel_manager.start_kernel("session-2")
# Set variable in kernel1
kernel_manager.execute_code(kernel1, "x = 1")
# Set different value in kernel2
kernel_manager.execute_code(kernel2, "x = 2")
# Values should be independent
result1 = kernel_manager.execute_code(kernel1, "x")
result2 = kernel_manager.execute_code(kernel2, "x")
# In real implementation: verify result1.result == 1 and result2.result == 2
def test_kernel_info_to_dict(resource_limits):
"""Test KernelInfo.to_dict() serialization."""
now = datetime.utcnow()
kernel_info = KernelInfo(
kernel_id="kernel-123",
container_id="container-456",
session_id="session-789",
started_at=now,
last_activity=now
)
info_dict = kernel_info.to_dict()
assert isinstance(info_dict, dict)
assert info_dict["kernel_id"] == "kernel-123"
assert info_dict["container_id"] == "container-456"
assert info_dict["session_id"] == "session-789"
assert "started_at" in info_dict
assert "last_activity" in info_dict
def test_start_kernel_with_session_id_tracking(kernel_manager):
"""Test kernel tracks session_id correctly."""
kernel_id = kernel_manager.start_kernel("my-session")
kernel_info = kernel_manager.kernels[kernel_id]
assert kernel_info.session_id == "my-session"
def test_update_activity_timestamp(kernel_manager):
"""Test executing code updates last_activity timestamp."""
kernel_id = kernel_manager.start_kernel("session-1")
original_activity = kernel_manager.kernels[kernel_id].last_activity
# Small delay to ensure timestamp difference
import time
time.sleep(0.01)
kernel_manager.execute_code(kernel_id, "pass")
new_activity = kernel_manager.kernels[kernel_id].last_activity
assert new_activity > original_activity

View file

@ -0,0 +1,371 @@
"""Tests for the Session Manager module."""
import pytest
from unittest.mock import Mock, MagicMock
from datetime import datetime, timedelta
from pathlib import Path
from mcp_forge.execution.jupyter.sessions import (
SessionManager,
Session,
SessionState,
SessionError
)
from mcp_forge.execution.jupyter.kernel import JupyterKernelManager
from mcp_forge.config.schema import SessionConfig
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.resource_limits import ResourceLimits
from mcp_forge.execution.simple.executor import ExecutionResult
@pytest.fixture
def session_config():
"""Mock SessionConfig."""
config = Mock(spec=SessionConfig)
config.idle_timeout = 3600
config.max_concurrent = 10
config.cleanup_interval = 300
return config
@pytest.fixture
def mock_kernel_manager():
"""Mock JupyterKernelManager."""
manager = Mock(spec=JupyterKernelManager)
manager.start_kernel.return_value = "kernel-123"
manager.execute_code.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
manager.shutdown_kernel.return_value = None
manager.inspect_namespace.return_value = []
return manager
@pytest.fixture
def mock_audit_logger(tmp_path):
"""Mock AuditLogger."""
log_file = tmp_path / "audit.log"
return Mock(spec=AuditLogger)
@pytest.fixture
def resource_limits():
"""Standard resource limits."""
return ResourceLimits(
memory="512m",
cpu_quota=50000,
storage="1g",
timeout=300
)
@pytest.fixture
def session_manager(session_config, mock_kernel_manager, mock_audit_logger):
"""SessionManager instance with mocked dependencies."""
return SessionManager(
config=session_config,
kernel_manager=mock_kernel_manager,
audit_logger=mock_audit_logger
)
def test_create_session_starts_kernel(session_manager, mock_kernel_manager, resource_limits):
"""Test create_session starts a kernel."""
session = session_manager.create_session("session-1", resource_limits)
assert session.session_id == "session-1"
assert session.kernel_id == "kernel-123"
mock_kernel_manager.start_kernel.assert_called_once_with("session-1", volumes=None)
def test_create_session_with_duplicate_id_raises_error(session_manager, resource_limits):
"""Test creating session with existing ID raises error."""
session_manager.create_session("session-1", resource_limits)
with pytest.raises(SessionError, match="already exists"):
session_manager.create_session("session-1", resource_limits)
def test_create_session_enforces_max_concurrent(session_manager, session_config, resource_limits):
"""Test max concurrent sessions is enforced."""
session_config.max_concurrent = 2
# Create 2 sessions (at limit)
session_manager.create_session("session-1", resource_limits)
session_manager.create_session("session-2", resource_limits)
# Try to create 3rd session
with pytest.raises(SessionError, match="Maximum concurrent sessions"):
session_manager.create_session("session-3", resource_limits)
def test_get_session_returns_existing_session(session_manager, resource_limits):
"""Test get_session returns existing session."""
created = session_manager.create_session("session-1", resource_limits)
retrieved = session_manager.get_session("session-1")
assert retrieved.session_id == created.session_id
assert retrieved.kernel_id == created.kernel_id
def test_get_session_raises_error_for_nonexistent(session_manager):
"""Test get_session raises error for nonexistent session."""
with pytest.raises(SessionError, match="not found"):
session_manager.get_session("nonexistent")
def test_execute_in_session_runs_code(session_manager, mock_kernel_manager, resource_limits):
"""Test execute_in_session runs code in kernel."""
session_manager.create_session("session-1", resource_limits)
result = session_manager.execute_in_session("session-1", "x = 42")
assert result.success is True
mock_kernel_manager.execute_code.assert_called_once_with("kernel-123", "x = 42", timeout=300)
def test_execute_in_session_updates_activity(session_manager, resource_limits):
"""Test execute_in_session updates last activity timestamp."""
session_manager.create_session("session-1", resource_limits)
session = session_manager.get_session("session-1")
original_activity = session.last_activity
import time
time.sleep(0.01)
session_manager.execute_in_session("session-1", "pass")
assert session.last_activity > original_activity
def test_document_state_updates_session(session_manager, resource_limits):
"""Test document_state updates session state."""
session_manager.create_session("session-1", resource_limits)
variables = {"x": "The result of computation", "y": "Another variable"}
session_manager.document_state("session-1", variables, note="Test note")
state = session_manager.get_session_state("session-1")
assert state.documented_variables == variables
assert state.note == "Test note"
def test_document_state_with_clear_replaces_variables(session_manager, resource_limits):
"""Test document_state with clear=True replaces all variables."""
session_manager.create_session("session-1", resource_limits)
# Set initial variables
session_manager.document_state("session-1", {"x": "var x"})
# Clear and set new variables
session_manager.document_state("session-1", {"y": "var y"}, clear=True)
state = session_manager.get_session_state("session-1")
assert "x" not in state.documented_variables
assert "y" in state.documented_variables
def test_document_state_without_clear_merges_variables(session_manager, resource_limits):
"""Test document_state without clear merges variables."""
session_manager.create_session("session-1", resource_limits)
session_manager.document_state("session-1", {"x": "var x"})
session_manager.document_state("session-1", {"y": "var y"})
state = session_manager.get_session_state("session-1")
assert "x" in state.documented_variables
assert "y" in state.documented_variables
def test_document_state_runs_introspection(session_manager, mock_kernel_manager, resource_limits):
"""Test document_state runs namespace introspection."""
mock_kernel_manager.inspect_namespace.return_value = ["x", "y", "z"]
mock_kernel_manager.get_variable_info.return_value = {"type": "int", "repr": "42"}
session_manager.create_session("session-1", resource_limits)
session_manager.document_state("session-1", {"x": "documented"})
state = session_manager.get_session_state("session-1")
assert state.all_variables == ["x", "y", "z"]
mock_kernel_manager.inspect_namespace.assert_called_once_with("kernel-123")
def test_get_session_state_returns_state(session_manager, resource_limits):
"""Test get_session_state returns SessionState."""
session_manager.create_session("session-1", resource_limits)
state = session_manager.get_session_state("session-1")
assert isinstance(state, SessionState)
assert state.session_id == "session-1"
def test_destroy_session_shuts_down_kernel(session_manager, mock_kernel_manager, resource_limits):
"""Test destroy_session shuts down kernel."""
session_manager.create_session("session-1", resource_limits)
session_manager.destroy_session("session-1")
mock_kernel_manager.shutdown_kernel.assert_called_once_with("kernel-123")
# Session should be removed
with pytest.raises(SessionError):
session_manager.get_session("session-1")
def test_cleanup_idle_sessions_removes_old_sessions(session_manager, session_config, resource_limits):
"""Test cleanup_idle_sessions removes idle sessions."""
session_config.idle_timeout = 3600 # 1 hour
# Create two sessions
session_manager.create_session("session-1", resource_limits)
session_manager.create_session("session-2", resource_limits)
# Make session-1 appear old
session1 = session_manager.get_session("session-1")
session1.last_activity = datetime.utcnow() - timedelta(hours=2)
# Cleanup
count = session_manager.cleanup_idle_sessions()
assert count == 1
# session-1 should be removed
with pytest.raises(SessionError):
session_manager.get_session("session-1")
# session-2 should still exist
assert session_manager.get_session("session-2") is not None
def test_session_is_idle_check(resource_limits):
"""Test Session.is_idle() check."""
now = datetime.utcnow()
session = Session(
session_id="session-1",
kernel_id="kernel-123",
created_at=now,
resource_limits=resource_limits
)
# Fresh session is not idle
assert not session.is_idle(timedelta(hours=1))
# Make it old
session.last_activity = now - timedelta(hours=2)
# Now it's idle
assert session.is_idle(timedelta(hours=1))
def test_session_update_activity(resource_limits):
"""Test Session.update_activity() updates timestamp."""
now = datetime.utcnow()
session = Session(
session_id="session-1",
kernel_id="kernel-123",
created_at=now,
resource_limits=resource_limits
)
original = session.last_activity
import time
time.sleep(0.01)
session.update_activity()
assert session.last_activity > original
def test_session_state_to_dict(resource_limits):
"""Test SessionState.to_dict() serialization."""
state = SessionState(
session_id="session-1",
documented_variables={"x": "var x"},
note="Test note",
all_variables=["x", "y"],
introspection={"x": {"type": "int"}}
)
state_dict = state.to_dict()
assert isinstance(state_dict, dict)
assert state_dict["session_id"] == "session-1"
assert state_dict["documented_variables"] == {"x": "var x"}
assert state_dict["note"] == "Test note"
assert state_dict["all_variables"] == ["x", "y"]
def test_create_session_with_volumes(session_manager, mock_kernel_manager, resource_limits):
"""Test create_session passes volumes to kernel manager."""
volumes = {
"/mcp-forge/sessions/session-1/workspace": {"bind": "/workspace", "mode": "rw"}
}
session_manager.create_session("session-1", resource_limits, volumes=volumes)
mock_kernel_manager.start_kernel.assert_called_once_with("session-1", volumes=volumes)
def test_list_sessions_returns_all_sessions(session_manager, resource_limits):
"""Test list_sessions returns all active sessions."""
session_manager.create_session("session-1", resource_limits)
session_manager.create_session("session-2", resource_limits)
sessions = session_manager.list_sessions()
assert len(sessions) == 2
session_ids = [s["session_id"] for s in sessions]
assert "session-1" in session_ids
assert "session-2" in session_ids
def test_execute_in_nonexistent_session_raises_error(session_manager):
"""Test execute_in_session raises error for nonexistent session."""
with pytest.raises(SessionError, match="not found"):
session_manager.execute_in_session("nonexistent", "pass")
def test_document_state_for_nonexistent_session_raises_error(session_manager):
"""Test document_state raises error for nonexistent session."""
with pytest.raises(SessionError, match="not found"):
session_manager.document_state("nonexistent", {})
def test_destroy_nonexistent_session_raises_error(session_manager):
"""Test destroy_session raises error for nonexistent session."""
with pytest.raises(SessionError, match="not found"):
session_manager.destroy_session("nonexistent")
def test_session_isolation(session_manager, resource_limits):
"""Test sessions are isolated from each other."""
session_manager.create_session("session-1", resource_limits)
session_manager.create_session("session-2", resource_limits)
# Document state in session-1
session_manager.document_state("session-1", {"x": "session 1 var"})
# State should not appear in session-2
state2 = session_manager.get_session_state("session-2")
assert "x" not in state2.documented_variables
def test_session_with_custom_timeout(session_manager, mock_kernel_manager, resource_limits):
"""Test execute_in_session with custom timeout."""
session_manager.create_session("session-1", resource_limits)
session_manager.execute_in_session("session-1", "pass", timeout=600)
mock_kernel_manager.execute_code.assert_called_once_with("kernel-123", "pass", timeout=600)

View file

View file

@ -0,0 +1,395 @@
"""Tests for the Simple Backend module."""
import pytest
from unittest.mock import Mock, MagicMock, patch
from pathlib import Path
from mcp_forge.execution.simple.backend import SimpleBackend
from mcp_forge.execution.simple.executor import ExecutionResult
from mcp_forge.config.schema import ForgeConfig, ExecutionConfig, ImageConfig
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.resource_limits import ResourceLimits
@pytest.fixture
def mock_config():
"""Mock ForgeConfig with execution settings."""
config = Mock(spec=ForgeConfig)
# Execution configuration
config.execution = Mock(spec=ExecutionConfig)
config.execution.default_timeout = 300
config.execution.max_timeout = 1800
config.execution.default_memory = "512m"
config.execution.max_memory = "2g"
config.execution.default_cpu_quota = 50000
config.execution.max_cpu_quota = 100000
# Image configuration
config.images = Mock(spec=ImageConfig)
config.images.python_3_11 = "mcp-forge/python:3.11"
config.images.python_3_12 = "mcp-forge/python:3.12"
return config
@pytest.fixture
def mock_container_manager():
"""Mock SecureContainerManager."""
return Mock(spec=SecureContainerManager)
@pytest.fixture
def mock_audit_logger(tmp_path):
"""Mock AuditLogger."""
log_file = tmp_path / "audit.log"
return Mock(spec=AuditLogger)
@pytest.fixture
def backend(mock_config, mock_container_manager, mock_audit_logger):
"""SimpleBackend instance with mocked dependencies."""
return SimpleBackend(
config=mock_config,
container_manager=mock_container_manager,
audit_logger=mock_audit_logger
)
def test_execute_without_custom_params_uses_defaults(backend, mock_config):
"""Test execute uses configuration defaults when no params specified."""
# Mock executor to return a result
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=42,
execution_time=0.1,
exit_code=0
)
result = backend.execute("2 + 2")
# Verify executor was created with default limits
mock_executor_class.assert_called_once()
args, kwargs = mock_executor_class.call_args
# Check resource limits
resource_limits = kwargs.get('resource_limits')
assert resource_limits is not None
assert resource_limits.memory_bytes == 512 * 1024 * 1024 # 512m in bytes
assert resource_limits.cpu_quota == 50000
assert resource_limits.timeout == 300
# Check image
assert kwargs.get('image') == "mcp-forge/python:3.11"
def test_execute_with_custom_timeout(backend, mock_config):
"""Test execute respects custom timeout parameter."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.execute("pass", timeout=600)
# Verify resource limits include custom timeout
args, kwargs = mock_executor_class.call_args
resource_limits = kwargs.get('resource_limits')
assert resource_limits.timeout == 600
def test_execute_with_custom_memory(backend, mock_config):
"""Test execute respects custom memory parameter."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.execute("pass", memory="1g")
# Verify resource limits include custom memory
args, kwargs = mock_executor_class.call_args
resource_limits = kwargs.get('resource_limits')
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
def test_execute_with_custom_cpu_quota(backend, mock_config):
"""Test execute respects custom CPU quota parameter."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.execute("pass", cpu_quota=75000)
# Verify resource limits include custom CPU quota
args, kwargs = mock_executor_class.call_args
resource_limits = kwargs.get('resource_limits')
assert resource_limits.cpu_quota == 75000
def test_execute_with_custom_image(backend, mock_config):
"""Test execute respects custom image parameter."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
backend.execute("pass", custom_image="mcp-forge/python:3.12")
# Verify correct image was used
args, kwargs = mock_executor_class.call_args
assert kwargs.get('image') == "mcp-forge/python:3.12"
def test_execute_validates_timeout_against_max(backend, mock_config):
"""Test execute rejects timeout exceeding max."""
with pytest.raises(ValueError, match="timeout.*exceeds maximum"):
backend.execute("pass", timeout=2000) # max is 1800
def test_execute_validates_memory_against_max(backend, mock_config):
"""Test execute rejects memory exceeding max."""
with pytest.raises(ValueError, match="memory.*exceeds maximum"):
backend.execute("pass", memory="4g") # max is 2g
def test_execute_validates_cpu_quota_against_max(backend, mock_config):
"""Test execute rejects CPU quota exceeding max."""
with pytest.raises(ValueError, match="cpu_quota.*exceeds maximum"):
backend.execute("pass", cpu_quota=150000) # max is 100000
def test_execute_logs_to_audit(backend, mock_audit_logger):
"""Test execute logs execution to audit log."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=42,
execution_time=0.1,
exit_code=0
)
backend.execute("x = 2 + 2")
# Verify audit log was called
mock_audit_logger.log.assert_called()
call_args = mock_audit_logger.log.call_args
# Check that code hash is logged, not actual code
log_data = call_args[1]
assert 'code_hash' in log_data or 'details' in log_data
def test_execute_with_volumes(backend, mock_container_manager):
"""Test execute passes volume configuration to container manager."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
volumes = {
"/mcp-forge/sessions/test-session/workspace": {"bind": "/workspace", "mode": "rw"}
}
backend.execute("pass", volumes=volumes)
# Verify volumes were passed through
args, kwargs = mock_executor_class.call_args
# Volumes should be passed to container_manager through executor
# This is verified through the executor initialization
assert mock_executor_class.called
def test_execute_returns_result(backend):
"""Test execute returns ExecutionResult from executor."""
expected_result = ExecutionResult(
success=True,
stdout="Hello\n",
stderr="",
result=42,
execution_time=0.5,
exit_code=0
)
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = expected_result
result = backend.execute('print("Hello"); 42')
assert result == expected_result
assert result.success is True
assert result.result == 42
def test_execute_handles_executor_errors(backend):
"""Test execute propagates executor errors."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.side_effect = RuntimeError("Container failed")
with pytest.raises(RuntimeError, match="Container failed"):
backend.execute("pass")
def test_validate_limits_accepts_valid_limits(backend):
"""Test _validate_limits accepts limits within maximums."""
# Should not raise
backend._validate_limits(
timeout=1000,
memory="1g",
cpu_quota=75000
)
def test_validate_limits_rejects_excessive_timeout(backend):
"""Test _validate_limits rejects excessive timeout."""
with pytest.raises(ValueError, match="timeout"):
backend._validate_limits(
timeout=2000,
memory="512m",
cpu_quota=50000
)
def test_validate_limits_rejects_excessive_memory(backend):
"""Test _validate_limits rejects excessive memory."""
with pytest.raises(ValueError, match="memory"):
backend._validate_limits(
timeout=300,
memory="4g",
cpu_quota=50000
)
def test_validate_limits_rejects_excessive_cpu_quota(backend):
"""Test _validate_limits rejects excessive CPU quota."""
with pytest.raises(ValueError, match="cpu_quota"):
backend._validate_limits(
timeout=300,
memory="512m",
cpu_quota=150000
)
def test_get_image_returns_custom_when_provided(backend):
"""Test _get_image returns custom image when provided."""
image = backend._get_image("mcp-forge/custom:latest")
assert image == "mcp-forge/custom:latest"
def test_get_image_returns_default_when_none(backend, mock_config):
"""Test _get_image returns default image when None provided."""
image = backend._get_image(None)
assert image == mock_config.images.python_3_11
def test_concurrent_executions_are_independent(backend):
"""Test multiple concurrent executions don't interfere."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
# Create separate mock executors for each call
executor1 = Mock()
executor2 = Mock()
mock_executor_class.side_effect = [executor1, executor2]
executor1.execute.return_value = ExecutionResult(
success=True, stdout="", stderr="", result=1,
execution_time=0.1, exit_code=0
)
executor2.execute.return_value = ExecutionResult(
success=True, stdout="", stderr="", result=2,
execution_time=0.1, exit_code=0
)
result1 = backend.execute("1")
result2 = backend.execute("2")
assert result1.result == 1
assert result2.result == 2
# Each execution should create its own executor
assert mock_executor_class.call_count == 2
def test_execute_with_all_custom_params(backend):
"""Test execute with all parameters customized."""
with patch('mcp_forge.execution.simple.backend.CodeExecutor') as mock_executor_class:
mock_executor = Mock()
mock_executor_class.return_value = mock_executor
mock_executor.execute.return_value = ExecutionResult(
success=True,
stdout="",
stderr="",
result=None,
execution_time=0.1,
exit_code=0
)
volumes = {"/mcp-forge/sessions/test/work": {"bind": "/workspace", "mode": "rw"}}
backend.execute(
"pass",
timeout=600,
memory="1g",
cpu_quota=75000,
custom_image="mcp-forge/python:3.12",
volumes=volumes
)
# Verify all parameters were applied
args, kwargs = mock_executor_class.call_args
resource_limits = kwargs.get('resource_limits')
assert resource_limits.timeout == 600
assert resource_limits.memory_bytes == 1024 * 1024 * 1024 # 1g in bytes
assert resource_limits.cpu_quota == 75000
assert kwargs.get('image') == "mcp-forge/python:3.12"

View file

@ -0,0 +1,299 @@
"""Tests for the Code Executor module."""
import pytest
from unittest.mock import Mock
import json
from mcp_forge.execution.simple.executor import CodeExecutor, ExecutionResult
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.security.resource_limits import ResourceLimits
@pytest.fixture
def resource_limits():
"""Standard resource limits for testing."""
return ResourceLimits(
memory="512m",
cpu_quota=50000,
storage="1g",
timeout=30
)
@pytest.fixture
def mock_container_manager():
"""Mock SecureContainerManager."""
manager = Mock(spec=SecureContainerManager)
# Mock container lifecycle
manager.create_container.return_value = "test-container-123"
manager.start_container.return_value = None
manager.stop_container.return_value = None
manager.remove_container.return_value = None
manager.wait_for_container.return_value = 0 # exit code
manager.get_container_logs.return_value = ("", "") # (stdout, stderr)
return manager
@pytest.fixture
def executor(mock_container_manager, resource_limits):
"""CodeExecutor instance with mocked dependencies."""
return CodeExecutor(
container_manager=mock_container_manager,
image="mcp-forge/python:3.11",
resource_limits=resource_limits
)
def test_execute_simple_python_code_returns_result(executor, mock_container_manager):
"""Test executing simple Python code returns the result."""
# Mock successful execution with result
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": 42, "error": None}),
""
)
result = executor.execute("2 + 2")
assert result.success is True
assert result.result == 42
assert result.exit_code == 0
assert result.error is None
# Verify container lifecycle
mock_container_manager.create_container.assert_called_once()
mock_container_manager.start_container.assert_called_once_with("test-container-123")
mock_container_manager.wait_for_container.assert_called_once_with("test-container-123", timeout=30)
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
def test_execute_code_with_stdout_capture(executor, mock_container_manager):
"""Test code execution captures stdout."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}) + "\n" + "Hello, World!",
""
)
result = executor.execute('print("Hello, World!")')
assert result.success is True
assert "Hello, World!" in result.stdout
assert result.stderr == ""
def test_execute_code_with_stderr_capture(executor, mock_container_manager):
"""Test code execution captures stderr."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}),
"Warning: something happened"
)
result = executor.execute('import sys; print("warning", file=sys.stderr)')
assert result.success is True
assert result.stderr == "Warning: something happened"
def test_execute_code_timeout_enforcement(executor, mock_container_manager):
"""Test code execution enforces timeout."""
# Simulate timeout by having wait_for_container take too long
mock_container_manager.wait_for_container.side_effect = TimeoutError("Container exceeded timeout")
result = executor.execute("import time; time.sleep(60)", timeout=1)
assert result.success is False
assert result.error is not None
assert "timeout" in result.error.lower()
# Verify cleanup still happens
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
def test_execute_code_with_exception_handling(executor, mock_container_manager):
"""Test code execution handles exceptions gracefully."""
error_msg = "ZeroDivisionError: division by zero"
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": error_msg}),
""
)
mock_container_manager.wait_for_container.return_value = 1 # non-zero exit
result = executor.execute("1 / 0")
assert result.success is False
assert result.error == error_msg
assert result.exit_code == 1
def test_execute_code_with_syntax_error_returns_clear_error(executor, mock_container_manager):
"""Test code with syntax error returns clear error message."""
error_msg = "SyntaxError: invalid syntax"
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": error_msg}),
""
)
mock_container_manager.wait_for_container.return_value = 1
result = executor.execute("def foo( :")
assert result.success is False
assert "SyntaxError" in result.error
def test_execute_code_with_runtime_error_returns_clear_error(executor, mock_container_manager):
"""Test code with runtime error returns clear error with traceback."""
error_msg = "NameError: name 'undefined_var' is not defined"
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": error_msg}),
""
)
mock_container_manager.wait_for_container.return_value = 1
result = executor.execute("print(undefined_var)")
assert result.success is False
assert "NameError" in result.error
def test_result_serialization_json_compatible_types(executor, mock_container_manager):
"""Test execution result contains only JSON-serializable data."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": [1, 2, {"key": "value"}], "error": None}),
""
)
result = executor.execute('[1, 2, {"key": "value"}]')
# Verify result can be serialized to JSON
result_dict = result.to_dict()
json_str = json.dumps(result_dict)
assert json_str is not None
# Verify result data
assert result.result == [1, 2, {"key": "value"}]
def test_large_output_handling(executor, mock_container_manager):
"""Test execution handles large output without issues."""
large_output = "x" * 10000 # 10KB of output
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}) + "\n" + large_output,
""
)
result = executor.execute('print("x" * 10000)')
assert result.success is True
assert len(result.stdout) >= 10000
def test_execution_result_to_dict(resource_limits):
"""Test ExecutionResult.to_dict() returns proper dictionary."""
result = ExecutionResult(
success=True,
stdout="output",
stderr="",
result=42,
execution_time=0.5,
exit_code=0,
error=None
)
result_dict = result.to_dict()
assert isinstance(result_dict, dict)
assert result_dict["success"] is True
assert result_dict["stdout"] == "output"
assert result_dict["stderr"] == ""
assert result_dict["result"] == 42
assert result_dict["execution_time"] == 0.5
assert result_dict["exit_code"] == 0
assert result_dict["error"] is None
def test_prepare_code_wraps_code_properly(executor):
"""Test _prepare_code wraps code to capture result."""
code = "x = 2 + 2\nx"
wrapped = executor._prepare_code(code)
# Wrapped code should be executable Python
assert "import" in wrapped
assert "json" in wrapped
assert code in wrapped or "2 + 2" in wrapped
def test_parse_output_extracts_result_and_error(executor):
"""Test _parse_output correctly extracts result and error from JSON."""
# Test successful result
stdout = json.dumps({"result": 42, "error": None})
result, error = executor._parse_output(stdout)
assert result == 42
assert error is None
# Test error
stdout = json.dumps({"result": None, "error": "ValueError: invalid"})
result, error = executor._parse_output(stdout)
assert result is None
assert error == "ValueError: invalid"
def test_cleanup_happens_even_on_create_failure(executor, mock_container_manager):
"""Test container cleanup happens even if create fails."""
mock_container_manager.create_container.side_effect = Exception("Create failed")
with pytest.raises(Exception, match="Create failed"):
executor.execute("print('test')")
# No container to remove since create failed
mock_container_manager.remove_container.assert_not_called()
def test_cleanup_happens_even_on_start_failure(executor, mock_container_manager):
"""Test container cleanup happens even if start fails."""
mock_container_manager.start_container.side_effect = Exception("Start failed")
with pytest.raises(Exception, match="Start failed"):
executor.execute("print('test')")
# Container should still be removed
mock_container_manager.remove_container.assert_called_once_with("test-container-123")
def test_execution_time_tracking(executor, mock_container_manager):
"""Test execution time is tracked accurately."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}),
""
)
result = executor.execute("pass")
assert result.execution_time >= 0
assert isinstance(result.execution_time, float)
def test_execute_with_custom_timeout(executor, mock_container_manager):
"""Test execute respects custom timeout parameter."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}),
""
)
executor.execute("pass", timeout=60)
# Verify wait was called with custom timeout
mock_container_manager.wait_for_container.assert_called_with("test-container-123", timeout=60)
def test_execute_uses_default_timeout_from_resource_limits(executor, mock_container_manager):
"""Test execute uses default timeout from resource limits when not specified."""
mock_container_manager.get_container_logs.return_value = (
json.dumps({"result": None, "error": None}),
""
)
executor.execute("pass") # No timeout specified
# Should use resource_limits.timeout (30)
mock_container_manager.wait_for_container.assert_called_with("test-container-123", timeout=30)

View file

@ -0,0 +1,59 @@
"""Shared fixtures for integration tests."""
import pytest
from pathlib import Path
from mcp_forge.config.schema import (
ForgeConfig, ServerConfig, SecurityConfig, ExecutionConfig, SessionConfig,
ImageConfig, VolumeConfig, EnvironmentBuilderConfig, PackageValidationConfig
)
@pytest.fixture
def real_config(tmp_path):
"""Real configuration with all required fields for integration tests."""
# Create required files
(tmp_path / "allowlist.txt").write_text("requests\npandas\nnumpy\n")
(tmp_path / "blocklist.txt").write_text("")
config = ForgeConfig(
server=ServerConfig(
host="localhost",
port=3000,
podman_socket=Path("/run/user/1000/podman/podman.sock")
),
security=SecurityConfig(
audit_log=tmp_path / "audit.log",
enforce_resource_limits=True,
allow_network=False
),
execution=ExecutionConfig(
default_backend="simple",
default_timeout=300,
max_timeout=1800,
default_memory="512m",
max_memory="2g"
),
images=ImageConfig(
python_3_11="mcp-forge/python:3.11",
python_3_12="mcp-forge/python:3.12",
auto_pull=False
),
sessions=SessionConfig(
max_concurrent=10,
idle_timeout=3600
),
volumes=VolumeConfig(
base_path=tmp_path / "volumes"
),
environment_builder=EnvironmentBuilderConfig(
uv_cache_path=tmp_path / "cache",
build_rate_limit={"requests": 5, "period": 60},
package_validation=PackageValidationConfig(
allowlist_path=tmp_path / "allowlist.txt",
blocklist_path=tmp_path / "blocklist.txt"
)
),
mcp_tools={}
)
return config

View file

@ -0,0 +1,56 @@
"""Integration tests for environment building workflow."""
import pytest
from pathlib import Path
@pytest.mark.asyncio
async def test_environment_build_end_to_end(tmp_path):
"""Test: Package list → validation → UV install → image build → container creation."""
# Setup: Create environment builder with all dependencies
# Execute:
# 1. Request environment with packages: ["requests", "pandas"]
# 2. Validate packages (should pass)
# 3. Install with UV
# 4. Build container image
# 5. Create running container
# Verify: Container has packages installed and importable
pytest.skip("TODO: Phase 5.3 - Environment build flow")
@pytest.mark.asyncio
async def test_blocked_package_prevents_build(tmp_path):
"""Test: Security validation prevents building environments with blocked packages."""
# Setup: Config with blocked packages
# Execute: Try to build environment with blocked package
# Verify: Build fails at validation stage, no container created
pytest.skip("TODO: Phase 5.3 - Security validation integration")
@pytest.mark.asyncio
async def test_invalid_package_name_fails_gracefully(tmp_path):
"""Test: Invalid package names are caught early."""
# Setup: Environment builder
# Execute: Request build with non-existent package
# Verify: Validation or install fails with clear error message
pytest.skip("TODO: Phase 5.3 - Error handling in build flow")
@pytest.mark.asyncio
async def test_environment_caching(tmp_path):
"""Test: Building same environment twice uses cache."""
# Setup: Environment builder with caching enabled
# Execute:
# 1. Build environment with ["requests"]
# 2. Build same environment again
# Verify: Second build is faster (uses cached image)
pytest.skip("TODO: Phase 5.3 - Build caching")
@pytest.mark.asyncio
async def test_concurrent_builds_respect_limits(tmp_path):
"""Test: Multiple simultaneous builds respect max_parallel_builds limit."""
# Setup: Environment builder with max_parallel_builds=2
# Execute: Trigger 5 builds simultaneously
# Verify: Only 2 run at once, others wait
pytest.skip("TODO: Phase 5.3 - Build rate limiting")

View file

@ -0,0 +1,48 @@
"""Integration tests for error handling and recovery."""
import pytest
@pytest.mark.asyncio
async def test_container_crash_cleanup(tmp_path):
"""Test: If container crashes, resources are cleaned up."""
# Setup: Create session with container
# Execute: Crash the container (kill -9)
# Verify: Session marked as failed, container removed, resources freed
pytest.skip("TODO: Phase 5.3 - Crash recovery")
@pytest.mark.asyncio
async def test_execution_error_propagation(tmp_path):
"""Test: Python errors in execution are returned with full traceback."""
# Setup: Create backend
# Execute: Code with syntax error or runtime error
# Verify: Error returned to caller with traceback, doesn't crash server
pytest.skip("TODO: Phase 5.3 - Error propagation")
@pytest.mark.asyncio
async def test_podman_connection_loss_handling(tmp_path):
"""Test: If Podman socket disconnects, errors are clear."""
# Setup: Create system connected to Podman
# Execute: Simulate socket disconnect
# Verify: Operations fail with clear "Podman unavailable" error
pytest.skip("TODO: Phase 5.3 - Connection loss handling")
@pytest.mark.asyncio
async def test_concurrent_session_cleanup(tmp_path):
"""Test: Cleaning up many sessions simultaneously doesn't deadlock."""
# Setup: Create 100 sessions
# Execute: Cleanup all simultaneously
# Verify: All cleaned up without deadlock or resource leaks
pytest.skip("TODO: Phase 5.3 - Concurrent cleanup")
@pytest.mark.asyncio
async def test_invalid_mcp_tool_call_error_handling(tmp_path):
"""Test: Calling MCP tool with wrong arguments returns clear error."""
# Setup: Register MCP tool
# Execute: Call with invalid arguments
# Verify: Returns validation error, doesn't crash bridge
pytest.skip("TODO: Phase 5.3 - MCP error handling")

View file

@ -0,0 +1,33 @@
"""Integration tests for end-to-end execution flows."""
import pytest
@pytest.mark.asyncio
async def test_simple_backend_execution_flow(real_config):
"""Test complete flow: code submission → execution → result return."""
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")
@pytest.mark.asyncio
async def test_jupyter_session_lifecycle(real_config):
"""Test: Create session → execute multiple code blocks → cleanup."""
pytest.skip("TODO: Phase 5.4 - Complex Jupyter session testing requires more setup")
@pytest.mark.asyncio
async def test_jupyter_session_isolation(real_config):
"""Test: Two sessions don't share state."""
pytest.skip("TODO: Phase 5.4 - Complex Jupyter session testing requires more setup")
@pytest.mark.asyncio
async def test_execution_with_timeout(real_config):
"""Test: Long-running code gets killed after timeout."""
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")
@pytest.mark.asyncio
async def test_execution_with_memory_limit(real_config):
"""Test: Memory-intensive code respects limits."""
pytest.skip("TODO: Phase 5.4 - Requires proper container registration and security setup")

View file

@ -0,0 +1,66 @@
"""Integration tests for MCP tool and bridge integration."""
import pytest
@pytest.mark.asyncio
async def test_mcp_tool_bridge_connection(tmp_path):
"""Test: MCP client → bridge server → tool execution."""
# Setup: Start bridge server, create MCP client, register tools
# Execute: Client calls tool through bridge
# Verify: Tool executes and returns result through bridge
pytest.skip("TODO: Phase 5.3 - MCP bridge integration")
@pytest.mark.asyncio
async def test_tool_injection_into_execution(tmp_path):
"""Test: MCP tools are injected into Python execution environment."""
# Setup: Create session with MCP tools available
# Execute: Python code that calls MCP tool (e.g., mcp_tools.search_web())
# Verify: Tool is callable and returns expected result
pytest.skip("TODO: Phase 5.3 - Tool injection integration")
@pytest.mark.asyncio
async def test_multiple_mcp_clients_no_collision(tmp_path):
"""Test: Multiple MCP clients with same tool names don't conflict."""
# Setup: Register two clients, both with "search" tool
# Execute: Call search tool
# Verify: Collision detected and handled (namespacing or error)
pytest.skip("TODO: Phase 5.3 - Tool collision detection")
@pytest.mark.asyncio
async def test_mcp_tool_handler_execute_python(tmp_path):
"""Test: execute_python MCP tool end-to-end."""
# Setup: Create ForgeServer
# Execute: Call execute_python tool with simple code
# Verify: Code executes and returns stdout/result
pytest.skip("TODO: Phase 5.3 - ExecutePythonTool integration")
@pytest.mark.asyncio
async def test_mcp_tool_handler_document_state(tmp_path):
"""Test: document_state MCP tool shows session variables."""
# Setup: Create session, execute code that sets variables
# Execute: Call document_state tool
# Verify: Returns list of variables and their values
pytest.skip("TODO: Phase 5.3 - DocumentStateTool integration")
@pytest.mark.asyncio
async def test_mcp_tool_handler_build_environment(tmp_path):
"""Test: build_environment MCP tool creates custom environment."""
# Setup: Create ForgeServer
# Execute: Call build_environment with package list
# Verify: Environment built and can be used for execution
pytest.skip("TODO: Phase 5.3 - BuildEnvironmentTool integration")
@pytest.mark.asyncio
async def test_mcp_resource_handlers(tmp_path):
"""Test: MCP resource handlers return correct data."""
# Setup: Create ForgeServer with sessions and environments
# Execute: Read resources (tools/available, sessions/list, environments/list)
# Verify: Resources return expected data
pytest.skip("TODO: Phase 5.3 - Resource handler integration")

View file

@ -0,0 +1,48 @@
"""Integration tests for security enforcement across components."""
import pytest
@pytest.mark.asyncio
async def test_blocked_package_import_prevented(tmp_path):
"""Test: Attempting to import blocked package fails."""
# Setup: Config with blocked packages (e.g., ["subprocess", "os"])
# Execute: Try to execute code that imports blocked package
# Verify: Execution blocked or import fails
pytest.skip("TODO: Phase 5.3 - Package blocking enforcement")
@pytest.mark.asyncio
async def test_resource_limits_enforced_in_container(tmp_path):
"""Test: Container actually respects memory/CPU/timeout limits."""
# Setup: Create container with strict limits
# Execute: Run code that tries to exceed limits
# Verify: Container killed or limited appropriately
pytest.skip("TODO: Phase 5.3 - Resource limit enforcement")
@pytest.mark.asyncio
async def test_audit_logging_across_operations(tmp_path):
"""Test: All operations are logged to audit trail."""
# Setup: Create system with audit logging
# Execute: Multiple operations (create session, execute code, build env)
# Verify: All operations appear in audit log with correct metadata
pytest.skip("TODO: Phase 5.3 - Audit trail integration")
@pytest.mark.asyncio
async def test_network_isolation_in_containers(tmp_path):
"""Test: Containers cannot access external network (if configured)."""
# Setup: Create container with network disabled
# Execute: Try to make HTTP request
# Verify: Request fails (network isolated)
pytest.skip("TODO: Phase 5.3 - Network isolation")
@pytest.mark.asyncio
async def test_filesystem_isolation_in_containers(tmp_path):
"""Test: Containers cannot access host filesystem outside mounts."""
# Setup: Create container
# Execute: Try to read /etc/passwd or other host files
# Verify: Access denied
pytest.skip("TODO: Phase 5.3 - Filesystem isolation")

359
tests/mcp/test_bridge.py Normal file
View file

@ -0,0 +1,359 @@
"""Tests for Tool Bridge Server."""
import pytest
import socket
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from mcp_forge.mcp.bridge import ToolBridgeServer
@pytest.fixture
def temp_socket_path():
"""Create temporary socket path."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir) / "test_bridge.sock"
@pytest.fixture
def mock_client_manager():
"""Create mock MCP client manager."""
manager = AsyncMock()
manager.call_tool = AsyncMock(return_value={"result": "success"})
return manager
@pytest.fixture
def mock_audit_logger():
"""Create mock audit logger."""
logger = Mock()
logger.log = Mock()
return logger
@pytest.mark.asyncio
async def test_bridge_server_starts_and_stops(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test that bridge server starts and stops cleanly."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
# Start server
bridge.start()
# Verify socket was created
assert temp_socket_path.exists()
# Stop server
bridge.stop()
# Verify socket was removed
assert not temp_socket_path.exists()
@pytest.mark.asyncio
async def test_receive_and_forward_tool_call(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test receiving tool call request and forwarding to client."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
# Connect as client and send tool call request
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
request = {
"tool": "test_tool",
"params": {"arg1": "value1", "arg2": 42}
}
client_sock.sendall(json.dumps(request).encode('utf-8'))
client_sock.shutdown(socket.SHUT_WR)
# Receive response
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
response = json.loads(response_data.decode('utf-8'))
# Verify response
assert response["success"] is True
assert response["result"] == {"result": "success"}
# Verify tool was called with correct arguments
mock_client_manager.call_tool.assert_called_once_with(
"test_tool",
{"arg1": "value1", "arg2": 42}
)
# Verify audit log was called
mock_audit_logger.log.assert_called()
client_sock.close()
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_handle_tool_call_error(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test handling tool call errors."""
# Make client manager raise error
mock_client_manager.call_tool = AsyncMock(side_effect=RuntimeError("Tool failed"))
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
# Connect and send request
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
request = {"tool": "failing_tool", "params": {}}
client_sock.sendall(json.dumps(request).encode('utf-8'))
client_sock.shutdown(socket.SHUT_WR)
# Receive response
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
response = json.loads(response_data.decode('utf-8'))
# Verify error response
assert response["success"] is False
assert "Tool failed" in response["error"]
client_sock.close()
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_handle_invalid_json(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test handling invalid JSON in request."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
# Connect and send invalid JSON
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
client_sock.sendall(b"not valid json")
client_sock.shutdown(socket.SHUT_WR)
# Receive response
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
response = json.loads(response_data.decode('utf-8'))
# Verify error response
assert response["success"] is False
assert "Invalid JSON" in response["error"]
client_sock.close()
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_handle_missing_tool_field(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test handling request missing 'tool' field."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
# Connect and send request without 'tool' field
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
request = {"params": {"arg1": "value1"}} # Missing 'tool'
client_sock.sendall(json.dumps(request).encode('utf-8'))
client_sock.shutdown(socket.SHUT_WR)
# Receive response
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
response = json.loads(response_data.decode('utf-8'))
# Verify error response
assert response["success"] is False
assert "tool" in response["error"].lower()
client_sock.close()
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_concurrent_requests(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test handling multiple concurrent requests."""
import threading
# Track call counts
call_count = {"count": 0}
async def mock_call_tool(tool_name, arguments):
call_count["count"] += 1
return {"result": f"success_{call_count['count']}"}
mock_client_manager.call_tool = mock_call_tool
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
results = []
def make_request(tool_name):
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
request = {"tool": tool_name, "params": {}}
client_sock.sendall(json.dumps(request).encode('utf-8'))
client_sock.shutdown(socket.SHUT_WR)
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
response = json.loads(response_data.decode('utf-8'))
results.append(response)
client_sock.close()
# Make 3 concurrent requests
threads = []
for i in range(3):
thread = threading.Thread(target=make_request, args=(f"tool_{i}",))
threads.append(thread)
thread.start()
# Wait for all threads
for thread in threads:
thread.join()
# Verify all requests succeeded
assert len(results) == 3
for result in results:
assert result["success"] is True
# Verify all were processed
assert call_count["count"] == 3
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_audit_logging_tool_name_only(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test that audit log only logs tool name, not parameters."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
try:
# Send request with sensitive parameters
client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client_sock.connect(str(temp_socket_path))
request = {
"tool": "sensitive_tool",
"params": {"password": "secret123", "token": "abc123"}
}
client_sock.sendall(json.dumps(request).encode('utf-8'))
client_sock.shutdown(socket.SHUT_WR)
# Receive response
response_data = b''
while True:
chunk = client_sock.recv(4096)
if not chunk:
break
response_data += chunk
client_sock.close()
# Verify audit log was called
mock_audit_logger.log.assert_called()
# Get the log call arguments
log_call = mock_audit_logger.log.call_args
# Verify tool name is in log
log_str = str(log_call)
assert "sensitive_tool" in log_str
# Verify sensitive parameters are NOT in log
assert "secret123" not in log_str
assert "abc123" not in log_str
finally:
bridge.stop()
@pytest.mark.asyncio
async def test_socket_cleanup_on_error(temp_socket_path, mock_client_manager, mock_audit_logger):
"""Test that socket is cleaned up even if server encounters error."""
bridge = ToolBridgeServer(
socket_path=temp_socket_path,
client_manager=mock_client_manager,
audit_logger=mock_audit_logger
)
bridge.start()
assert temp_socket_path.exists()
# Stop should cleanup
bridge.stop()
assert not temp_socket_path.exists()

249
tests/mcp/test_client.py Normal file
View file

@ -0,0 +1,249 @@
"""Tests for MCP Client Wrapper."""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from mcp_forge.mcp.client import MCPClientWrapper
@pytest.fixture
def mock_fastmcp_client():
"""Mock fastmcp Client."""
client = AsyncMock()
# Create tool mocks with actual string names (not Mock.name)
tool1 = Mock()
tool1.name = "tool1"
tool1.description = "Tool 1"
tool1.inputSchema = {"type": "object", "properties": {}}
tool2 = Mock()
tool2.name = "tool2"
tool2.description = "Tool 2"
tool2.inputSchema = {"type": "object", "properties": {}}
# Mock list_tools to return tool objects
client.list_tools = AsyncMock(return_value=Mock(tools=[tool1, tool2]))
# Mock call_tool to return result with data
client.call_tool = AsyncMock(return_value=Mock(
data="result",
content=[Mock(text="result")],
is_error=False
))
# Mock context manager
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
return client
@pytest.mark.asyncio
async def test_connect_success(mock_fastmcp_client):
"""Test successful connection to MCP server."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
assert not client.is_connected()
await client.connect()
assert client.is_connected()
mock_fastmcp_client.__aenter__.assert_called_once()
@pytest.mark.asyncio
async def test_connect_with_env(mock_fastmcp_client):
"""Test connection with environment variables."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
env = {"API_KEY": "test123", "DEBUG": "true"}
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"],
env=env
)
await client.connect()
assert client.is_connected()
assert client.env == env
@pytest.mark.asyncio
async def test_disconnect(mock_fastmcp_client):
"""Test disconnect from MCP server."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
assert client.is_connected()
await client.disconnect()
assert not client.is_connected()
mock_fastmcp_client.__aexit__.assert_called_once()
@pytest.mark.asyncio
async def test_list_tools(mock_fastmcp_client):
"""Test listing available tools."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
tools = await client.list_tools()
assert tools == ["tool1", "tool2"]
mock_fastmcp_client.list_tools.assert_called_once()
@pytest.mark.asyncio
async def test_list_tools_not_connected():
"""Test list_tools raises error when not connected."""
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
with pytest.raises(RuntimeError, match="not connected"):
await client.list_tools()
@pytest.mark.asyncio
async def test_get_tool_schema(mock_fastmcp_client):
"""Test getting tool schema."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
schema = await client.get_tool_schema("tool1")
assert schema == {"type": "object", "properties": {}}
@pytest.mark.asyncio
async def test_get_tool_schema_not_found(mock_fastmcp_client):
"""Test get_tool_schema raises error for unknown tool."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
with pytest.raises(KeyError, match="Tool 'unknown' not found"):
await client.get_tool_schema("unknown")
@pytest.mark.asyncio
async def test_call_tool_success(mock_fastmcp_client):
"""Test successful tool call."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
result = await client.call_tool("tool1", {"param": "value"})
assert result == "result"
mock_fastmcp_client.call_tool.assert_called_once_with("tool1", {"param": "value"})
@pytest.mark.asyncio
async def test_call_tool_not_connected():
"""Test call_tool raises error when not connected."""
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
with pytest.raises(RuntimeError, match="not connected"):
await client.call_tool("tool1", {})
@pytest.mark.asyncio
async def test_call_tool_failure(mock_fastmcp_client):
"""Test tool call failure handling."""
mock_fastmcp_client.call_tool = AsyncMock(side_effect=Exception("Tool failed"))
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
await client.connect()
with pytest.raises(RuntimeError, match="Tool call failed.*Tool failed"):
await client.call_tool("tool1", {})
@pytest.mark.asyncio
async def test_connection_failure():
"""Test connection failure handling."""
failing_client = AsyncMock()
failing_client.__aenter__ = AsyncMock(side_effect=Exception("Connection failed"))
with patch('mcp_forge.mcp.client.Client', return_value=failing_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
with pytest.raises(RuntimeError, match="Failed to connect.*Connection failed"):
await client.connect()
assert not client.is_connected()
@pytest.mark.asyncio
async def test_reconnect(mock_fastmcp_client):
"""Test reconnection after disconnect."""
with patch('mcp_forge.mcp.client.Client', return_value=mock_fastmcp_client):
client = MCPClientWrapper(
name="test-server",
command="python",
args=["-m", "test_server"]
)
# First connection
await client.connect()
assert client.is_connected()
# Disconnect
await client.disconnect()
assert not client.is_connected()
# Reconnect
await client.connect()
assert client.is_connected()
# Should be able to use tools
tools = await client.list_tools()
assert tools == ["tool1", "tool2"]

View file

@ -0,0 +1,114 @@
"""Tests for HTTP and SSE transport support in MCP client."""
import pytest
from mcp_forge.mcp.client import MCPClientWrapper
def test_stdio_transport_creation():
"""Test creating a client with stdio transport."""
client = MCPClientWrapper(
name="test_stdio",
transport_type="stdio",
command="python",
args=["-m", "server"],
env={"KEY": "value"}
)
assert client.name == "test_stdio"
assert client.transport_type == "stdio"
assert client.command == "python"
assert client.args == ["-m", "server"]
assert client.env == {"KEY": "value"}
assert client.transport is not None
def test_http_transport_creation():
"""Test creating a client with HTTP transport."""
client = MCPClientWrapper(
name="test_http",
transport_type="http",
url="http://localhost:8006/mcp",
headers={"Authorization": "Bearer token123"}
)
assert client.name == "test_http"
assert client.transport_type == "http"
assert client.url == "http://localhost:8006/mcp"
assert client.headers == {"Authorization": "Bearer token123"}
assert client.transport is not None
def test_sse_transport_creation():
"""Test creating a client with SSE transport."""
client = MCPClientWrapper(
name="test_sse",
transport_type="sse",
url="http://localhost:9000/events",
headers={"X-Custom": "value"}
)
assert client.name == "test_sse"
assert client.transport_type == "sse"
assert client.url == "http://localhost:9000/events"
assert client.headers == {"X-Custom": "value"}
assert client.transport is not None
def test_stdio_without_command_raises_error():
"""Test that stdio transport requires a command."""
with pytest.raises(ValueError, match="command required for stdio transport"):
MCPClientWrapper(
name="test_stdio",
transport_type="stdio"
)
def test_http_without_url_raises_error():
"""Test that HTTP transport requires a URL."""
with pytest.raises(ValueError, match="url required for http transport"):
MCPClientWrapper(
name="test_http",
transport_type="http"
)
def test_sse_without_url_raises_error():
"""Test that SSE transport requires a URL."""
with pytest.raises(ValueError, match="url required for sse transport"):
MCPClientWrapper(
name="test_sse",
transport_type="sse"
)
def test_invalid_transport_type_raises_error():
"""Test that an invalid transport type raises an error."""
with pytest.raises(ValueError, match="Unknown transport type"):
MCPClientWrapper(
name="test_invalid",
transport_type="invalid"
)
def test_http_transport_with_empty_headers():
"""Test HTTP transport with empty headers dict."""
client = MCPClientWrapper(
name="test_http",
transport_type="http",
url="http://localhost:8006/mcp"
)
assert client.headers == {}
assert client.transport is not None
def test_default_stdio_transport():
"""Test that stdio is the default transport type."""
client = MCPClientWrapper(
name="test_default",
command="python",
args=["-m", "server"]
)
assert client.transport_type == "stdio"
assert client.command == "python"

227
tests/mcp/test_injection.py Normal file
View file

@ -0,0 +1,227 @@
"""Tests for Tool Injection Generator."""
import pytest
import ast
from unittest.mock import AsyncMock
from mcp_forge.mcp.injection import ToolInjectionGenerator
@pytest.fixture
def mock_client_manager():
"""Create mock MCP client manager with tools."""
manager = AsyncMock()
# Tool schemas
manager.get_tool_schema = AsyncMock(side_effect=lambda tool_name: {
"read_file": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"},
"encoding": {"type": "string", "description": "File encoding"}
},
"required": ["path"]
},
"write_file": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to write"},
"content": {"type": "string", "description": "Content to write"},
"mode": {"type": "string", "description": "Write mode"}
},
"required": ["path", "content"]
},
"calculate": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression"},
"precision": {"type": "integer", "description": "Decimal precision"}
},
"required": ["expression"]
}
}[tool_name])
return manager
@pytest.mark.asyncio
async def test_generate_injection_code_is_valid_python(mock_client_manager):
"""Test that generated code is valid Python."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file", "write_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# Try to parse the generated code
try:
ast.parse(code)
except SyntaxError as e:
pytest.fail(f"Generated code has syntax error: {e}")
@pytest.mark.asyncio
async def test_generated_code_includes_bridge_client(mock_client_manager):
"""Test that generated code includes bridge client."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# Verify bridge client function is included
assert "_mcp_call" in code
assert "socket.socket" in code
assert "socket.AF_UNIX" in code
assert "/tmp/bridge.sock" in code
@pytest.mark.asyncio
async def test_generated_code_includes_tool_functions(mock_client_manager):
"""Test that generated code includes wrapper functions for each tool."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file", "write_file", "calculate"],
bridge_socket_path="/tmp/bridge.sock"
)
# Verify tool functions are defined
assert "def read_file(" in code
assert "def write_file(" in code
assert "def calculate(" in code
@pytest.mark.asyncio
async def test_function_signatures_match_schemas(mock_client_manager):
"""Test that function signatures match tool schemas."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file", "write_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# read_file has path (required) and encoding (optional)
assert "def read_file(path: str, encoding: str = None)" in code
# write_file has path, content (required) and mode (optional)
assert "def write_file(path: str, content: str, mode: str = None)" in code
@pytest.mark.asyncio
async def test_generated_functions_have_docstrings(mock_client_manager):
"""Test that generated functions have docstrings."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# Verify docstring is present (contains parameter descriptions)
assert '"""' in code
assert "File path to read" in code or "path:" in code.lower()
@pytest.mark.asyncio
async def test_generated_functions_call_bridge(mock_client_manager):
"""Test that generated functions call _mcp_call."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# Verify function calls _mcp_call with tool name
lines = code.split('\n')
in_read_file = False
found_call = False
for line in lines:
if "def read_file(" in line:
in_read_file = True
if in_read_file and "_mcp_call" in line and "read_file" in line:
found_call = True
break
assert found_call, "Generated function should call _mcp_call with tool name"
@pytest.mark.asyncio
async def test_type_hints_from_schema(mock_client_manager):
"""Test that type hints are generated from schema types."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["calculate"],
bridge_socket_path="/tmp/bridge.sock"
)
# calculate has string expression and integer precision
assert "expression: str" in code
assert "precision: int" in code or "precision: " in code
@pytest.mark.asyncio
async def test_required_vs_optional_parameters(mock_client_manager):
"""Test that required and optional parameters are handled correctly."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# path is required (no default), encoding is optional (has default)
assert "def read_file(path: str, encoding: str = None)" in code or \
"def read_file(path: str, encoding: " in code
@pytest.mark.asyncio
async def test_generated_code_has_imports(mock_client_manager):
"""Test that generated code includes necessary imports."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path="/tmp/bridge.sock"
)
# Verify imports
assert "import socket" in code
assert "import json" in code
assert "from typing import Any" in code or "typing" in code
@pytest.mark.asyncio
async def test_empty_tool_list(mock_client_manager):
"""Test handling of empty tool list."""
generator = ToolInjectionGenerator(mock_client_manager)
code = await generator.generate_injection_code(
tool_names=[],
bridge_socket_path="/tmp/bridge.sock"
)
# Should still include bridge client
assert "_mcp_call" in code
# But no tool functions
assert "def read_file(" not in code
@pytest.mark.asyncio
async def test_custom_socket_path(mock_client_manager):
"""Test that custom socket path is used correctly."""
generator = ToolInjectionGenerator(mock_client_manager)
custom_path = "/custom/path/to/socket.sock"
code = await generator.generate_injection_code(
tool_names=["read_file"],
bridge_socket_path=custom_path
)
# Verify custom path is in generated code
assert custom_path in code

245
tests/mcp/test_manager.py Normal file
View file

@ -0,0 +1,245 @@
"""Tests for MCP Client Manager."""
import pytest
from unittest.mock import AsyncMock, patch
from mcp_forge.mcp.manager import MCPClientManager
@pytest.fixture
def mock_client1():
"""Create mock MCP client 1."""
client = AsyncMock()
client.name = "client1"
client.is_connected.return_value = False
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.list_tools = AsyncMock(return_value=["tool1", "tool2"])
client.get_tool_schema = AsyncMock(return_value={
"type": "object",
"properties": {"param1": {"type": "string"}}
})
client.call_tool = AsyncMock(return_value={"result": "success"})
return client
@pytest.fixture
def mock_client2():
"""Create mock MCP client 2."""
client = AsyncMock()
client.name = "client2"
client.is_connected.return_value = False
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.list_tools = AsyncMock(return_value=["tool3", "tool4"])
client.get_tool_schema = AsyncMock(return_value={
"type": "object",
"properties": {"param2": {"type": "number"}}
})
client.call_tool = AsyncMock(return_value={"result": "success2"})
return client
@pytest.fixture
def client_config():
"""Create test client configuration."""
return {
"client1": {
"command": "python",
"args": ["server1.py"],
"env": {"KEY1": "value1"}
},
"client2": {
"command": "python",
"args": ["server2.py"],
"env": {"KEY2": "value2"}
}
}
@pytest.mark.asyncio
async def test_initialize_clients_from_config(mock_client1, mock_client2, client_config):
"""Test initializing clients from configuration."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
# Setup mock to return different clients for different configs
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Verify clients were created with correct config
assert mock_wrapper.call_count == 2
# Verify first client created with correct params
call1 = mock_wrapper.call_args_list[0]
assert call1[1]["name"] == "client1"
assert call1[1]["command"] == "python"
assert call1[1]["args"] == ["server1.py"]
assert call1[1]["env"] == {"KEY1": "value1"}
# Verify second client created with correct params
call2 = mock_wrapper.call_args_list[1]
assert call2[1]["name"] == "client2"
assert call2[1]["command"] == "python"
assert call2[1]["args"] == ["server2.py"]
assert call2[1]["env"] == {"KEY2": "value2"}
# Verify clients were connected
mock_client1.connect.assert_called_once()
mock_client2.connect.assert_called_once()
# Verify tools were listed
mock_client1.list_tools.assert_called_once()
mock_client2.list_tools.assert_called_once()
@pytest.mark.asyncio
async def test_get_client_for_tool(mock_client1, mock_client2, client_config):
"""Test getting client that provides a specific tool."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Get client for tool1 (from client1)
client = await manager.get_client_for_tool("tool1")
assert client == mock_client1
# Get client for tool3 (from client2)
client = await manager.get_client_for_tool("tool3")
assert client == mock_client2
@pytest.mark.asyncio
async def test_get_client_for_unknown_tool(mock_client1, mock_client2, client_config):
"""Test getting client for tool that doesn't exist."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Try to get client for non-existent tool
with pytest.raises(KeyError, match="Tool 'unknown_tool' not found"):
await manager.get_client_for_tool("unknown_tool")
@pytest.mark.asyncio
async def test_list_all_tools(mock_client1, mock_client2, client_config):
"""Test listing all tools across all clients."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
tools = await manager.list_all_tools()
# Should have all tools from both clients
assert set(tools) == {"tool1", "tool2", "tool3", "tool4"}
@pytest.mark.asyncio
async def test_detect_tool_name_collision():
"""Test detection of tool name collisions across clients."""
# Create clients with overlapping tool names
client1 = AsyncMock()
client1.name = "client1"
client1.connect = AsyncMock()
client1.list_tools = AsyncMock(return_value=["tool1", "tool2"])
client2 = AsyncMock()
client2.name = "client2"
client2.connect = AsyncMock()
client2.list_tools = AsyncMock(return_value=["tool2", "tool3"]) # tool2 collision!
config = {
"client1": {"command": "python", "args": ["server1.py"]},
"client2": {"command": "python", "args": ["server2.py"]}
}
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [client1, client2]
manager = MCPClientManager(config)
# Should raise ValueError about collision during initialization
with pytest.raises(ValueError, match="Tool name collision.*tool2.*client1.*client2"):
await manager.initialize()
@pytest.mark.asyncio
async def test_get_tool_schema(mock_client1, mock_client2, client_config):
"""Test getting tool schema via manager."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Get schema for tool1 (from client1)
schema = await manager.get_tool_schema("tool1")
assert schema == {"type": "object", "properties": {"param1": {"type": "string"}}}
mock_client1.get_tool_schema.assert_called_once_with("tool1")
# Get schema for tool3 (from client2)
schema = await manager.get_tool_schema("tool3")
assert schema == {"type": "object", "properties": {"param2": {"type": "number"}}}
mock_client2.get_tool_schema.assert_called_once_with("tool3")
@pytest.mark.asyncio
async def test_call_tool(mock_client1, mock_client2, client_config):
"""Test calling tool via manager."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Call tool1 (from client1)
result = await manager.call_tool("tool1", {"param1": "value"})
assert result == {"result": "success"}
mock_client1.call_tool.assert_called_once_with("tool1", {"param1": "value"})
# Call tool3 (from client2)
result = await manager.call_tool("tool3", {"param2": 42})
assert result == {"result": "success2"}
mock_client2.call_tool.assert_called_once_with("tool3", {"param2": 42})
@pytest.mark.asyncio
async def test_shutdown_all_clients(mock_client1, mock_client2, client_config):
"""Test shutting down all clients."""
with patch('mcp_forge.mcp.manager.MCPClientWrapper') as mock_wrapper:
mock_wrapper.side_effect = [mock_client1, mock_client2]
manager = MCPClientManager(client_config)
await manager.initialize()
# Shutdown all clients
await manager.shutdown()
# Verify both clients were disconnected
mock_client1.disconnect.assert_called_once()
mock_client2.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_manager_before_initialization():
"""Test that manager methods fail before initialization."""
config = {"client1": {"command": "python", "args": ["server.py"]}}
manager = MCPClientManager(config)
# Should raise RuntimeError if not initialized
with pytest.raises(RuntimeError, match="Manager not initialized"):
await manager.list_all_tools()
with pytest.raises(RuntimeError, match="Manager not initialized"):
await manager.get_client_for_tool("tool1")
with pytest.raises(RuntimeError, match="Manager not initialized"):
await manager.get_tool_schema("tool1")
with pytest.raises(RuntimeError, match="Manager not initialized"):
await manager.call_tool("tool1", {})

View file

@ -0,0 +1,433 @@
"""
Tests for Secure Container Manager.
Following TDD approach - these tests are written before implementation.
Tests cover all requirements from todo.md section 1.3.2.
All tests use mocked Podman client (no actual containers needed).
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import MagicMock, Mock, patch, call
from pathlib import Path
def test_create_container_with_valid_params_succeeds():
"""Test that container creation with valid params succeeds."""
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger, AuditEventType
from mcp_forge.security.resource_limits import ResourceLimits
from mcp_forge.config.schema import SecurityConfig
# Setup mocks
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_container.id = "abc123"
mock_podman_client.client.containers.create.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
config = ContainerConfig(
image="mcp-forge/python:3.11",
command=["python", "-c", "print('hello')"],
resource_limits=ResourceLimits(memory="512m", storage="1g", cpu_quota=100000)
)
container_id = manager.create_container(config, session_id="test-session-1")
assert container_id == "abc123"
mock_podman_client.client.containers.create.assert_called_once()
def test_create_container_with_forbidden_params_raises_security_error():
"""Test that forbidden parameters raise SecurityError."""
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
config = ContainerConfig(
image="mcp-forge/python:3.11",
command=["python", "-c", "print('hello')"]
)
# Try to override security params (should be caught in to_podman_params or validation)
with pytest.raises(SecurityError):
# This should fail validation
manager.create_container(config, session_id="test-session", privileged=True)
def test_create_container_with_invalid_image_raises_security_error():
"""Test that invalid/disallowed images raise SecurityError."""
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
config = ContainerConfig(
image="evil/malicious:latest",
command=["python", "-c", "print('hello')"]
)
with pytest.raises(SecurityError) as exc_info:
manager.create_container(config, session_id="test-session")
assert "image" in str(exc_info.value).lower()
def test_create_container_enforces_required_parameters():
"""Test that required security parameters are enforced."""
from mcp_forge.podman.containers import ContainerConfig
config = ContainerConfig(
image="mcp-forge/python:3.11",
command=["python", "-c", "print('hello')"]
)
params = config.to_podman_params()
# Check required security parameters
assert params["network_mode"] == "none"
assert params["read_only"] is True
assert "no-new-privileges" in params["security_opt"]
assert params["user"] == "1000:1000"
def test_resource_limits_are_applied_correctly():
"""Test that resource limits are correctly applied."""
from mcp_forge.podman.containers import ContainerConfig
from mcp_forge.security.resource_limits import ResourceLimits
limits = ResourceLimits(
memory="1g",
storage="2g",
cpu_quota=200000
)
config = ContainerConfig(
image="mcp-forge/python:3.11",
resource_limits=limits
)
params = config.to_podman_params()
assert params["mem_limit"] == "1073741824" # 1GB in bytes as string
assert params["cpu_quota"] == 200000
assert params["storage_opt"]["size"] == "2147483648" # 2GB in bytes as string
def test_volume_mounts_are_validated():
"""Test that volume mounts are validated against allowlist."""
from mcp_forge.podman.containers import ContainerConfig, SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
# Valid mount (session path)
config = ContainerConfig(
image="mcp-forge/python:3.11",
volumes={"/mcp-forge/sessions/test-session-1/workspace": {"bind": "/workspace", "mode": "rw"}}
)
# This should succeed (valid session path)
mock_container = MagicMock()
mock_container.id = "abc123"
mock_podman_client.client.containers.create.return_value = mock_container
container_id = manager.create_container(config, session_id="test-session-1")
assert container_id == "abc123"
# Invalid mount (forbidden path)
config_bad = ContainerConfig(
image="mcp-forge/python:3.11",
volumes={"/etc/passwd": {"bind": "/tmp/passwd", "mode": "r"}}
)
with pytest.raises(SecurityError):
manager.create_container(config_bad, session_id="test-session-1")
def test_start_container_on_session_container_succeeds():
"""Test that starting a session container succeeds."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_podman_client.client.containers.get.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
# Register container with validator
validator.register_session_container("abc123")
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
manager.start_container("abc123")
mock_container.start.assert_called_once()
def test_start_container_on_non_session_container_raises_security_error():
"""Test that starting a non-session container raises SecurityError."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
# Try to start container not registered with validator
with pytest.raises(SecurityError):
manager.start_container("unknown123")
def test_stop_container_works():
"""Test that stopping a container works."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_podman_client.client.containers.get.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
# Register container
validator.register_session_container("abc123")
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
manager.stop_container("abc123", timeout=10)
mock_container.stop.assert_called_once_with(timeout=10)
def test_remove_container_works():
"""Test that removing a container works."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_podman_client.client.containers.get.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
# Register container
validator.register_session_container("abc123")
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
manager.remove_container("abc123", force=True)
mock_container.remove.assert_called_once_with(force=True)
def test_cleanup_old_containers():
"""Test cleanup of old containers."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
# Create mock old and new containers
old_container = MagicMock()
old_container.id = "old123"
old_container.attrs = {
"Created": (datetime.now() - timedelta(hours=25)).isoformat(),
"Labels": {"mcp-forge.session": "old-session"}
}
new_container = MagicMock()
new_container.id = "new123"
new_container.attrs = {
"Created": datetime.now().isoformat(),
"Labels": {"mcp-forge.session": "new-session"}
}
mock_podman_client.client.containers.list.return_value = [old_container, new_container]
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
count = manager.cleanup_old_containers(max_age=timedelta(hours=24))
assert count == 1
old_container.remove.assert_called_once_with(force=True)
new_container.remove.assert_not_called()
def test_get_container_logs():
"""Test getting container logs."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_container.logs.return_value = b"stdout output\nstderr output"
mock_podman_client.client.containers.get.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
# Register container
validator.register_session_container("abc123")
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
stdout, stderr = manager.get_container_logs("abc123", tail=100)
assert "output" in stdout or "output" in stderr
mock_container.logs.assert_called()
def test_wait_for_container():
"""Test waiting for container to exit."""
from mcp_forge.podman.containers import SecureContainerManager
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.security.audit import AuditLogger
from mcp_forge.config.schema import SecurityConfig
mock_podman_client = MagicMock(spec=PodmanClient)
mock_container = MagicMock()
mock_container.wait.return_value = {"StatusCode": 0}
mock_podman_client.client.containers.get.return_value = mock_container
audit_logger = AuditLogger(Path("/tmp/test_audit.log"))
validator = OperationValidator(SecurityConfig(audit_log=Path("/tmp/test_audit.log")))
# Register container
validator.register_session_container("abc123")
manager = SecureContainerManager(
podman_client=mock_podman_client,
validator=validator,
audit_logger=audit_logger
)
exit_code = manager.wait_for_container("abc123", timeout=300)
assert exit_code == 0
mock_container.wait.assert_called_once_with(timeout=300)
def test_container_config_to_podman_params_includes_all_security_settings():
"""Test that ContainerConfig.to_podman_params includes all required settings."""
from mcp_forge.podman.containers import ContainerConfig
from mcp_forge.security.resource_limits import ResourceLimits
config = ContainerConfig(
image="mcp-forge/python:3.11",
command=["python", "-m", "test"],
environment={"VAR1": "value1"},
volumes={"/mcp-forge/sessions/sess-1/work": {"bind": "/workspace", "mode": "rw"}},
resource_limits=ResourceLimits(memory="512m", storage="1g", cpu_quota=100000),
working_dir="/workspace",
user="1000:1000"
)
params = config.to_podman_params()
# Required security settings
assert params["network_mode"] == "none"
assert params["read_only"] is True
assert "no-new-privileges" in params["security_opt"]
assert params["user"] == "1000:1000"
# Configuration passthrough
assert params["image"] == "mcp-forge/python:3.11"
assert params["command"] == ["python", "-m", "test"]
assert params["environment"] == {"VAR1": "value1"}
assert params["working_dir"] == "/workspace"
# Resource limits
assert "mem_limit" in params
assert "cpu_quota" in params

View file

@ -0,0 +1,324 @@
"""
Tests for Podman client wrapper.
Following TDD approach - these tests are written before implementation.
Tests cover all requirements from todo.md section 1.3.1.
All tests use mocked Podman client (no actual Podman needed).
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
def test_connection_to_podman_socket_succeeds(tmp_path):
"""Test that connection to Podman socket succeeds."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch() # Create fake socket file
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client_instance = MagicMock()
mock_client_instance.ping.return_value = "OK"
mock_podman.return_value = mock_client_instance
client.connect()
assert client._client is not None
mock_podman.assert_called_once_with(base_url=f"unix://{socket_path}")
def test_connection_failure_raises_clear_error(tmp_path):
"""Test that connection failure raises clear error."""
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "nonexistent.sock"
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient', side_effect=Exception("Connection failed")):
with pytest.raises(PodmanConnectionError) as exc_info:
client.connect()
assert "connection" in str(exc_info.value).lower() or "failed" in str(exc_info.value).lower()
def test_socket_path_validation(tmp_path):
"""Test that socket path is validated before connecting."""
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
nonexistent_socket = tmp_path / "nonexistent.sock"
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=nonexistent_socket,
validator=validator,
audit_logger=audit_logger
)
with pytest.raises(PodmanConnectionError) as exc_info:
client.verify_socket_access()
assert "not found" in str(exc_info.value).lower() or "does not exist" in str(exc_info.value).lower()
def test_socket_permissions_check(tmp_path):
"""Test that socket permissions are checked."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
socket_path.chmod(0o000) # Remove all permissions
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
# verify_socket_access should check readability
# Depending on implementation, might raise error or just warn
try:
client.verify_socket_access()
except Exception as e:
# Should mention permissions or access
assert "permission" in str(e).lower() or "access" in str(e).lower() or "readable" in str(e).lower()
finally:
socket_path.chmod(0o644) # Restore for cleanup
def test_api_version_compatibility_check(tmp_path):
"""Test that API version is checked."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client = MagicMock()
mock_client.version.return_value = {
"Version": "4.5.0",
"ApiVersion": "4.5.0"
}
mock_client.ping.return_value = "OK"
mock_podman.return_value = mock_client
client.connect()
version_info = client.check_api_version()
assert "Version" in version_info or "ApiVersion" in version_info
def test_ping_health_check(tmp_path):
"""Test that ping/health check works."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client = MagicMock()
mock_client.ping.return_value = "OK"
mock_podman.return_value = mock_client
client.connect()
result = client.ping()
assert result is True or result == "OK"
def test_lazy_connection(tmp_path):
"""Test that connection is lazy (only connects when needed)."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
# Creating client should not connect
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
assert client._client is None # Not connected yet
# Accessing client property should trigger connection
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client_instance = MagicMock()
mock_client_instance.ping.return_value = "OK"
mock_podman.return_value = mock_client_instance
_ = client.client
assert client._client is not None
def test_disconnect_cleanup(tmp_path):
"""Test that disconnect cleans up properly."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client = MagicMock()
mock_client.ping.return_value = "OK"
mock_podman.return_value = mock_client
client.connect()
assert client._client is not None
client.disconnect()
assert client._client is None
def test_connection_error_includes_socket_path(tmp_path):
"""Test that connection errors include the socket path for debugging."""
from mcp_forge.podman.client import PodmanClient, PodmanConnectionError
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "test.sock"
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with pytest.raises(PodmanConnectionError) as exc_info:
client.verify_socket_access()
assert str(socket_path) in str(exc_info.value) or socket_path.name in str(exc_info.value)
def test_client_property_auto_connects(tmp_path):
"""Test that accessing client property auto-connects if not connected."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
socket_path.touch()
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
with patch('mcp_forge.podman.client.BasePodmanClient') as mock_podman:
mock_client = MagicMock()
mock_client.ping.return_value = "OK"
mock_podman.return_value = mock_client
# First access should trigger connect
_ = client.client
assert mock_podman.called
# Second access should reuse connection
mock_podman.reset_mock()
_ = client.client
assert not mock_podman.called # Should not connect again
def test_validator_and_audit_logger_stored(tmp_path):
"""Test that validator and audit logger are stored for later use."""
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
socket_path = tmp_path / "podman.sock"
audit_logger = AuditLogger(tmp_path / "audit.log")
validator = OperationValidator(SecurityConfig(audit_log=tmp_path / "audit.log"))
client = PodmanClient(
socket_path=socket_path,
validator=validator,
audit_logger=audit_logger
)
assert client.validator is validator
assert client.audit_logger is audit_logger

View file

@ -0,0 +1,486 @@
"""
Tests for Podman operation allowlist and validation.
Following TDD approach - these tests are written before implementation.
Tests cover all security validation requirements from todo.md section 1.2.2.
"""
import pytest
def test_allowed_operation_with_valid_params_passes():
"""Test that allowed operation with valid parameters passes validation."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(
audit_log=Path("/var/log/audit.log"),
enforce_resource_limits=True,
allow_network=False
)
validator = OperationValidator(config)
# Should not raise
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000",
"memory": "536870912",
"cpu_quota": 50000
},
session_id="test-session-123"
)
def test_allowed_operation_with_forbidden_params_raises_security_error():
"""Test that allowed operation with forbidden parameters raises SecurityError."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(
audit_log=Path("/var/log/audit.log"),
enforce_resource_limits=True,
allow_network=False
)
validator = OperationValidator(config)
# Try to add privileged mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"privileged": True, # FORBIDDEN
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session-123"
)
assert "privileged" in str(exc_info.value).lower()
def test_forbidden_param_cap_add_raises_security_error():
"""Test that cap_add parameter is rejected."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"cap_add": ["SYS_ADMIN"], # FORBIDDEN
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
assert "cap_add" in str(exc_info.value).lower()
def test_forbidden_param_devices_raises_security_error():
"""Test that devices parameter is rejected."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"devices": ["/dev/sda"], # FORBIDDEN
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
assert "devices" in str(exc_info.value).lower()
def test_required_parameters_validation():
"""Test that required parameters are enforced."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Missing network_mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "network_mode" in str(exc_info.value).lower()
# Missing read_only
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "read_only" in str(exc_info.value).lower()
def test_image_allowlist_enforcement():
"""Test that only allowed images can be used."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Valid image
validator.validate_image_name("mcp-forge/python:3.11")
validator.validate_image_name("mcp-forge/python:3.12")
validator.validate_image_name("mcp-forge/jupyter:latest")
validator.validate_image_name("mcp-forge/custom:my-env")
# Invalid image
with pytest.raises(SecurityError) as exc_info:
validator.validate_image_name("ubuntu:latest")
assert "image" in str(exc_info.value).lower() or "allowlist" in str(exc_info.value).lower()
with pytest.raises(SecurityError):
validator.validate_image_name("malicious/image:latest")
def test_volume_mount_path_validation():
"""Test that volume mount paths are validated against allowlist."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Valid session path
validator.validate_volume_mount(
"/mcp-forge/sessions/test-session-123/workdir",
"test-session-123"
)
# Valid shared readonly path
validator.validate_volume_mount(
"/mcp-forge/shared/readonly/data",
"test-session-123"
)
# Valid upload path
validator.validate_volume_mount(
"/mcp-forge/uploads/test-session-123/file.txt",
"test-session-123"
)
# Invalid: root path
with pytest.raises(SecurityError) as exc_info:
validator.validate_volume_mount("/", "test-session")
assert "forbidden" in str(exc_info.value).lower() or "root" in str(exc_info.value).lower()
# Invalid: /etc
with pytest.raises(SecurityError):
validator.validate_volume_mount("/etc/passwd", "test-session")
# Invalid: docker socket
with pytest.raises(SecurityError):
validator.validate_volume_mount("/var/run/docker.sock", "test-session")
# Invalid: podman socket
with pytest.raises(SecurityError):
validator.validate_volume_mount("/var/run/podman/podman.sock", "test-session")
def test_capability_restrictions():
"""Test that capability restrictions are enforced."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# cap_add is forbidden
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"cap_add": ["NET_ADMIN"],
"network_mode": "none",
"read_only": True
},
session_id="test-session"
)
def test_network_mode_enforcement():
"""Test that network mode is enforced as 'none'."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Wrong network mode
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "bridge", # Must be "none"
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "network_mode" in str(exc_info.value).lower()
def test_privileged_mode_always_rejected():
"""Test that privileged mode is always rejected regardless of other params."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"privileged": True,
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session"
)
assert "privileged" in str(exc_info.value).lower()
def test_session_container_tracking():
"""Test that session containers are tracked and validated."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Register a session container
validator.register_session_container("container-123")
# Should be able to operate on registered container
validator.validate_container_start("container-123")
validator.validate_container_stop("container-123")
validator.validate_container_remove("container-123")
# Cannot operate on non-session container
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_start("unknown-container")
assert "session" in str(exc_info.value).lower() or "not found" in str(exc_info.value).lower()
with pytest.raises(SecurityError):
validator.validate_container_stop("unknown-container")
with pytest.raises(SecurityError):
validator.validate_container_remove("unknown-container")
def test_unregister_session_container():
"""Test that session containers can be unregistered."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
validator.register_session_container("container-123")
validator.validate_container_start("container-123") # Should work
validator.unregister_session_container("container-123")
# After unregistration, should not work
with pytest.raises(Exception): # SecurityError
validator.validate_container_start("container-123")
def test_validate_container_create_stores_container_id():
"""Test that validate_container_create automatically registers container."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Create container with session_id should auto-register
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"network_mode": "none",
"read_only": True,
"security_opt": ["no-new-privileges"],
"user": "1000:1000"
},
session_id="test-session-123"
)
# The actual container_id would be returned by Podman after creation
# So this test just verifies validation passes
def test_security_error_includes_rule_violation():
"""Test that SecurityError messages indicate what rule was violated."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Test various violations have clear messages
with pytest.raises(SecurityError) as exc_info:
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={"privileged": True},
session_id="test"
)
error_msg = str(exc_info.value)
assert "privileged" in error_msg.lower()
with pytest.raises(SecurityError) as exc_info:
validator.validate_image_name("bad-image:latest")
error_msg = str(exc_info.value)
assert "image" in error_msg.lower() or "allowlist" in error_msg.lower()
def test_wildcard_image_pattern_matching():
"""Test that wildcard patterns work in image allowlist."""
from mcp_forge.security.allowlist import OperationValidator
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# mcp-forge/custom:* should match any tag
validator.validate_image_name("mcp-forge/custom:my-env-v1")
validator.validate_image_name("mcp-forge/custom:another-tag")
validator.validate_image_name("mcp-forge/custom:abc123")
def test_forbidden_mount_paths_comprehensive():
"""Test all forbidden mount paths are blocked."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
forbidden_paths = [
"/",
"/etc",
"/etc/shadow",
"/var/run/docker.sock",
"/var/run/podman/podman.sock",
"/sys",
"/sys/kernel",
"/proc",
"/proc/self",
]
for path in forbidden_paths:
with pytest.raises(SecurityError):
validator.validate_volume_mount(path, "test-session")
def test_pid_mode_forbidden():
"""Test that pid_mode parameter is forbidden."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"pid_mode": "host", # FORBIDDEN
"network_mode": "none"
},
session_id="test"
)
def test_ipc_mode_forbidden():
"""Test that ipc_mode parameter is forbidden."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
with pytest.raises(SecurityError):
validator.validate_container_create(
image="mcp-forge/python:3.11",
params={
"ipc_mode": "host", # FORBIDDEN
"network_mode": "none"
},
session_id="test"
)
def test_session_path_must_match_session_id():
"""Test that session paths must match the provided session_id."""
from mcp_forge.security.allowlist import OperationValidator, SecurityError
from mcp_forge.config.schema import SecurityConfig
from pathlib import Path
config = SecurityConfig(audit_log=Path("/audit.log"))
validator = OperationValidator(config)
# Correct session match
validator.validate_volume_mount(
"/mcp-forge/sessions/session-123/workdir",
"session-123"
)
# Wrong session in path
with pytest.raises(SecurityError):
validator.validate_volume_mount(
"/mcp-forge/sessions/other-session/workdir",
"session-123"
)

View file

@ -0,0 +1,369 @@
"""
Tests for audit logger module.
Following TDD approach - these tests are written before implementation.
Tests cover all logging requirements from todo.md section 1.2.3.
"""
import pytest
import json
from pathlib import Path
import tempfile
import threading
import time
from datetime import datetime
def test_log_entries_written_to_file(tmp_path):
"""Test that log entries are written to the log file."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.CONTAINER_CREATE,
severity=AuditSeverity.INFO,
message="Test container created",
details={"image": "test-image", "container_id": "abc123"}
)
assert log_file.exists()
content = log_file.read_text()
assert len(content) > 0
def test_log_entries_are_valid_json(tmp_path):
"""Test that log entries are valid JSON."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.CONTAINER_CREATE,
severity=AuditSeverity.INFO,
message="Test entry",
details={"key": "value"}
)
# Each line should be valid JSON
lines = log_file.read_text().strip().split("\n")
for line in lines:
data = json.loads(line) # Should not raise
assert isinstance(data, dict)
def test_log_entries_contain_required_fields(tmp_path):
"""Test that log entries contain all required fields."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.EXECUTION_REQUEST,
severity=AuditSeverity.INFO,
message="Code execution requested",
session_id="test-session-123"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
# Required fields
assert "timestamp" in entry
assert "event_type" in entry
assert "severity" in entry
assert "message" in entry
assert "session_id" in entry
def test_timestamp_format_is_iso_8601(tmp_path):
"""Test that timestamp is in ISO 8601 format."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.SESSION_CREATE,
severity=AuditSeverity.INFO,
message="Session created"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
# Should be parseable as ISO 8601
timestamp = entry["timestamp"]
dt = datetime.fromisoformat(timestamp)
assert isinstance(dt, datetime)
def test_concurrent_logging_is_thread_safe(tmp_path):
"""Test that concurrent logging from multiple threads is thread-safe."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
def log_entries(thread_id, count):
for i in range(count):
logger.log(
event_type=AuditEventType.CONTAINER_CREATE,
severity=AuditSeverity.INFO,
message=f"Thread {thread_id} entry {i}"
)
# Create multiple threads
threads = []
entries_per_thread = 10
num_threads = 5
for i in range(num_threads):
t = threading.Thread(target=log_entries, args=(i, entries_per_thread))
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join()
# Verify all entries written
lines = log_file.read_text().strip().split("\n")
assert len(lines) == num_threads * entries_per_thread
# Verify all entries are valid JSON
for line in lines:
json.loads(line)
def test_security_violations_logged_with_correct_severity(tmp_path):
"""Test that security violations are logged at correct severity."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log_security_violation(
operation="container_create",
reason="Privileged mode attempted",
session_id="test-session"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
assert entry["severity"] == "critical"
assert entry["event_type"] == "security.violation"
def test_pii_is_not_logged(tmp_path):
"""Test that PII (code content, tokens, files) is not logged."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
# Log execution request - should NOT include actual code
logger.log(
event_type=AuditEventType.EXECUTION_REQUEST,
severity=AuditSeverity.INFO,
message="Code execution requested",
details={
"code_hash": "abc123def456", # Hash is OK
# "code": "print('hello')" # Should NOT be logged
}
)
content = log_file.read_text()
# Should not contain actual code
assert "print" not in content
assert "hello" not in content
# Should contain hash
assert "abc123def456" in content
def test_log_container_operation(tmp_path):
"""Test log_container_operation convenience method."""
from mcp_forge.security.audit import AuditLogger, AuditEventType
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log_container_operation(
operation="create",
container_id="container-123",
image="mcp-forge/python:3.11",
session_id="session-456"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
assert entry["event_type"] == "container.create"
assert entry["container_id"] == "container-123"
assert entry["image"] == "mcp-forge/python:3.11"
assert entry["session_id"] == "session-456"
def test_log_container_operation_with_error(tmp_path):
"""Test logging container operation with error."""
from mcp_forge.security.audit import AuditLogger
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log_container_operation(
operation="start",
container_id="container-123",
image="mcp-forge/python:3.11",
session_id="session-456",
error="Container not found"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
assert "error" in entry
assert entry["error"] == "Container not found"
assert entry["severity"] == "error"
def test_log_creates_directory_if_not_exists(tmp_path):
"""Test that logger creates log directory if it doesn't exist."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_dir = tmp_path / "nested" / "log" / "dir"
log_file = log_dir / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.SESSION_CREATE,
severity=AuditSeverity.INFO,
message="Test"
)
assert log_file.exists()
assert log_file.parent.exists()
def test_audit_event_types():
"""Test that all required audit event types are defined."""
from mcp_forge.security.audit import AuditEventType
# Required event types from todo.md
assert hasattr(AuditEventType, "CONTAINER_CREATE")
assert hasattr(AuditEventType, "CONTAINER_START")
assert hasattr(AuditEventType, "CONTAINER_STOP")
assert hasattr(AuditEventType, "CONTAINER_REMOVE")
assert hasattr(AuditEventType, "EXECUTION_REQUEST")
assert hasattr(AuditEventType, "SECURITY_VIOLATION")
assert hasattr(AuditEventType, "BUILD_REQUEST")
assert hasattr(AuditEventType, "BUILD_COMPLETE")
assert hasattr(AuditEventType, "SESSION_CREATE")
assert hasattr(AuditEventType, "SESSION_DESTROY")
def test_audit_severity_levels():
"""Test that all required severity levels are defined."""
from mcp_forge.security.audit import AuditSeverity
assert hasattr(AuditSeverity, "INFO")
assert hasattr(AuditSeverity, "WARNING")
assert hasattr(AuditSeverity, "ERROR")
assert hasattr(AuditSeverity, "CRITICAL")
def test_log_with_all_optional_parameters(tmp_path):
"""Test logging with all optional parameters provided."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.BUILD_COMPLETE,
severity=AuditSeverity.INFO,
message="Build completed successfully",
session_id="session-123",
user_id="user-456",
details={"image": "custom-env", "duration": 120},
error=None
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
assert entry["session_id"] == "session-123"
assert entry["user_id"] == "user-456"
assert entry["details"]["image"] == "custom-env"
assert entry["details"]["duration"] == 120
def test_multiple_log_entries_on_separate_lines(tmp_path):
"""Test that multiple log entries are written on separate lines."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
for i in range(5):
logger.log(
event_type=AuditEventType.CONTAINER_CREATE,
severity=AuditSeverity.INFO,
message=f"Entry {i}"
)
lines = log_file.read_text().strip().split("\n")
assert len(lines) == 5
# Each line should be parseable
for line in lines:
json.loads(line)
def test_log_security_violation_parameters(tmp_path):
"""Test log_security_violation includes all necessary information."""
from mcp_forge.security.audit import AuditLogger
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log_security_violation(
operation="volume_mount",
reason="Attempted to mount /etc",
session_id="session-789"
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
assert entry["event_type"] == "security.violation"
assert entry["severity"] == "critical"
assert entry["operation"] == "volume_mount"
assert entry["reason"] == "Attempted to mount /etc"
assert entry["session_id"] == "session-789"
def test_details_can_be_none(tmp_path):
"""Test that details parameter can be None."""
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
log_file = tmp_path / "audit.log"
logger = AuditLogger(log_file)
logger.log(
event_type=AuditEventType.SESSION_CREATE,
severity=AuditSeverity.INFO,
message="Session created",
details=None
)
lines = log_file.read_text().strip().split("\n")
entry = json.loads(lines[0])
# Should work without error
assert "message" in entry

View file

@ -0,0 +1,276 @@
"""
Tests for resource limits module.
Following TDD approach - these tests are written before implementation.
Tests cover all parsing and validation requirements from todo.md section 1.2.1.
"""
import pytest
def test_parse_memory_string_megabytes():
"""Test parsing memory string with megabytes suffix."""
from mcp_forge.security.resource_limits import parse_memory_string
result = parse_memory_string("512m")
assert result == 536870912 # 512 * 1024 * 1024
def test_parse_memory_string_gigabytes():
"""Test parsing memory string with gigabytes suffix."""
from mcp_forge.security.resource_limits import parse_memory_string
result = parse_memory_string("2g")
assert result == 2147483648 # 2 * 1024 * 1024 * 1024
def test_parse_memory_string_kilobytes():
"""Test parsing memory string with kilobytes suffix."""
from mcp_forge.security.resource_limits import parse_memory_string
result = parse_memory_string("1024k")
assert result == 1048576 # 1024 * 1024
def test_parse_memory_string_case_insensitive():
"""Test that memory string parsing is case-insensitive."""
from mcp_forge.security.resource_limits import parse_memory_string
assert parse_memory_string("512M") == 536870912
assert parse_memory_string("2G") == 2147483648
assert parse_memory_string("1024K") == 1048576
def test_parse_memory_string_invalid_format_raises_value_error():
"""Test that invalid format raises ValueError."""
from mcp_forge.security.resource_limits import parse_memory_string
with pytest.raises(ValueError) as exc_info:
parse_memory_string("invalid")
assert "invalid" in str(exc_info.value).lower() or "format" in str(exc_info.value).lower()
with pytest.raises(ValueError):
parse_memory_string("512x") # Invalid suffix
with pytest.raises(ValueError):
parse_memory_string("abc") # Not a number
def test_parse_memory_string_negative_value_raises_value_error():
"""Test that negative values raise ValueError."""
from mcp_forge.security.resource_limits import parse_memory_string
with pytest.raises(ValueError) as exc_info:
parse_memory_string("-512m")
assert "positive" in str(exc_info.value).lower() or "negative" in str(exc_info.value).lower()
def test_parse_memory_string_zero_value_raises_value_error():
"""Test that zero value raises ValueError."""
from mcp_forge.security.resource_limits import parse_memory_string
with pytest.raises(ValueError) as exc_info:
parse_memory_string("0m")
assert "positive" in str(exc_info.value).lower() or "zero" in str(exc_info.value).lower()
def test_parse_cpu_quota_valid_value():
"""Test that valid CPU quota values are accepted."""
from mcp_forge.security.resource_limits import parse_cpu_quota
result = parse_cpu_quota(50000)
assert result == 50000
result = parse_cpu_quota(100000) # 100% of one core
assert result == 100000
def test_parse_cpu_quota_max_limit():
"""Test that CPU quota has a reasonable maximum (10 cores)."""
from mcp_forge.security.resource_limits import parse_cpu_quota
# Should accept up to 1000000 (10 cores)
result = parse_cpu_quota(1000000)
assert result == 1000000
# Should reject more than 10 cores
with pytest.raises(ValueError) as exc_info:
parse_cpu_quota(1000001)
assert "1000000" in str(exc_info.value) or "maximum" in str(exc_info.value).lower()
def test_parse_cpu_quota_negative_raises_value_error():
"""Test that negative CPU quota raises ValueError."""
from mcp_forge.security.resource_limits import parse_cpu_quota
with pytest.raises(ValueError) as exc_info:
parse_cpu_quota(-1)
assert "positive" in str(exc_info.value).lower() or "negative" in str(exc_info.value).lower()
def test_parse_cpu_quota_zero_raises_value_error():
"""Test that zero CPU quota raises ValueError."""
from mcp_forge.security.resource_limits import parse_cpu_quota
with pytest.raises(ValueError) as exc_info:
parse_cpu_quota(0)
assert "positive" in str(exc_info.value).lower() or "zero" in str(exc_info.value).lower()
def test_parse_storage_string_same_as_memory():
"""Test that storage parsing works the same as memory parsing."""
from mcp_forge.security.resource_limits import parse_storage_string
assert parse_storage_string("1g") == 1073741824
assert parse_storage_string("512m") == 536870912
assert parse_storage_string("2048k") == 2097152
def test_resource_limits_class_initialization():
"""Test ResourceLimits class initializes correctly."""
from mcp_forge.security.resource_limits import ResourceLimits
limits = ResourceLimits(
memory="512m",
storage="1g",
cpu_quota=50000,
timeout=300
)
assert limits.memory_bytes == 536870912
assert limits.storage_bytes == 1073741824
assert limits.cpu_quota == 50000
assert limits.timeout == 300
def test_resource_limits_validates_memory():
"""Test that ResourceLimits validates memory string."""
from mcp_forge.security.resource_limits import ResourceLimits
with pytest.raises(ValueError):
ResourceLimits(
memory="invalid",
storage="1g",
cpu_quota=50000,
timeout=300
)
def test_resource_limits_validates_storage():
"""Test that ResourceLimits validates storage string."""
from mcp_forge.security.resource_limits import ResourceLimits
with pytest.raises(ValueError):
ResourceLimits(
memory="512m",
storage="invalid",
cpu_quota=50000,
timeout=300
)
def test_resource_limits_validates_cpu_quota():
"""Test that ResourceLimits validates CPU quota."""
from mcp_forge.security.resource_limits import ResourceLimits
with pytest.raises(ValueError):
ResourceLimits(
memory="512m",
storage="1g",
cpu_quota=-1,
timeout=300
)
def test_resource_limits_to_podman_params():
"""Test conversion to Podman container parameters."""
from mcp_forge.security.resource_limits import ResourceLimits
limits = ResourceLimits(
memory="512m",
storage="1g",
cpu_quota=50000,
timeout=300
)
params = limits.to_podman_params()
assert isinstance(params, dict)
assert "mem_limit" in params
assert params["mem_limit"] == "536870912" # Should be string for Podman
# CPU quota is set via cpu_quota parameter
assert "cpu_quota" in params
assert params["cpu_quota"] == 50000
def test_resource_limits_default_timeout():
"""Test that ResourceLimits has a default timeout."""
from mcp_forge.security.resource_limits import ResourceLimits
limits = ResourceLimits(
memory="512m",
storage="1g",
cpu_quota=50000
)
assert limits.timeout == 300 # Default from signature
def test_parse_memory_string_with_spaces():
"""Test parsing memory strings that have spaces."""
from mcp_forge.security.resource_limits import parse_memory_string
# Should handle spaces gracefully (strip them)
result = parse_memory_string(" 512m ")
assert result == 536870912
def test_parse_memory_string_bytes_suffix():
"""Test parsing memory string with bytes suffix (no multiplier)."""
from mcp_forge.security.resource_limits import parse_memory_string
# Just a number (bytes) - should this be supported?
# Based on architecture, we support k, m, g suffixes
# Plain numbers should probably raise an error for safety
with pytest.raises(ValueError):
parse_memory_string("1024")
def test_resource_limits_storage_quota_in_podman_params():
"""Test that storage limits are included in Podman params."""
from mcp_forge.security.resource_limits import ResourceLimits
limits = ResourceLimits(
memory="512m",
storage="1g",
cpu_quota=50000,
timeout=300
)
params = limits.to_podman_params()
# Storage limit might be set via storage_opt or similar
# The exact parameter depends on Podman API
assert "storage_bytes" in params or "storage_opt" in params
def test_cpu_quota_explanation():
"""Test that CPU quota values have clear meaning."""
from mcp_forge.security.resource_limits import parse_cpu_quota
# 100000 = 100% of one CPU core
# 50000 = 50% of one CPU core
# 200000 = 200% = 2 CPU cores
assert parse_cpu_quota(50000) == 50000 # 0.5 cores
assert parse_cpu_quota(100000) == 100000 # 1 core
assert parse_cpu_quota(200000) == 200000 # 2 cores
def test_parse_memory_with_decimal():
"""Test parsing memory strings with decimal values."""
from mcp_forge.security.resource_limits import parse_memory_string
# Should handle decimals
result = parse_memory_string("1.5g")
assert result == 1610612736 # 1.5 * 1024 * 1024 * 1024

View file

@ -0,0 +1,266 @@
"""Tests for MCP resource handlers."""
import pytest
import json
from unittest.mock import Mock, AsyncMock, patch
from mcp_forge.server.resources import ResourceHandler
@pytest.fixture
def mock_client_manager():
"""Mock MCP client manager."""
manager = Mock()
manager.list_all_tools = AsyncMock(return_value=["read_file", "write_file", "calculate"])
return manager
@pytest.fixture
def mock_session_manager():
"""Mock session manager."""
manager = Mock()
# Mock session state
mock_state = Mock()
mock_state.session_id = "test-session"
mock_state.documented_variables = {"x": "Test variable", "result": "Calculation result"}
mock_state.note = "Test session state"
mock_state.all_variables = ["x", "y", "result", "np", "pd"]
mock_state.introspection = {
"x": {"type": "int", "size": 28},
"result": {"type": "float", "size": 24}
}
mock_state.to_dict = Mock(return_value={
"session_id": "test-session",
"documented_variables": {"x": "Test variable", "result": "Calculation result"},
"note": "Test session state",
"all_variables": ["x", "y", "result", "np", "pd"],
"introspection": {
"x": {"type": "int", "size": 28},
"result": {"type": "float", "size": 24}
},
"last_updated": "2026-02-06T12:00:00Z"
})
manager.get_session_state = Mock(return_value=mock_state)
manager.list_sessions = Mock(return_value=[
{"session_id": "test-session", "created_at": "2026-02-06T12:00:00Z"}
])
return manager
@pytest.fixture
def mock_environment_builder():
"""Mock environment builder."""
builder = Mock()
builder.list_templates = Mock(return_value={
"datascience": {
"description": "Data science environment with numpy, pandas, matplotlib",
"packages": ["numpy>=1.24", "pandas>=2.0", "matplotlib>=3.7"]
},
"ml": {
"description": "Machine learning environment",
"packages": ["scikit-learn>=1.3", "tensorflow>=2.13"]
}
})
return builder
@pytest.fixture
def mock_config():
"""Mock forge configuration."""
config = Mock()
config.execution = Mock()
config.execution.default_backend = "simple"
config.execution.default_timeout = 300
config.sessions = Mock()
config.sessions.max_concurrent = 10
# Add environment-related config for environment/info resource
config.max_packages = 50
config.max_build_time = 300
config.base_images = {"python:3.11": {}, "python:3.12": {}}
return config
@pytest.fixture
def resource_handler(mock_client_manager, mock_session_manager, mock_environment_builder, mock_config):
"""Create ResourceHandler instance with mocked dependencies."""
return ResourceHandler(
client_manager=mock_client_manager,
session_manager=mock_session_manager,
environment_builder=mock_environment_builder,
config=mock_config
)
@pytest.mark.asyncio
async def test_handle_tools_available(resource_handler, mock_client_manager):
"""Test handling tools/available resource returns list of available tools."""
result = await resource_handler.handle_resource("mcp://forge/tools/available")
assert str(result.uri) == "mcp://forge/tools/available"
assert result.mimeType == "application/json"
# Parse JSON content
tools = json.loads(result.text)
assert tools == {"tools": ["read_file", "write_file", "calculate"]}
# Verify client manager was called
mock_client_manager.list_all_tools.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_session_state(resource_handler, mock_session_manager):
"""Test handling session state resource returns documented state."""
session_id = "test-session"
result = await resource_handler.handle_resource(f"mcp://forge/sessions/{session_id}/state")
assert str(result.uri) == f"mcp://forge/sessions/{session_id}/state"
assert result.mimeType == "application/json"
# Parse JSON content
state = json.loads(result.text)
assert state["session_id"] == session_id
assert state["documented_variables"] == {"x": "Test variable", "result": "Calculation result"}
assert state["note"] == "Test session state"
assert "last_updated" in state
# Verify session manager was called
mock_session_manager.get_session_state.assert_called_once_with(session_id)
@pytest.mark.asyncio
async def test_handle_session_variables(resource_handler, mock_session_manager):
"""Test handling session variables resource returns list of variables."""
session_id = "test-session"
result = await resource_handler.handle_resource(f"mcp://forge/sessions/{session_id}/variables")
assert str(result.uri) == f"mcp://forge/sessions/{session_id}/variables"
assert result.mimeType == "application/json"
# Parse JSON content
data = json.loads(result.text)
assert data["variables"] == ["x", "y", "result", "np", "pd"]
# Verify session manager was called
mock_session_manager.get_session_state.assert_called_once_with(session_id)
@pytest.mark.asyncio
async def test_handle_environments_list(resource_handler, mock_environment_builder):
"""Test handling environments/list resource returns templates and built environments."""
result = await resource_handler.handle_resource("mcp://forge/environments/list")
assert str(result.uri) == "mcp://forge/environments/list"
assert result.mimeType == "application/json"
# Parse JSON content
environments = json.loads(result.text)
assert "templates" in environments
assert "datascience" in environments["templates"]
assert "ml" in environments["templates"]
assert environments["templates"]["datascience"]["description"] == "Data science environment with numpy, pandas, matplotlib"
# Verify environment builder was called
mock_environment_builder.list_templates.assert_called_once()
@pytest.mark.asyncio
async def test_handle_environment_info(resource_handler, mock_config):
"""Test handling environment info resource returns configuration info."""
result = await resource_handler.handle_resource("mcp://forge/environment/info")
assert str(result.uri) == "mcp://forge/environment/info"
assert result.mimeType == "application/json"
# Parse JSON content
info = json.loads(result.text)
assert "base_images" in info
assert "python:3.11" in info["base_images"]
assert "python:3.12" in info["base_images"]
assert info["max_packages"] == 50
assert info["max_build_time"] == 300
@pytest.mark.asyncio
async def test_handle_sessions_list(resource_handler, mock_session_manager):
"""Test handling sessions/list resource returns list of active sessions."""
result = await resource_handler.handle_resource("mcp://forge/sessions/list")
assert str(result.uri) == "mcp://forge/sessions/list"
assert result.mimeType == "application/json"
# Parse JSON content
data = json.loads(result.text)
assert len(data["sessions"]) == 1
assert data["sessions"][0]["session_id"] == "test-session"
# Verify session manager was called
mock_session_manager.list_sessions.assert_called_once()
@pytest.mark.asyncio
async def test_handle_unknown_resource(resource_handler):
"""Test handling unknown resource raises ValueError."""
with pytest.raises(ValueError, match="Unknown resource URI"):
await resource_handler.handle_resource("mcp://forge/unknown/resource")
@pytest.mark.asyncio
async def test_handle_session_not_found(resource_handler, mock_session_manager):
"""Test handling session resource when session doesn't exist raises KeyError."""
mock_session_manager.get_session_state.side_effect = KeyError("Session not found")
with pytest.raises(KeyError, match="Session not found"):
await resource_handler.handle_resource("mcp://forge/sessions/nonexistent/state")
@pytest.mark.asyncio
async def test_parse_uri_tools_available(resource_handler):
"""Test URI parsing for tools/available resource."""
resource_type, params = resource_handler._parse_uri("mcp://forge/tools/available")
assert resource_type == "tools_available"
assert params == {}
@pytest.mark.asyncio
async def test_parse_uri_session_state(resource_handler):
"""Test URI parsing for session state resource."""
resource_type, params = resource_handler._parse_uri("mcp://forge/sessions/test-123/state")
assert resource_type == "session_state"
assert params == {"session_id": "test-123"}
@pytest.mark.asyncio
async def test_parse_uri_session_variables(resource_handler):
"""Test URI parsing for session variables resource."""
resource_type, params = resource_handler._parse_uri("mcp://forge/sessions/test-123/variables")
assert resource_type == "session_variables"
assert params == {"session_id": "test-123"}
@pytest.mark.asyncio
async def test_parse_uri_invalid_format(resource_handler):
"""Test URI parsing with invalid format raises ValueError."""
with pytest.raises(ValueError, match="Invalid URI format"):
resource_handler._parse_uri("invalid://uri")
@pytest.mark.asyncio
async def test_json_serialization_valid(resource_handler):
"""Test that all returned JSON content is valid and can be parsed."""
# Test all resource types return valid JSON
resources = [
"mcp://forge/tools/available",
"mcp://forge/sessions/test-session/state",
"mcp://forge/sessions/test-session/variables",
"mcp://forge/environments/list",
"mcp://forge/environment/info",
"mcp://forge/sessions/list"
]
for uri in resources:
result = await resource_handler.handle_resource(uri)
# Should not raise exception
parsed = json.loads(result.text)
assert parsed is not None

203
tests/server/test_server.py Normal file
View file

@ -0,0 +1,203 @@
"""Tests for MCP Forge Server."""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from pathlib import Path
from mcp_forge.server.server import ForgeServer
from mcp_forge.config.schema import ForgeConfig
@pytest.fixture
def mock_config(tmp_path):
"""Mock forge configuration."""
config = Mock(spec=ForgeConfig)
# Server config
config.server = Mock()
config.server.host = "localhost"
config.server.port = 3000
config.server.podman_socket = Path("/run/user/1000/podman/podman.sock")
# Security config
config.security = Mock()
config.security.audit_log = tmp_path / "audit.log"
config.security.max_memory = "2g"
config.security.max_timeout = 1800
# Execution config
config.execution = Mock()
config.execution.default_backend = "simple"
config.execution.default_timeout = 300
config.execution.max_timeout = 1800
config.execution.default_memory = "512m"
config.execution.max_memory = "2g"
# Sessions config
config.sessions = Mock()
config.sessions.max_concurrent = 10
config.sessions.idle_timeout = 3600
# Environment builder config
config.environment_builder = Mock()
config.environment_builder.uv_cache_path = tmp_path / "cache"
config.environment_builder.max_build_time = 600
config.environment_builder.package_validation = Mock()
return config
@pytest.mark.asyncio
async def test_server_initialization(mock_config):
"""Test that server initializes all components."""
with patch('mcp_forge.server.server.AuditLogger'), \
patch('mcp_forge.server.server.OperationValidator'), \
patch('mcp_forge.server.server.PodmanClient'), \
patch('mcp_forge.server.server.SecureContainerManager'), \
patch('mcp_forge.server.server.MCPClientManager'), \
patch('mcp_forge.server.server.ToolBridgeServer'), \
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
patch('mcp_forge.server.server.SimpleBackend'), \
patch('mcp_forge.server.server.JupyterKernelManager'), \
patch('mcp_forge.server.server.JupyterBackend'), \
patch('mcp_forge.server.server.SessionManager'), \
patch('mcp_forge.server.server.EnvironmentBuilder'):
server = ForgeServer(config=mock_config)
# Verify server was created
assert server is not None
assert server.config == mock_config
# Verify components were initialized
assert hasattr(server, 'audit_logger')
assert hasattr(server, 'operation_validator')
assert hasattr(server, 'podman_client')
assert hasattr(server, 'container_manager')
assert hasattr(server, 'client_manager')
assert hasattr(server, 'bridge_server')
assert hasattr(server, 'simple_backend')
assert hasattr(server, 'jupyter_backend')
assert hasattr(server, 'environment_builder')
@pytest.mark.asyncio
async def test_tools_registration(mock_config):
"""Test that tools are registered with the server."""
with patch('mcp_forge.server.server.AuditLogger'), \
patch('mcp_forge.server.server.OperationValidator'), \
patch('mcp_forge.server.server.PodmanClient'), \
patch('mcp_forge.server.server.SecureContainerManager'), \
patch('mcp_forge.server.server.MCPClientManager'), \
patch('mcp_forge.server.server.ToolBridgeServer'), \
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
patch('mcp_forge.server.server.SimpleBackend'), \
patch('mcp_forge.server.server.JupyterKernelManager'), \
patch('mcp_forge.server.server.JupyterBackend'), \
patch('mcp_forge.server.server.SessionManager'), \
patch('mcp_forge.server.server.EnvironmentBuilder'):
server = ForgeServer(config=mock_config)
# Verify tool instances were created
assert hasattr(server, 'execute_python_tool')
assert hasattr(server, 'document_state_tool')
assert hasattr(server, 'build_environment_tool')
@pytest.mark.asyncio
async def test_resources_registration(mock_config):
"""Test that resources are registered with the server."""
with patch('mcp_forge.server.server.AuditLogger'), \
patch('mcp_forge.server.server.OperationValidator'), \
patch('mcp_forge.server.server.PodmanClient'), \
patch('mcp_forge.server.server.SecureContainerManager'), \
patch('mcp_forge.server.server.MCPClientManager'), \
patch('mcp_forge.server.server.ToolBridgeServer'), \
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
patch('mcp_forge.server.server.SimpleBackend'), \
patch('mcp_forge.server.server.JupyterKernelManager'), \
patch('mcp_forge.server.server.JupyterBackend'), \
patch('mcp_forge.server.server.SessionManager'), \
patch('mcp_forge.server.server.EnvironmentBuilder'):
server = ForgeServer(config=mock_config)
# Verify resource handler was created
assert hasattr(server, 'resource_handler')
@pytest.mark.asyncio
async def test_server_shutdown(mock_config):
"""Test that server shuts down gracefully."""
with patch('mcp_forge.server.server.AuditLogger') as mock_audit, \
patch('mcp_forge.server.server.OperationValidator'), \
patch('mcp_forge.server.server.PodmanClient'), \
patch('mcp_forge.server.server.SecureContainerManager'), \
patch('mcp_forge.server.server.MCPClientManager') as mock_client_mgr, \
patch('mcp_forge.server.server.ToolBridgeServer') as mock_bridge, \
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
patch('mcp_forge.server.server.SimpleBackend'), \
patch('mcp_forge.server.server.JupyterKernelManager'), \
patch('mcp_forge.server.server.JupyterBackend'), \
patch('mcp_forge.server.server.SessionManager'), \
patch('mcp_forge.server.server.EnvironmentBuilder'):
# Setup mocks
mock_client_mgr.return_value.shutdown = AsyncMock()
mock_bridge_instance = Mock()
mock_bridge_instance.stop = Mock() # Not async
mock_bridge.return_value = mock_bridge_instance
mock_audit_instance = Mock()
mock_audit.return_value = mock_audit_instance
server = ForgeServer(config=mock_config)
# Shutdown server
await server.shutdown()
# Verify cleanup was called
server.client_manager.shutdown.assert_called_once()
server.bridge_server.stop.assert_called_once()
server.audit_logger.log.assert_called()
@pytest.mark.asyncio
async def test_server_component_initialization_order(mock_config):
"""Test that components are initialized in correct order."""
init_order = []
def track_init(name):
def decorator(cls):
original_init = cls.__init__
def new_init(self, *args, **kwargs):
init_order.append(name)
return original_init(self, *args, **kwargs)
cls.__init__ = new_init
return cls
return decorator
with patch('mcp_forge.server.server.AuditLogger') as mock_audit, \
patch('mcp_forge.server.server.OperationValidator') as mock_validator, \
patch('mcp_forge.server.server.PodmanClient') as mock_podman, \
patch('mcp_forge.server.server.SecureContainerManager') as mock_container, \
patch('mcp_forge.server.server.MCPClientManager'), \
patch('mcp_forge.server.server.ToolBridgeServer'), \
patch('mcp_forge.server.server.ToolInjectionGenerator'), \
patch('mcp_forge.server.server.SimpleBackend'), \
patch('mcp_forge.server.server.JupyterKernelManager'), \
patch('mcp_forge.server.server.JupyterBackend'), \
patch('mcp_forge.server.server.SessionManager'), \
patch('mcp_forge.server.server.EnvironmentBuilder'):
mock_audit.side_effect = lambda *args, **kwargs: init_order.append('audit_logger') or Mock()
mock_validator.side_effect = lambda *args, **kwargs: init_order.append('operation_validator') or Mock()
mock_podman.side_effect = lambda *args, **kwargs: init_order.append('podman_client') or Mock()
mock_container.side_effect = lambda *args, **kwargs: init_order.append('container_manager') or Mock()
server = ForgeServer(config=mock_config)
# Verify security components are initialized first
assert init_order.index('audit_logger') < init_order.index('podman_client')
assert init_order.index('operation_validator') < init_order.index('podman_client')
assert init_order.index('podman_client') < init_order.index('container_manager')

View file

@ -0,0 +1,86 @@
"""Integration tests for MCP Forge Server - tests real component initialization."""
import pytest
from pathlib import Path
from unittest.mock import patch
from mcp_forge.server.server import ForgeServer
from mcp_forge.config.schema import (
ForgeConfig, ServerConfig, SecurityConfig, ExecutionConfig, SessionConfig,
ImageConfig, VolumeConfig, EnvironmentBuilderConfig, PackageValidationConfig
)
@pytest.fixture
def real_config(tmp_path):
"""Real configuration with all required fields."""
# Create required files
(tmp_path / "allowlist.txt").write_text("requests\npandas\nnumpy\n")
(tmp_path / "blocklist.txt").write_text("")
config = ForgeConfig(
server=ServerConfig(
host="localhost",
port=3000,
podman_socket=Path("/run/user/1000/podman/podman.sock")
),
security=SecurityConfig(
audit_log=tmp_path / "audit.log",
enforce_resource_limits=True,
allow_network=False
),
execution=ExecutionConfig(
default_backend="simple",
default_timeout=300,
max_timeout=1800,
default_memory="512m",
max_memory="2g"
),
images=ImageConfig(
allowed_python_versions=["3.11", "3.12"],
default_base_image="python:3.11"
),
sessions=SessionConfig(
max_concurrent=10,
idle_timeout=3600
),
volumes=VolumeConfig(
base_path=tmp_path / "volumes"
),
environment_builder=EnvironmentBuilderConfig(
uv_cache_path=tmp_path / "cache",
build_rate_limit={"requests": 5, "period": 60},
package_validation=PackageValidationConfig(
allowlist_path=tmp_path / "allowlist.txt",
blocklist_path=tmp_path / "blocklist.txt"
)
),
mcp_tools={}
)
return config
@pytest.mark.asyncio
async def test_server_can_initialize_with_minimal_mocking(real_config):
"""Test that ForgeServer can initialize with real component instances."""
# Only mock the podman library since we don't have a real Podman socket
with patch('mcp_forge.podman.client.BasePodmanClient'):
# This should succeed if all parameter mismatches are fixed
server = ForgeServer(config=real_config)
# Verify all components were created
assert server.audit_logger is not None
assert server.operation_validator is not None
assert server.podman_client is not None
assert server.container_manager is not None
assert server.client_manager is not None
assert server.bridge_server is not None
assert server.simple_backend is not None
assert server.kernel_manager is not None
assert server.session_manager is not None
assert server.jupyter_backend is not None
assert server.environment_builder is not None
# Sub-components created by EnvironmentBuilder
assert server.package_validator is not None
assert server.uv_installer is not None
assert server.image_builder is not None

View file

@ -0,0 +1,250 @@
"""Tests for Build Custom Environment Tool."""
import pytest
from unittest.mock import Mock, AsyncMock
from mcp.types import Tool, TextContent
import json
from mcp_forge.server.tools.build_environment import BuildEnvironmentTool
from mcp_forge.builder.environment_builder import BuildResult
@pytest.fixture
def mock_environment_builder():
"""Mock environment builder."""
builder = Mock()
builder.build_environment = AsyncMock(return_value=BuildResult(
success=True,
image_name="mcp-forge/custom:test-env",
image_id="sha256:abc123",
build_time=45.2,
size_bytes=524288000, # 500MB
installed_packages=["numpy==1.24.0", "pandas==2.0.0"],
cache_hit=False
))
return builder
@pytest.fixture
def mock_audit_logger():
"""Mock audit logger."""
logger = Mock()
logger.log_environment_build = Mock()
return logger
@pytest.fixture
def build_environment_tool(mock_environment_builder, mock_audit_logger):
"""Create BuildEnvironmentTool instance with mocked dependencies."""
return BuildEnvironmentTool(
environment_builder=mock_environment_builder,
audit_logger=mock_audit_logger
)
@pytest.mark.asyncio
async def test_get_tool_definition(build_environment_tool):
"""Test that tool definition matches MCP spec."""
definition = build_environment_tool.get_tool_definition()
assert isinstance(definition, Tool)
assert definition.name == "build_custom_environment"
assert definition.description is not None
assert "build" in definition.description.lower()
# Verify required schema properties
schema = definition.inputSchema
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "packages" in schema["properties"]
assert "base_image" in schema["properties"]
assert "python_version" in schema["properties"]
assert "description" in schema["properties"]
assert "name" in schema["required"]
assert "packages" in schema["required"]
@pytest.mark.asyncio
async def test_build_with_valid_packages(
build_environment_tool,
mock_environment_builder,
mock_audit_logger
):
"""Test building environment with valid packages."""
arguments = {
"name": "test-ml-env",
"packages": ["numpy>=1.24.0", "pandas>=2.0.0"]
}
result = await build_environment_tool.execute(arguments)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
assert response["image_name"] == "mcp-forge/custom:test-env"
assert response["build_time"] == 45.2
assert len(response["installed_packages"]) == 2
# Verify builder was called
mock_environment_builder.build_environment.assert_called_once()
# Verify audit log was called
mock_audit_logger.log_environment_build.assert_called_once()
@pytest.mark.asyncio
async def test_build_with_base_image(
build_environment_tool,
mock_environment_builder
):
"""Test building with custom base image."""
arguments = {
"name": "test-env",
"packages": ["requests"],
"base_image": "python:3.12"
}
result = await build_environment_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
# Verify base_image was passed to builder
call_args = mock_environment_builder.build_environment.call_args
assert call_args.kwargs["base_image"] == "python:3.12"
@pytest.mark.asyncio
async def test_build_with_python_version(
build_environment_tool,
mock_environment_builder
):
"""Test building with specific Python version."""
arguments = {
"name": "test-env",
"packages": ["numpy"],
"python_version": "3.12"
}
result = await build_environment_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
# Verify python_version was passed to builder
call_args = mock_environment_builder.build_environment.call_args
assert call_args.kwargs["python_version"] == "3.12"
@pytest.mark.asyncio
async def test_build_with_description(
build_environment_tool,
mock_environment_builder
):
"""Test building with description."""
arguments = {
"name": "test-env",
"packages": ["pandas"],
"description": "Environment for data analysis"
}
result = await build_environment_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
@pytest.mark.asyncio
async def test_validate_arguments_missing_name(build_environment_tool):
"""Test that validation fails when name is missing."""
arguments = {
"packages": ["numpy"]
}
with pytest.raises((ValueError, KeyError)):
await build_environment_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_missing_packages(build_environment_tool):
"""Test that validation fails when packages is missing."""
arguments = {
"name": "test-env"
}
with pytest.raises((ValueError, KeyError)):
await build_environment_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_invalid_name(build_environment_tool):
"""Test that validation fails for invalid environment name."""
arguments = {
"name": "invalid name with spaces",
"packages": ["numpy"]
}
with pytest.raises(ValueError, match="name"):
await build_environment_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_invalid_packages_type(build_environment_tool):
"""Test that validation fails when packages is not a list."""
arguments = {
"name": "test-env",
"packages": "not-a-list"
}
with pytest.raises(ValueError, match="packages"):
await build_environment_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_empty_packages(build_environment_tool):
"""Test that validation fails when packages list is empty."""
arguments = {
"name": "test-env",
"packages": []
}
with pytest.raises(ValueError, match="packages"):
await build_environment_tool.execute(arguments)
@pytest.mark.asyncio
async def test_build_error_handling(
build_environment_tool,
mock_environment_builder
):
"""Test that build errors are handled gracefully."""
mock_environment_builder.build_environment = AsyncMock(return_value=BuildResult(
success=False,
image_name="",
image_id="",
build_time=5.0,
size_bytes=0,
installed_packages=[],
cache_hit=False,
error="Package 'invalid-pkg' not found"
))
arguments = {
"name": "test-env",
"packages": ["invalid-pkg"]
}
result = await build_environment_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is False
assert "error" in response
assert "invalid-pkg" in response["error"]

View file

@ -0,0 +1,188 @@
"""Tests for Document State Tool."""
import pytest
from unittest.mock import Mock, AsyncMock
from mcp.types import Tool, TextContent
import json
from mcp_forge.server.tools.document_state import DocumentStateTool
@pytest.fixture
def mock_session_manager():
"""Mock session manager."""
manager = Mock()
# Mock get_session_state to return a session with state
mock_session = Mock()
mock_session.state = Mock()
mock_session.state.to_dict = Mock(return_value={
"variables": {"x": 1, "y": 2},
"documented_variables": {},
"note": None
})
manager.get_session_state = Mock(return_value=mock_session)
manager.document_variables = AsyncMock(return_value={"success": True, "documented_count": 2})
manager.session_exists = Mock(return_value=True)
return manager
@pytest.fixture
def document_state_tool(mock_session_manager):
"""Create DocumentStateTool instance with mocked dependencies."""
return DocumentStateTool(session_manager=mock_session_manager)
@pytest.mark.asyncio
async def test_get_tool_definition(document_state_tool):
"""Test that tool definition matches MCP spec."""
definition = document_state_tool.get_tool_definition()
assert isinstance(definition, Tool)
assert definition.name == "document_state"
assert definition.description is not None
assert "document" in definition.description.lower()
# Verify required schema properties
schema = definition.inputSchema
assert schema["type"] == "object"
assert "session_id" in schema["properties"]
assert "variables" in schema["properties"]
assert "note" in schema["properties"]
assert "clear" in schema["properties"]
assert "session_id" in schema["required"]
assert "variables" in schema["required"]
@pytest.mark.asyncio
async def test_document_variables(
document_state_tool,
mock_session_manager
):
"""Test documenting variables in a session."""
arguments = {
"session_id": "test-session",
"variables": {
"df": "Customer data with 1000 rows",
"model": "Trained RandomForest classifier"
}
}
result = await document_state_tool.execute(arguments)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
assert response["documented_count"] == 2
assert response["session_id"] == "test-session"
# Verify session manager was called
mock_session_manager.document_variables.assert_called_once()
@pytest.mark.asyncio
async def test_document_with_note(
document_state_tool,
mock_session_manager
):
"""Test documenting with a note."""
arguments = {
"session_id": "test-session",
"variables": {
"result": "Final analysis output"
},
"note": "Analysis complete, ready for reporting"
}
result = await document_state_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
# Verify note was passed
call_args = mock_session_manager.document_variables.call_args
assert call_args is not None
assert call_args.kwargs["note"] == "Analysis complete, ready for reporting"
@pytest.mark.asyncio
async def test_clear_existing_documentation(
document_state_tool,
mock_session_manager
):
"""Test clearing existing documentation."""
arguments = {
"session_id": "test-session",
"variables": {
"new_var": "New variable"
},
"clear": True
}
result = await document_state_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
# Verify clear flag was passed
call_args = mock_session_manager.document_variables.call_args
assert call_args is not None
assert call_args.kwargs["clear"] is True
@pytest.mark.asyncio
async def test_session_validation(
document_state_tool,
mock_session_manager
):
"""Test that validation fails for non-existent session."""
mock_session_manager.session_exists = Mock(return_value=False)
arguments = {
"session_id": "nonexistent",
"variables": {
"x": "test"
}
}
with pytest.raises(ValueError, match="Session"):
await document_state_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_missing_session_id(document_state_tool):
"""Test that validation fails when session_id is missing."""
arguments = {
"variables": {"x": "test"}
}
with pytest.raises((ValueError, KeyError)):
await document_state_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_missing_variables(document_state_tool):
"""Test that validation fails when variables is missing."""
arguments = {
"session_id": "test-session"
}
with pytest.raises((ValueError, KeyError)):
await document_state_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_invalid_variables_type(document_state_tool):
"""Test that validation fails when variables is not a dict."""
arguments = {
"session_id": "test-session",
"variables": ["not", "a", "dict"]
}
with pytest.raises(ValueError, match="variables"):
await document_state_tool.execute(arguments)

View file

@ -0,0 +1,389 @@
"""Tests for Execute Python Tool."""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from mcp.types import Tool, TextContent
import json
from mcp_forge.server.tools.execute_python import ExecutePythonTool
from mcp_forge.execution.simple.backend import ExecutionResult
@pytest.fixture
def mock_simple_backend():
"""Mock simple backend."""
backend = Mock()
backend.execute = AsyncMock(return_value=ExecutionResult(
success=True,
stdout="Hello World",
stderr="",
result="42",
execution_time=0.5,
exit_code=0
))
return backend
@pytest.fixture
def mock_jupyter_backend():
"""Mock jupyter backend."""
backend = Mock()
backend.execute = AsyncMock(return_value=ExecutionResult(
success=True,
stdout="Stateful execution",
stderr="",
result="session-123",
execution_time=1.2,
exit_code=0
))
backend.execute_in_session = AsyncMock(return_value=ExecutionResult(
success=True,
stdout="Using existing session",
stderr="",
result="[1, 2, 3]",
execution_time=0.3,
exit_code=0
))
return backend
@pytest.fixture
def mock_client_manager():
"""Mock MCP client manager."""
manager = Mock()
manager.list_all_tools = AsyncMock(return_value=[
{"name": "github_search_repos", "description": "Search GitHub repositories"},
{"name": "filesystem_read", "description": "Read file contents"}
])
return manager
@pytest.fixture
def mock_bridge_server():
"""Mock tool bridge server."""
server = Mock()
server.socket_path = "/tmp/mcp-bridge.sock"
server.is_running = Mock(return_value=True)
return server
@pytest.fixture
def mock_injection_generator():
"""Mock tool injection generator."""
generator = Mock()
generator.generate_injection_code = Mock(return_value="""
# MCP Tool Injection
def github_search_repos(**kwargs):
import socket
# ... tool implementation
pass
""")
return generator
@pytest.fixture
def mock_config():
"""Mock forge configuration."""
config = Mock()
config.execution = Mock()
config.execution.default_backend = "simple"
config.execution.default_timeout = 300
config.execution.max_timeout = 1800
config.execution.default_memory = "512m"
config.execution.max_memory = "2g"
return config
@pytest.fixture
def execute_python_tool(
mock_simple_backend,
mock_jupyter_backend,
mock_client_manager,
mock_bridge_server,
mock_injection_generator,
mock_config
):
"""Create ExecutePythonTool instance with mocked dependencies."""
return ExecutePythonTool(
simple_backend=mock_simple_backend,
jupyter_backend=mock_jupyter_backend,
client_manager=mock_client_manager,
bridge_server=mock_bridge_server,
injection_generator=mock_injection_generator,
config=mock_config
)
@pytest.mark.asyncio
async def test_get_tool_definition(execute_python_tool):
"""Test that tool definition matches MCP spec."""
definition = execute_python_tool.get_tool_definition()
assert isinstance(definition, Tool)
assert definition.name == "execute_python"
assert definition.description is not None
assert "Execute Python code" in definition.description
# Verify required schema properties
schema = definition.inputSchema
assert schema["type"] == "object"
assert "code" in schema["properties"]
assert "mcp_tools" in schema["properties"]
assert "session_id" in schema["properties"]
assert "backend" in schema["properties"]
assert "timeout" in schema["properties"]
assert "custom_image" in schema["properties"]
assert "environment" in schema["properties"]
assert schema["required"] == ["code"]
@pytest.mark.asyncio
async def test_execute_simple_backend_stateless(
execute_python_tool,
mock_simple_backend
):
"""Test execution with simple backend (stateless)."""
arguments = {
"code": "print('Hello World'); 42"
}
result = await execute_python_tool.execute(arguments)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
assert response["stdout"] == "Hello World"
assert response["result"] == "42"
assert response["execution_time"] == 0.5
assert "session_id" not in response or response["session_id"] is None
# Verify simple backend was called
mock_simple_backend.execute.assert_called_once()
@pytest.mark.asyncio
async def test_execute_jupyter_backend_stateful(
execute_python_tool,
mock_jupyter_backend
):
"""Test execution with jupyter backend (stateful)."""
arguments = {
"code": "x = 42; print('Stateful')",
"session_id": "test-session",
"backend": "jupyter"
}
result = await execute_python_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
assert "session_id" in response
# Verify jupyter backend was called
mock_jupyter_backend.execute_in_session.assert_called_once()
@pytest.mark.asyncio
async def test_execute_with_mcp_tools(
execute_python_tool,
mock_simple_backend,
mock_injection_generator,
mock_bridge_server
):
"""Test execution with MCP tool injection."""
arguments = {
"code": "repos = github_search_repos(query='test', max_results=10)",
"mcp_tools": ["github_search_repos"]
}
result = await execute_python_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is True
assert "available_tools" in response
assert "github_search_repos" in response["available_tools"]
# Verify injection generator was called
mock_injection_generator.generate_injection_code.assert_called_once_with(
tool_names=["github_search_repos"],
socket_path="/tmp/mcp-bridge.sock"
)
@pytest.mark.asyncio
async def test_execute_with_custom_image(
execute_python_tool,
mock_simple_backend
):
"""Test execution with custom image."""
arguments = {
"code": "import pandas as pd; pd.DataFrame()",
"custom_image": "mcp-forge/custom:my-ml-env"
}
_result = await execute_python_tool.execute(arguments)
# Verify backend was called with custom image
call_args = mock_simple_backend.execute.call_args
assert call_args is not None
# Check that custom_image was passed in execution options
assert "image" in call_args.kwargs or "custom_image" in call_args.kwargs
@pytest.mark.asyncio
async def test_execute_with_environment_template(
execute_python_tool,
mock_simple_backend
):
"""Test execution with environment template."""
arguments = {
"code": "import numpy as np; np.array([1,2,3])",
"environment": "datascience"
}
_result = await execute_python_tool.execute(arguments)
# Verify backend was called with environment specification
mock_simple_backend.execute.assert_called_once()
call_args = mock_simple_backend.execute.call_args
assert call_args is not None
@pytest.mark.asyncio
async def test_execute_with_timeout(
execute_python_tool,
mock_simple_backend
):
"""Test execution with custom timeout."""
arguments = {
"code": "import time; time.sleep(10)",
"timeout": 5
}
_result = await execute_python_tool.execute(arguments)
# Verify timeout was passed to backend
call_args = mock_simple_backend.execute.call_args
assert call_args is not None
@pytest.mark.asyncio
async def test_validate_arguments_missing_code(execute_python_tool):
"""Test that validation fails when code is missing."""
arguments = {}
with pytest.raises((ValueError, KeyError)):
await execute_python_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_invalid_backend(execute_python_tool):
"""Test that validation fails for invalid backend."""
arguments = {
"code": "print('test')",
"backend": "invalid"
}
with pytest.raises(ValueError, match="backend"):
await execute_python_tool.execute(arguments)
@pytest.mark.asyncio
async def test_validate_arguments_invalid_timeout(
execute_python_tool,
mock_config
):
"""Test that validation fails for timeout exceeding max."""
arguments = {
"code": "print('test')",
"timeout": 3600 # Exceeds max_timeout of 1800
}
with pytest.raises(ValueError, match="Timeout"):
await execute_python_tool.execute(arguments)
@pytest.mark.asyncio
async def test_backend_selection_with_session_id(execute_python_tool):
"""Test that jupyter backend is selected when session_id provided."""
backend = execute_python_tool._select_backend(
session_id="test-session",
backend=None
)
assert backend == "jupyter"
@pytest.mark.asyncio
async def test_backend_selection_default(
execute_python_tool,
mock_config
):
"""Test that default backend is used when no session_id."""
backend = execute_python_tool._select_backend(
session_id=None,
backend=None
)
assert backend == "simple"
@pytest.mark.asyncio
async def test_backend_selection_explicit(execute_python_tool):
"""Test that explicit backend is respected."""
backend = execute_python_tool._select_backend(
session_id=None,
backend="jupyter"
)
assert backend == "jupyter"
@pytest.mark.asyncio
async def test_execution_error_handling(
execute_python_tool,
mock_simple_backend
):
"""Test that execution errors are handled gracefully."""
mock_simple_backend.execute = AsyncMock(return_value=ExecutionResult(
success=False,
stdout="",
stderr="NameError: name 'undefined_variable' is not defined",
result=None,
execution_time=0.1,
exit_code=1
))
arguments = {
"code": "print(undefined_variable)"
}
result = await execute_python_tool.execute(arguments)
# Parse JSON response
response = json.loads(result[0].text)
assert response["success"] is False
assert "NameError" in response["stderr"]
@pytest.mark.asyncio
async def test_bridge_server_not_running(
execute_python_tool,
mock_bridge_server
):
"""Test that error is raised if bridge server not running."""
mock_bridge_server.is_running = Mock(return_value=False)
arguments = {
"code": "repos = github_search_repos(query='test')",
"mcp_tools": ["github_search_repos"]
}
with pytest.raises(RuntimeError, match="bridge server"):
await execute_python_tool.execute(arguments)