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
54 changes: 53 additions & 1 deletion src/cmcp_gateway/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@

from __future__ import annotations

import asyncio
import hmac
import json
import logging
import time
import uuid
from collections import defaultdict
from typing import TYPE_CHECKING, Any

from agent_os.stateless import StatelessKernel
Expand Down Expand Up @@ -53,6 +56,48 @@ async def _unhandled_error_handler(request: Request, exc: Exception) -> Response
)


class _RateLimitMiddleware(BaseHTTPMiddleware):
"""NET-002: per-IP rate limit for unauthenticated endpoints (/health).

Uses a sliding-window counter: at most `requests_per_minute` requests
from a single IP address within any 60-second window.
"""

def __init__(
self,
app: Any,
*,
paths: frozenset[str],
requests_per_minute: int = 60,
) -> None:
super().__init__(app)
self._paths = paths
self._limit = requests_per_minute
self._window = 60.0
self._counts: dict[str, list[float]] = defaultdict(list)
self._lock = asyncio.Lock()

async def dispatch(self, request: Request, call_next: Any) -> Response:
if request.url.path not in self._paths:
return await call_next(request)
ip = request.client[0] if request.client else "unknown"
now = time.monotonic()
async with self._lock:
cutoff = now - self._window
hits = self._counts[ip]
# Prune timestamps outside the window
while hits and hits[0] <= cutoff:
hits.pop(0)
if len(hits) >= self._limit:
return JSONResponse(
{"error": "Too Many Requests", "error_code": "RATE_LIMITED"},
status_code=429,
headers={"Retry-After": "60"},
)
hits.append(now)
return await call_next(request)


class _BearerAuthMiddleware(BaseHTTPMiddleware):
"""AUTH-001 (CRITICAL): validate Authorization: Bearer <token> on all protected endpoints."""

Expand Down Expand Up @@ -108,7 +153,14 @@ def __init__(
self._max_request_bytes = max_request_bytes
self._audit = audit_chain
self._kernel = StatelessKernel()
middleware = (
# NET-002: rate-limit unauthenticated /health before auth middleware runs.
# Starlette applies middleware outermost-first (first in list = first to run).
rate_limit = Middleware(
_RateLimitMiddleware,
paths=frozenset(_AUTH_EXEMPT_PATHS),
requests_per_minute=60,
)
middleware = [rate_limit] + (
[Middleware(_BearerAuthMiddleware, bearer_token=bearer_token)]
if bearer_token is not None
else []
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/test_mcp_server_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,92 @@ def test_content_length_check_rejects_before_body_read():
assert resp.status_code == 413


# ── NET-002: /health rate limit ───────────────────────────────────────────────

def _make_server_with_low_rate_limit(requests_per_minute: int = 3) -> "MCPServer":
"""Create a server with a very low rate limit for testing."""
from cmcp_gateway.mcp.server import _RateLimitMiddleware
from starlette.middleware import Middleware

proxy = MagicMock()
proxy._catalog = MagicMock()
proxy._catalog.entries = {}
with patch("cmcp_gateway.mcp.server.StatelessKernel"):
server = MCPServer(proxy, bearer_token=None)

# Replace rate-limit middleware with a tighter one for this test
from starlette.applications import Starlette
from starlette.routing import Route

server.app = Starlette(
routes=server.app.routes,
middleware=[
Middleware(
_RateLimitMiddleware,
paths=frozenset({"/health"}),
requests_per_minute=requests_per_minute,
)
],
exception_handlers={},
)
return server


def test_health_allows_requests_within_limit():
"""NET-002: requests within rate limit return 200."""
server = _make_server_with_low_rate_limit(requests_per_minute=5)
client = TestClient(server.app, raise_server_exceptions=False)
for _ in range(3):
resp = client.get("/health")
assert resp.status_code == 200


def test_health_rate_limit_returns_429_when_exceeded():
"""NET-002: exceeding rate limit returns 429 with Retry-After header."""
server = _make_server_with_low_rate_limit(requests_per_minute=2)
client = TestClient(server.app, raise_server_exceptions=False)

# First two should pass
assert client.get("/health").status_code == 200
assert client.get("/health").status_code == 200
# Third exceeds limit
resp = client.get("/health")
assert resp.status_code == 429
assert "Retry-After" in resp.headers
body = resp.json()
assert body["error_code"] == "RATE_LIMITED"


def test_rate_limit_middleware_paths_only():
"""NET-002: rate limit applies only to configured paths, not all endpoints."""
from cmcp_gateway.mcp.server import _RateLimitMiddleware
from starlette.middleware import Middleware
from starlette.applications import Starlette

proxy = MagicMock()
proxy._catalog = MagicMock()
proxy._catalog.entries = {}
with patch("cmcp_gateway.mcp.server.StatelessKernel"):
server = MCPServer(proxy, bearer_token=None)

# Rate-limit ONLY /nonexistent (so /health is unaffected)
server.app = Starlette(
routes=server.app.routes,
middleware=[
Middleware(
_RateLimitMiddleware,
paths=frozenset({"/nonexistent"}),
requests_per_minute=1,
)
],
exception_handlers={},
)
client = TestClient(server.app, raise_server_exceptions=False)
for _ in range(5):
resp = client.get("/health")
assert resp.status_code == 200


# ── INJECT-002: sanitize method in error responses ────────────────────────────

def test_unknown_method_non_ascii_is_replaced():
Expand Down
Loading