Merge pull request #834 from coleam00/security/remove-docker-socket-risk

Security: Remove Docker socket mounting to eliminate CVE-2025-9074 risk
This commit is contained in:
DIY Smart Code
2025-11-06 08:45:50 +01:00
committed by GitHub
7 changed files with 576 additions and 56 deletions

View File

@@ -80,6 +80,29 @@ SERVICE_DISCOVERY_MODE=local
STATE_STORAGE_TYPE=file
FILE_STATE_DIRECTORY=agent-work-orders-state
# MCP Server Monitoring (Security Configuration)
# Controls how archon-server monitors MCP server status
#
# HTTP Mode (Recommended - Default):
# - Secure: No Docker socket access required
# - Portable: Works in Docker, Kubernetes, bare metal
# - Set: ENABLE_DOCKER_SOCKET_MONITORING=false (or leave unset)
#
# Docker Socket Mode (Legacy - Security Risk):
# - Requires: Docker socket mounted (root-equivalent host access)
# - Security Risk: CVE-2025-9074 demonstrates container escape vulnerabilities
# - Only use if: You specifically need Docker container uptime details
# - Set: ENABLE_DOCKER_SOCKET_MONITORING=true
# - Also requires: Uncommenting Docker socket volume in docker-compose.yml (line 36)
#
# Default: false (HTTP mode, secure)
ENABLE_DOCKER_SOCKET_MONITORING=false
# MCP Health Check Timeout (seconds)
# Timeout for HTTP health check requests to MCP server
# Default: 5
MCP_HEALTH_CHECK_TIMEOUT=5
# Frontend Configuration
# VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server
# Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com

View File

@@ -33,7 +33,12 @@ services:
networks:
- app-network
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for MCP container control
# SECURITY: Docker socket mounting removed (CVE-2025-9074 - CVSS 9.3)
# MCP status now monitored via HTTP health checks (secure, portable)
# To re-enable Docker socket mode (not recommended):
# 1. Set ENABLE_DOCKER_SOCKET_MONITORING=true in .env
# 2. Uncomment the line below
# - /var/run/docker.sock:/var/run/docker.sock # SECURITY RISK: root-equivalent host access
- ./python/src:/app/src # Mount source code for hot reload
- ./python/tests:/app/tests # Mount tests for UI test execution
- ./migration:/app/migration # Mount migration files for version tracking

View File

