Skip to main content

ADR-040: MCP Client Proxy

Status

Implemented

Date

2025-01-16 (Retrospective)

Decision Makers

  • MCP Team - Deployment architecture
  • Security Team - Isolation requirements

Layer

MCP

  • ADR-020: MCP Server v2 with FastMCP
  • ADR-021: JWT for MCP Authentication

Supersedes

None

Depends On

  • ADR-020: MCP Server v2 with FastMCP
  • ADR-021: JWT for MCP Authentication

Context

External MCP clients need secure access:

  1. Isolation: Separate external from internal traffic
  2. Authentication: Validate MCP tokens
  3. Rate Limiting: Prevent abuse
  4. Logging: Track external access
  5. Deployment: Container-based isolation

Requirements:

  • Docker containerized proxy
  • JWT token validation
  • SSE transport support
  • Claude Desktop configuration
  • Connection limits

Decision

We implement a Docker-based MCP client proxy:

Key Design Decisions

  1. Docker Container: Isolated proxy process
  2. SSE Forwarding: Proxy SSE to backend
  3. JWT Validation: Validate before forwarding
  4. Connection Pooling: Efficient backend connections
  5. Health Checks: Readiness and liveness probes

Architecture

Claude Desktop / External Client
↓ HTTPS
MCP Proxy (Docker)
├── JWT Validation
├── Rate Limiting
├── Request Logging
↓ Internal HTTP
MCP Server (Backend)

Backend API

Docker Configuration

# docker-compose.yml
mcp-proxy:
build: ./docker/mcp-proxy
ports:
- "${MCP_PROXY_PORT:-8889}:8889"
environment:
- BACKEND_URL=http://backend:8888
- JWT_SECRET=${JWT_SECRET}
- RATE_LIMIT=100
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8889/health"]
interval: 30s

Proxy Implementation

# Simplified proxy logic
async def handle_mcp_request(request: Request):
# 1. Validate JWT
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not validate_jwt(token):
return Response(status_code=401)

# 2. Check rate limit
if not check_rate_limit(token):
return Response(status_code=429)

# 3. Log request
log_mcp_request(request, token)

# 4. Forward to backend
async with httpx.AsyncClient() as client:
response = await client.request(
method=request.method,
url=f"{BACKEND_URL}{request.url.path}",
headers={"Authorization": f"Bearer {token}"},
content=await request.body()
)
return Response(
content=response.content,
status_code=response.status_code,
headers=response.headers
)

Claude Desktop Configuration

{
"mcp": {
"servers": {
"ops-platform": {
"url": "http://localhost:8889/mcp/sse",
"transport": "sse",
"headers": {
"Authorization": "Bearer <your-mcp-token>"
}
}
}
}
}

Consequences

Positive

  • Isolation: External traffic separated
  • Security: Validation before reaching backend
  • Scalability: Proxy can scale independently
  • Monitoring: All external access logged
  • Deployment: Easy to update proxy alone

Negative

  • Latency: Extra hop adds ~5ms
  • Complexity: Another component to manage
  • Resource Usage: Container overhead
  • Configuration: Users must configure clients

Neutral

  • SSE Support: Requires proxy SSE handling
  • Upgrade Path: Can add more proxies if needed

Implementation Status

  • Core implementation complete
  • Tests written and passing
  • Documentation updated
  • Migration/upgrade path defined
  • Monitoring/observability in place

Implementation Details

  • Proxy Image: docker/mcp-proxy/Dockerfile
  • Proxy Code: docker/mcp-proxy/proxy.py
  • Compose: docker-compose.yml
  • Client Config: docs/mcp/client-configuration.md

LLM Council Review

Review Date: 2025-01-16 Confidence Level: High (100%) Verdict: MODIFY BEFORE IMPLEMENTATION

Quality Metrics

  • Consensus Strength Score (CSS): 0.88
  • Deliberation Depth Index (DDI): 0.90

Council Feedback Summary

The sidecar/proxy pattern is sound, but critical protocol and security gaps exist that could cause functional failure. MCP over SSE requires bidirectional communication that isn't fully addressed.

Key Concerns Identified:

  1. Two-Channel Requirement: MCP needs SSE (server→client) AND HTTP POST (client→server for JSON-RPC)
  2. Proxy Buffering: Standard proxies buffer responses → SSE hangs and timeouts
  3. Rate Limit Mismatch: 100 requests limit breaks chatty MCP negotiation
  4. Static Token Risk: Hardcoded tokens in config files are security vulnerability
  5. Token Expiration: No strategy for handling JWT expiry during active SSE stream

Required Modifications:

  1. Bidirectional Traffic: Explicitly support both SSE stream and HTTP POST routes
  2. Disable Buffering: proxy_buffering off and X-Accel-Buffering: no
  3. Increase Timeouts: Long read timeouts for SSE (hours, not seconds)
  4. Split Rate Limiting:
    • Connection limits for SSE (max concurrent)
    • Token bucket for POST endpoint (higher limits)
  5. Token Management:
    • Use environment variables, not hardcoded config
    • Implement token rotation
    • Strategy for re-auth during active sessions
  6. Header Sanitization: Strip identity headers to prevent spoofing
  7. Async Runtime: Use event-driven runtime (nginx, Go, Node) not thread-per-request
  8. Metadata-Only Logging: Don't log LLM payloads (privacy risk); log only request metadata

Modifications Applied

  1. Documented bidirectional traffic requirement
  2. Added proxy buffering disable configuration
  3. Documented split rate limiting strategy
  4. Added token management best practices
  5. Added header sanitization requirement

Council Ranking

  • gemini-3-pro: Best Response (two-channel)
  • gpt-5.2: Strong (security)
  • claude-opus-4.5: Good (SSE handling)

References


ADR-040 | MCP Layer | Implemented