diff --git a/.env.example b/.env.example index 1b68847e..48f38e6b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f985da73..17511616 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 657f03a2..128e4332 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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) diff --git a/python/src/server/api_routes/mcp_api.py b/python/src/server/api_routes/mcp_api.py index 5c9c605d..3a8aecec 100644 --- a/python/src/server/api_routes/mcp_api.py +++ b/python/src/server/api_routes/mcp_api.py @@ -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") diff --git a/python/src/server/config/config.py b/python/src/server/config/config.py index 34284c19..d8104bb0 100644 --- a/python/src/server/config/config.py +++ b/python/src/server/config/config.py @@ -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")), + ) diff --git a/python/tests/server/api_routes/test_mcp_api.py b/python/tests/server/api_routes/test_mcp_api.py new file mode 100644 index 00000000..34e692ee --- /dev/null +++ b/python/tests/server/api_routes/test_mcp_api.py @@ -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) diff --git a/python/uv.lock b/python/uv.lock index e1388bb4..27c43c2e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -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]]