@@ -58,7 +58,9 @@ server = [
"httpx>=0.24.0",
"pydantic>=2.0.0",
"python-dotenv>=1.0.0",
"docker>=6.1.0",
# OPTIONAL: Docker SDK only needed for legacy Docker socket monitoring mode
# Uncomment if ENABLE_DOCKER_SOCKET_MONITORING=true (not recommended - security risk)
# "docker>=6.1.0",
"tldextract>=5.0.0",
# Logging
"logfire>=0.30.0",
@@ -128,7 +130,9 @@ all = [
"python-jose[cryptography]>=3.3.0",
"cryptography>=41.0.0",
"slowapi>=0.1.9",
"docker>=6.1.0",
# OPTIONAL: Docker SDK only needed for legacy Docker socket monitoring mode
# Uncomment if ENABLE_DOCKER_SOCKET_MONITORING=true (not recommended - security risk)
# "docker>=6.1.0",
"tldextract>=5.0.0",
"logfire>=0.30.0",
# MCP specific (mcp version)

View File

@@ -3,23 +3,93 @@ MCP API endpoints for Archon
Provides status and configuration endpoints for the MCP service.
The MCP container is managed by docker-compose, not by this API.
Status monitoring uses HTTP health checks by default (secure, portable).
Docker socket mode available via ENABLE_DOCKER_SOCKET_MONITORING (legacy, security risk).
"""
import os
from typing import Any
import docker
from docker.errors import NotFound
import httpx
from fastapi import APIRouter, HTTPException
# Import unified logging
from ..config.config import get_mcp_monitoring_config
from ..config.logfire_config import api_logger, safe_set_attribute, safe_span
from ..config.service_discovery import get_mcp_url
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def get_container_status() -> dict[str, Any]:
"""Get simple MCP container status without Docker management."""
async def get_container_status_http() -> dict[str, Any]:
"""Get MCP server status via HTTP health check endpoint.
This is the secure, recommended approach that doesn't require Docker socket.
Works across all deployment environments (Docker, Kubernetes, bare metal).
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
config = get_mcp_monitoring_config()
mcp_url = get_mcp_url()
try:
# Use async context manager for proper connection cleanup
async with httpx.AsyncClient(timeout=config.health_check_timeout) as client:
response = await client.get(f"{mcp_url}/health")
response.raise_for_status()
# MCP health endpoint returns: {"success": bool, "uptime_seconds": int, "health": {...}}
data = response.json()
# Transform to expected API contract
uptime_value = data.get("uptime_seconds")
return {
"status": "running" if data.get("success") else "unhealthy",
"uptime": int(uptime_value) if uptime_value is not None else None,
"logs": [], # Historical artifact, kept for API compatibility
}
except httpx.ConnectError:
# MCP container not running or unreachable
api_logger.warning("MCP server unreachable via HTTP health check")
return {
"status": "unreachable",
"uptime": None,
"logs": [],
}
except httpx.TimeoutException:
# MCP responding too slowly
api_logger.warning(f"MCP server health check timed out after {config.health_check_timeout}s")
return {
"status": "unhealthy",
"uptime": None,
"logs": [],
}
except Exception:
# Unexpected error
api_logger.error("Failed to check MCP server health via HTTP", exc_info=True)
return {
"status": "error",
"uptime": None,
"logs": [],
}
def get_container_status_docker() -> dict[str, Any]:
"""Get MCP container status via Docker socket (legacy mode).
SECURITY WARNING: Requires Docker socket mounted, granting root-equivalent host access.
Only enable this mode if you specifically need Docker container status details.
Set ENABLE_DOCKER_SOCKET_MONITORING=true to use this mode.
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
import docker
from docker.errors import NotFound
docker_client = None
try:
docker_client = docker.from_env()
@@ -34,6 +104,7 @@ def get_container_status() -> dict[str, Any]:
# Try to get uptime from container info
try:
from datetime import datetime
started_at = container.attrs["State"]["StartedAt"]
started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
uptime = int((datetime.now(started_time.tzinfo) - started_time).total_seconds())
@@ -47,27 +118,26 @@ def get_container_status() -> dict[str, Any]:
"status": status,
"uptime": uptime,
"logs": [], # No log streaming anymore
"container_status": container_status
}
except NotFound:
api_logger.warning("MCP container not found via Docker socket")
return {
"status": "not_found",
"uptime": None,
"logs": [],
"container_status": "not_found",
"message": "MCP container not found. Run: docker compose up -d archon-mcp"
"message": "MCP container not found. Run: docker compose up -d archon-mcp",
}
except Exception as e:
api_logger.error("Failed to get container status", exc_info=True)
api_logger.error("Failed to get MCP container status via Docker", exc_info=True)
return {
"status": "error",
"uptime": None,
"logs": [],
"container_status": "error",
"error": str(e)
"error": str(e),
}
finally:
# CRITICAL: Always close Docker client to prevent connection leaks
if docker_client is not None:
try:
docker_client.close()
@@ -75,15 +145,38 @@ def get_container_status() -> dict[str, Any]:
pass
async def get_container_status() -> dict[str, Any]:
"""Get MCP server status using configured monitoring strategy.
Routes to HTTP health check (secure, default) or Docker socket (legacy).
Returns:
Status dict: {"status": str, "uptime": int|None, "logs": []}
"""
config = get_mcp_monitoring_config()
if config.enable_docker_socket:
api_logger.info("Using Docker socket monitoring (ENABLE_DOCKER_SOCKET_MONITORING=true)")
# Docker mode is synchronous
return get_container_status_docker()
else:
# HTTP mode is asynchronous (default)
return await get_container_status_http()
@router.get("/status")
async def get_status():
"""Get MCP server status."""
"""Get MCP server status.
Returns container/server status, uptime, and logs (empty).
Monitoring strategy controlled by ENABLE_DOCKER_SOCKET_MONITORING env var.
"""
with safe_span("api_mcp_status") as span:
safe_set_attribute(span, "endpoint", "/api/mcp/status")
safe_set_attribute(span, "method", "GET")
try:
status = get_container_status()
status = await get_container_status()
api_logger.debug(f"MCP server status checked - status={status.get('status')}")
safe_set_attribute(span, "status", status.get("status"))
safe_set_attribute(span, "uptime", status.get("uptime"))
@@ -91,7 +184,7 @@ async def get_status():
except Exception as e:
api_logger.error(f"MCP server status API failed - error={str(e)}")
safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/config")
@@ -118,9 +211,7 @@ async def get_mcp_config():
try:
from ..services.credential_service import credential_service
model_choice = await credential_service.get_credential(
"MODEL_CHOICE", "gpt-4o-mini"
)
model_choice = await credential_service.get_credential("MODEL_CHOICE", "gpt-4o-mini")
config["model_choice"] = model_choice
except Exception:
# Fallback to default model
@@ -136,7 +227,7 @@ async def get_mcp_config():
except Exception as e:
api_logger.error("Failed to get MCP configuration", exc_info=True)
safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail={"error": str(e)})
raise HTTPException(status_code=500, detail={"error": str(e)}) from e
@router.get("/clients")
@@ -151,18 +242,11 @@ async def get_mcp_clients():
# For now, return empty array as expected by frontend
api_logger.debug("Getting MCP clients - returning empty array")
return {
"clients": [],
"total": 0
}
return {"clients": [], "total": 0}
except Exception as e:
api_logger.error(f"Failed to get MCP clients - error={str(e)}")
safe_set_attribute(span, "error", str(e))
return {
"clients": [],
"total": 0,
"error": str(e)
}
return {"clients": [], "total": 0, "error": str(e)}
@router.get("/sessions")
@@ -174,7 +258,7 @@ async def get_mcp_sessions():
try:
# Basic session info for now
status = get_container_status()
status = await get_container_status()
session_info = {
"active_sessions": 0, # TODO: Implement real session tracking
@@ -192,7 +276,7 @@ async def get_mcp_sessions():
except Exception as e:
api_logger.error(f"Failed to get MCP sessions - error={str(e)}")
safe_set_attribute(span, "error", str(e))
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/health")

View File

@@ -38,6 +38,23 @@ class RAGStrategyConfig:
use_reranking: bool = True
@dataclass
class MCPMonitoringConfig:
"""Configuration for MCP server monitoring strategy.
Controls how archon-server monitors MCP server status - via HTTP health checks
(secure, default) or Docker socket (legacy, security risk).
Attributes:
enable_docker_socket: Whether to use Docker socket for container status.
Default False for security (uses HTTP health checks).
health_check_timeout: Timeout in seconds for HTTP health check requests.
"""
enable_docker_socket: bool = False
health_check_timeout: int = 5
def validate_openai_api_key(api_key: str) -> bool:
"""Validate OpenAI API key format."""
if not api_key:
@@ -68,14 +85,14 @@ def validate_supabase_key(supabase_key: str) -> tuple[bool, str]:
# Also skip all other validations (aud, exp, etc) since we only care about the role
decoded = jwt.decode(
supabase_key,
'',
"",
options={
"verify_signature": False,
"verify_aud": False,
"verify_exp": False,
"verify_nbf": False,
"verify_iat": False
}
"verify_iat": False,
},
)
role = decoded.get("role")
@@ -232,3 +249,29 @@ def get_rag_strategy_config() -> RAGStrategyConfig:
use_agentic_rag=str_to_bool(os.getenv("USE_AGENTIC_RAG")),
use_reranking=str_to_bool(os.getenv("USE_RERANKING")),
)
def get_mcp_monitoring_config() -> MCPMonitoringConfig:
"""Load MCP monitoring configuration from environment variables.
Environment Variables:
ENABLE_DOCKER_SOCKET_MONITORING: "true"/"false" (default: false)
Controls whether to use Docker socket for status monitoring.
Default is false for security (uses HTTP health checks instead).
MCP_HEALTH_CHECK_TIMEOUT: Timeout in seconds (default: 5)
Timeout for HTTP health check requests to MCP server.
Returns:
MCPMonitoringConfig with parsed settings.
"""
def str_to_bool(value: str | None) -> bool:
"""Convert string environment variable to boolean."""
if value is None:
return False
return value.lower() in ("true", "1", "yes", "on")
return MCPMonitoringConfig(
enable_docker_socket=str_to_bool(os.getenv("ENABLE_DOCKER_SOCKET_MONITORING")),
health_check_timeout=int(os.getenv("MCP_HEALTH_CHECK_TIMEOUT", "5")),
)

View File

@@ -0,0 +1,381 @@
"""
Tests for MCP API endpoints with HTTP and Docker socket modes.
"""
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from src.server.api_routes.mcp_api import (
get_container_status,
get_container_status_docker,
get_container_status_http,
)
from src.server.config.config import MCPMonitoringConfig
@pytest.fixture
def mock_mcp_url():
"""Mock MCP URL for testing."""
return "http://test-mcp:8051"
@pytest.fixture
def mock_config_http():
"""Mock configuration with HTTP mode enabled."""
return MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
@pytest.fixture
def mock_config_docker():
"""Mock configuration with Docker socket mode enabled."""
return MCPMonitoringConfig(enable_docker_socket=True, health_check_timeout=5)
# HTTP Mode Tests
@pytest.mark.asyncio
async def test_get_container_status_http_running(mock_mcp_url):
"""Test HTTP health check when MCP server is running."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 123.45, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Create mock async context manager
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "running"
assert result["uptime"] == 123
assert result["logs"] == []
mock_client.get.assert_called_once_with(f"{mock_mcp_url}/health")
@pytest.mark.asyncio
async def test_get_container_status_http_unreachable(mock_mcp_url):
"""Test HTTP health check when MCP server is unreachable."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock connection error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unreachable"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_timeout(mock_mcp_url):
"""Test HTTP health check when MCP server times out."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock timeout error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timeout"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unhealthy"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_unhealthy(mock_mcp_url):
"""Test HTTP health check when MCP server reports unhealthy."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": False, "error": "Service unavailable"}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "unhealthy"
assert result["uptime"] is None
assert result["logs"] == []
@pytest.mark.asyncio
async def test_get_container_status_http_zero_uptime(mock_mcp_url):
"""Test HTTP health check preserves 0 uptime for freshly-launched MCP."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 0, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "running"
assert result["uptime"] == 0 # Important: 0 should be preserved, not None
assert result["logs"] == []
mock_client.get.assert_called_once_with(f"{mock_mcp_url}/health")
@pytest.mark.asyncio
async def test_get_container_status_http_error(mock_mcp_url):
"""Test HTTP health check when an unexpected error occurs."""
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
# Mock unexpected error
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=Exception("Unexpected error"))
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status_http()
assert result["status"] == "error"
assert result["uptime"] is None
assert result["logs"] == []
# Docker Mode Tests
def test_get_container_status_docker_running():
"""Test Docker socket check when container is running."""
mock_container = MagicMock()
mock_container.status = "running"
mock_container.attrs = {
"State": {"StartedAt": "2025-01-01T00:00:00.000000000Z"},
}
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "running"
assert result["uptime"] is not None # Uptime should be calculated
assert result["logs"] == []
mock_docker_client.containers.get.assert_called_once_with("archon-mcp")
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_stopped():
"""Test Docker socket check when container is stopped."""
mock_container = MagicMock()
mock_container.status = "exited"
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "stopped"
assert result["uptime"] is None
assert result["logs"] == []
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_not_found():
"""Test Docker socket check when container is not found."""
# Create a mock NotFound exception
mock_not_found = type("NotFound", (Exception,), {})
mock_docker_client = MagicMock()
mock_docker_client.containers.get.side_effect = mock_not_found("Container not found")
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker.errors = MagicMock()
mock_docker.errors.NotFound = mock_not_found
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker.errors}):
result = get_container_status_docker()
assert result["status"] == "not_found"
assert result["uptime"] is None
assert result["logs"] == []
assert "message" in result
mock_docker_client.close.assert_called_once()
def test_get_container_status_docker_error():
"""Test Docker socket check when an error occurs."""
mock_docker_client = MagicMock()
mock_docker_client.containers.get.side_effect = Exception("Docker error")
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}):
result = get_container_status_docker()
assert result["status"] == "error"
assert result["uptime"] is None
assert result["logs"] == []
assert "error" in result
mock_docker_client.close.assert_called_once()
# Routing Tests
@pytest.mark.asyncio
async def test_get_container_status_routes_to_http(mock_mcp_url):
"""Test that get_container_status routes to HTTP mode by default."""
mock_response = MagicMock()
mock_response.json.return_value = {"success": True, "uptime_seconds": 100, "health": {}}
mock_response.status_code = 200
with (
patch("src.server.api_routes.mcp_api.get_mcp_url", return_value=mock_mcp_url),
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch("httpx.AsyncClient") as mock_client_class,
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=False, health_check_timeout=5)
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None
result = await get_container_status()
assert result["status"] == "running"
assert result["uptime"] == 100
mock_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_get_container_status_routes_to_docker():
"""Test that get_container_status routes to Docker mode when enabled."""
mock_container = MagicMock()
mock_container.status = "running"
mock_container.attrs = {
"State": {"StartedAt": "2025-01-01T00:00:00.000000000Z"},
}
mock_docker_client = MagicMock()
mock_docker_client.containers.get.return_value = mock_container
# Create mock docker module with errors submodule
mock_docker = MagicMock()
mock_docker.from_env.return_value = mock_docker_client
mock_docker_errors = MagicMock()
mock_docker_errors.NotFound = type("NotFound", (Exception,), {})
with (
patch("src.server.api_routes.mcp_api.get_mcp_monitoring_config") as mock_get_config,
patch.dict("sys.modules", {"docker": mock_docker, "docker.errors": mock_docker_errors}),
):
mock_get_config.return_value = MCPMonitoringConfig(enable_docker_socket=True, health_check_timeout=5)
result = await get_container_status()
assert result["status"] == "running"
mock_docker_client.containers.get.assert_called_once_with("archon-mcp")
mock_docker_client.close.assert_called_once()
# Environment Variable Tests
@pytest.mark.asyncio
async def test_config_defaults_to_http_mode():
"""Test that configuration defaults to secure HTTP mode."""
# Clear any environment variables
os.environ.pop("ENABLE_DOCKER_SOCKET_MONITORING", None)
os.environ.pop("MCP_HEALTH_CHECK_TIMEOUT", None)
from src.server.config.config import get_mcp_monitoring_config
config = get_mcp_monitoring_config()
assert config.enable_docker_socket is False
assert config.health_check_timeout == 5
@pytest.mark.asyncio
async def test_config_respects_environment_variables():
"""Test that configuration respects environment variables."""
os.environ["ENABLE_DOCKER_SOCKET_MONITORING"] = "true"
os.environ["MCP_HEALTH_CHECK_TIMEOUT"] = "10"
from src.server.config.config import get_mcp_monitoring_config
config = get_mcp_monitoring_config()
assert config.enable_docker_socket is True
assert config.health_check_timeout == 10
# Cleanup
os.environ.pop("ENABLE_DOCKER_SOCKET_MONITORING", None)
os.environ.pop("MCP_HEALTH_CHECK_TIMEOUT", None)

