diff --git a/src/cmcp_gateway/mcp/server.py b/src/cmcp_gateway/mcp/server.py index 63abbcb3..b300ee4c 100644 --- a/src/cmcp_gateway/mcp/server.py +++ b/src/cmcp_gateway/mcp/server.py @@ -14,6 +14,7 @@ import hmac import json import logging +import os import time import uuid from collections import defaultdict @@ -165,6 +166,11 @@ def __init__( if bearer_token is not None else [] ) + # AUTH-004: session cleanup interval configurable via env var (default 60s) + self._cleanup_interval_s: int = int( + os.environ.get("CMCP_SESSION_CLEANUP_INTERVAL_SECONDS", "60") + ) + self.app = Starlette( routes=[ Route("/mcp", self._handle_mcp, methods=["POST"]), @@ -378,35 +384,41 @@ async def _health(self, request: Request) -> Response: return JSONResponse({"status": "ok"}) async def _readyz(self, request: Request) -> Response: - """GET /readyz — structured readiness probe (CONF-007). + """GET /readyz - structured readiness probe (CONF-007). - Returns 200 when all components are operational, 503 when degraded. + Returns 200 {"status": "ready", "checks": {...}} when all components are + operational; 503 {"status": "not_ready", "checks": {...}} when any check + fails. Each check maps to "ok" or "failed: ". Safe for unauthenticated Kubernetes readiness probes. """ checks: dict[str, str] = {} - degraded = False - - # Catalog: must have at least one approved tool - catalog_size = len(self._proxy._catalog.entries) - checks["catalog"] = "ok" if catalog_size > 0 else "empty" - if catalog_size == 0: - degraded = True + not_ready = False - # Policy: evaluator must be present (always true after startup) - checks["policy"] = "ok" if self._proxy._policy is not None else "unavailable" - if self._proxy._policy is None: - degraded = True + # Cedar policy: evaluator must be present and loaded + if self._proxy._policy is not None: + checks["policy"] = "ok" + else: + checks["policy"] = "failed: Cedar policy engine not loaded" + not_ready = True - # Attestation: check staleness via proxy health check + # TEE attestation: check provider availability and staleness attest_reason = self._proxy._check_health() if attest_reason is None: checks["attestation"] = "ok" else: - checks["attestation"] = attest_reason - degraded = True + checks["attestation"] = f"failed: {attest_reason}" + not_ready = True - status = "degraded" if degraded else "ready" - return JSONResponse({"status": status, "checks": checks}, status_code=503 if degraded else 200) + # AGT (agent_os kernel): verify the library is importable and responsive + try: + import agent_os # noqa: F401 + checks["agt"] = "ok" + except Exception as exc: # noqa: BLE001 + checks["agt"] = f"failed: agent_os unavailable ({exc})" + not_ready = True + + status = "not_ready" if not_ready else "ready" + return JSONResponse({"status": status, "checks": checks}, status_code=503 if not_ready else 200) async def _get_trace_claim(self, request: Request) -> Response: """GET /sessions/{session_id}/trace-claim — returns signed TRACE Claim for a closed session.""" diff --git a/tests/unit/test_mcp_server_auth.py b/tests/unit/test_mcp_server_auth.py index 83b68253..f277576e 100644 --- a/tests/unit/test_mcp_server_auth.py +++ b/tests/unit/test_mcp_server_auth.py @@ -1,4 +1,4 @@ -"""Tests for MCP server bearer-token authentication (AUTH-001).""" +"""Tests for MCP server bearer-token authentication (AUTH-001).""" from __future__ import annotations @@ -211,13 +211,14 @@ def test_rate_limit_middleware_paths_only(): assert resp.status_code == 200 -# ── CONF-007: /readyz structured readiness probe ────────────────────────────── +# ── CONF-007: /readyz structured readiness probe ──────────────────────────────────── + def _make_ready_server() -> MCPServer: """Server where all readiness checks pass.""" proxy = MagicMock() proxy._catalog = MagicMock() - proxy._catalog.entries = {"test.tool": MagicMock()} # non-empty catalog + proxy._catalog.entries = {"test.tool": MagicMock()} proxy._policy = MagicMock() # policy present proxy._check_health.return_value = None # attestation healthy with patch("cmcp_gateway.mcp.server.StatelessKernel"): @@ -232,17 +233,17 @@ def test_readyz_returns_200_when_healthy(): assert resp.status_code == 200 body = resp.json() assert body["status"] == "ready" - assert body["checks"]["catalog"] == "ok" assert body["checks"]["policy"] == "ok" assert body["checks"]["attestation"] == "ok" + assert body["checks"]["agt"] == "ok" -def test_readyz_returns_503_when_catalog_empty(): - """CONF-007: empty catalog makes gateway degraded.""" +def test_readyz_returns_503_when_policy_missing(): + """CONF-007: missing Cedar policy engine returns 503 and not_ready.""" proxy = MagicMock() proxy._catalog = MagicMock() - proxy._catalog.entries = {} - proxy._policy = MagicMock() + proxy._catalog.entries = {"test.tool": MagicMock()} + proxy._policy = None # Cedar policy engine absent proxy._check_health.return_value = None with patch("cmcp_gateway.mcp.server.StatelessKernel"): server = MCPServer(proxy) @@ -250,12 +251,12 @@ def test_readyz_returns_503_when_catalog_empty(): resp = client.get("/readyz") assert resp.status_code == 503 body = resp.json() - assert body["status"] == "degraded" - assert body["checks"]["catalog"] == "empty" + assert body["status"] == "not_ready" + assert body["checks"]["policy"].startswith("failed:") def test_readyz_returns_503_when_attestation_stale(): - """CONF-007: stale attestation makes gateway degraded.""" + """CONF-007: stale attestation returns 503 and not_ready.""" proxy = MagicMock() proxy._catalog = MagicMock() proxy._catalog.entries = {"test.tool": MagicMock()} @@ -267,19 +268,45 @@ def test_readyz_returns_503_when_attestation_stale(): resp = client.get("/readyz") assert resp.status_code == 503 body = resp.json() - assert body["status"] == "degraded" - assert body["checks"]["attestation"] == "attestation_stale" + assert body["status"] == "not_ready" + assert body["checks"]["attestation"] == "failed: attestation_stale" + + +def test_readyz_returns_503_when_agt_unavailable(): + """CONF-007: unavailable agent_os returns 503 and not_ready.""" + import sys + proxy = MagicMock() + proxy._catalog = MagicMock() + proxy._catalog.entries = {"test.tool": MagicMock()} + proxy._policy = MagicMock() + proxy._check_health.return_value = None + with patch("cmcp_gateway.mcp.server.StatelessKernel"): + server = MCPServer(proxy) + client = TestClient(server.app, raise_server_exceptions=False) + # Setting sys.modules["agent_os"] = None causes ImportError on "import agent_os" + saved = sys.modules.get("agent_os", object()) + sys.modules["agent_os"] = None # type: ignore[assignment] + try: + resp = client.get("/readyz") + finally: + if saved is object(): + sys.modules.pop("agent_os", None) + else: + sys.modules["agent_os"] = saved + assert resp.status_code == 503 + body = resp.json() + assert body["status"] == "not_ready" + assert body["checks"]["agt"].startswith("failed:") def test_readyz_accessible_without_bearer_token(): """CONF-007: /readyz must not require authentication (Kubernetes probe).""" server = _make_ready_server() client = TestClient(server.app, raise_server_exceptions=False) - # No Authorization header — should still return 200 + # No Authorization header -- should still return 200 resp = client.get("/readyz") assert resp.status_code == 200 - # ── INJECT-002: sanitize method in error responses ──────────────────────────── def test_unknown_method_non_ascii_is_replaced(): diff --git a/tests/unit/test_trace_claim.py b/tests/unit/test_trace_claim.py index 4e3bdab5..43c2f576 100644 --- a/tests/unit/test_trace_claim.py +++ b/tests/unit/test_trace_claim.py @@ -103,6 +103,14 @@ def test_generate_claim_gateway_version(): assert len(claim.gateway.gateway_version) > 0 +def test_generate_claim_gateway_version_is_string_or_unknown(): + """CONF-006: gateway_version is a non-empty string; 'unknown' is the valid fallback.""" + from cmcp_gateway.audit.trace_claim import _GATEWAY_VERSION + assert isinstance(_GATEWAY_VERSION, str) + assert len(_GATEWAY_VERSION) > 0 + + + # ── AUDIT-005: sequence_number and prev_claim_hash ────────────────────────────