Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions src/cmcp_gateway/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import hmac
import json
import logging
import os
import time
import uuid
from collections import defaultdict
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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: <reason>".
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."""
Expand Down
57 changes: 42 additions & 15 deletions tests/unit/test_mcp_server_auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"):
Expand All @@ -232,30 +233,30 @@ 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)
client = TestClient(server.app, raise_server_exceptions=False)
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()}
Expand All @@ -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():
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_trace_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────


Expand Down
Loading