24
python/uv.lock generated
View File

@@ -188,7 +188,6 @@ all = [
{ name = "asyncpg" },
{ name = "crawl4ai" },
{ name = "cryptography" },
{ name = "docker" },
{ name = "factory-boy" },
{ name = "fastapi" },
{ name = "httpx" },
@@ -213,6 +212,7 @@ all = [
{ name = "sse-starlette" },
{ name = "structlog" },
{ name = "supabase" },
{ name = "tldextract" },
{ name = "uvicorn" },
{ name = "watchfiles" },
]
@@ -240,7 +240,6 @@ server = [
{ name = "asyncpg" },
{ name = "crawl4ai" },
{ name = "cryptography" },
{ name = "docker" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "logfire" },
@@ -295,7 +294,6 @@ all = [
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "crawl4ai", specifier = "==0.7.4" },
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "docker", specifier = ">=6.1.0" },
{ name = "factory-boy", specifier = ">=3.3.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.24.0" },
@@ -320,6 +318,7 @@ all = [
{ name = "sse-starlette", specifier = ">=2.3.3" },
{ name = "structlog", specifier = ">=23.1.0" },
{ name = "supabase", specifier = "==2.15.1" },
{ name = "tldextract", specifier = ">=5.0.0" },
{ name = "uvicorn", specifier = ">=0.24.0" },
{ name = "watchfiles", specifier = ">=0.18" },
]
@@ -347,7 +346,6 @@ server = [
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "crawl4ai", specifier = "==0.7.4" },
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "docker", specifier = ">=6.1.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "logfire", specifier = ">=0.30.0" },
@@ -819,20 +817,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
]
[[package]]
name = "docker"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
@@ -2263,10 +2247,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183 },
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542 },
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897 },
{ url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087 },
{ url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387 },
{ url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495 },
{ url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008 },
]
[[package]]