Du laptop au serveur : containeriser avec Docker, orchestrer avec Nomad, configurer Caddy pour le TLS automatique, et les bonnes pratiques pour un serveur MCP qui tourne 24/7.
Un bon Dockerfile pour un serveur MCP Python est multi-stage : un stage de build installe les dépendances, et le stage final copie uniquement le nécessaire. Résultat : une image plus petite et plus sécurisée.
# Dockerfile — MCP Finance Server # Stage 1 : Build FROM python:3.12-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # Stage 2 : Runtime FROM python:3.12-slim WORKDIR /app # Copier les dépendances installées COPY --from=builder /install /usr/local # Copier le code COPY server.py . COPY tools/ tools/ COPY providers/ providers/ COPY cache.py config.py auth.py rate_limiter.py ./ # User non-root RUN useradd -r -s /bin/false mcp USER mcp # Health check intégré HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" EXPOSE 8080 CMD ["python", "server.py"]
Image finale ~120 MB au lieu de ~450 MB. Pas de compilateur, pas de pip dans le runtime.
L'app tourne en tant que user mcp. Si compromis, pas d'accès root dans le container.
Docker sait si le process est sain. Nomad/K8s/Compose peuvent redémarrer automatiquement.
# Build et test local docker build -t mcp-finance:latest . docker run -p 8080:8080 \ -e MCP_SERVICE_TOKEN=sk-test-123 \ mcp-finance:latest # Vérifier le health check curl http://localhost:8080/health # → {"status":"ok","tools":5}
Les 12-factor apps passent la configuration par variables d'environnement. Un serveur MCP finance a besoin de ces variables :
| Variable | Défaut | Description |
|---|---|---|
MCP_SERVICE_TOKEN | Requis | Token d'authentification principal |
MCP_PORT | 8080 | Port d'écoute |
MCP_LOG_LEVEL | INFO | Niveau de log (DEBUG, INFO, WARNING) |
YAHOO_RATE_LIMIT | 30 | Requêtes/minute vers Yahoo Finance |
CACHE_MAX_SIZE | 10000 | Nombre max d'entrées en cache |
CACHE_QUOTE_TTL | 15 | TTL des quotes en secondes |
WORKERS | 1 | Nombre de workers uvicorn |
# config.py — Configuration centralisée import os class Config: PORT = int(os.getenv("MCP_PORT", "8080")) LOG_LEVEL = os.getenv("MCP_LOG_LEVEL", "INFO") SERVICE_TOKEN = os.environ["MCP_SERVICE_TOKEN"] # Crash si absent YAHOO_RATE_LIMIT = int(os.getenv("YAHOO_RATE_LIMIT", "30")) CACHE_MAX_SIZE = int(os.getenv("CACHE_MAX_SIZE", "10000")) CACHE_QUOTE_TTL = int(os.getenv("CACHE_QUOTE_TTL", "15")) WORKERS = int(os.getenv("WORKERS", "1"))
os.environ["MCP_SERVICE_TOKEN"] (pas os.getenv) fait crasher le serveur au démarrage si le token n'est pas défini. C'est voulu. Un serveur MCP finance sans authentification ne doit jamais démarrer en production. Fail fast, fail loud.
En production, le serveur MCP ne doit jamais être exposé directement. Un reverse proxy gère le TLS, la compression, et les timeouts SSE. Caddy est le choix le plus simple :
# Caddyfile mcp.example.com { # TLS automatique via Let's Encrypt # SSE endpoint — pas de buffer, pas de timeout handle /sse { reverse_proxy localhost:8080 { flush_interval -1 # Désactiver le buffering SSE transport http { read_timeout 0 # SSE = connexion longue durée } } } # Tous les autres endpoints handle { reverse_proxy localhost:8080 { header_up X-Real-IP {remote} } } # Headers de sécurité header { Strict-Transport-Security "max-age=31536000;" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" } }
Le piège #1 du déploiement SSE : le reverse proxy bufferise les réponses par défaut. Pour le HTML classique, c'est bien (compression). Pour SSE, c'est fatal — les événements arrivent par paquets au lieu d'être streamés en temps réel. flush_interval -1 dans Caddy (ou proxy_buffering off dans Nginx) résout le problème.
# nginx.conf — pour ceux qui préfèrent Nginx server { listen 443 ssl http2; server_name mcp.example.com; ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem; location /sse { proxy_pass http://127.0.0.1:8080; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_buffering off; # CRITIQUE pour SSE proxy_read_timeout 86400; # 24h — SSE longue durée proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:8080; proxy_set_header X-Real-IP $remote_addr; } }
Trois options selon votre échelle :
| Option | Quand l'utiliser | Complexité |
|---|---|---|
| systemd | Un seul serveur, pas de scaling | Minimal |
| Docker Compose | Dev/staging, multi-container simple | Faible |
| Nomad | Production, rolling deploys, multi-node | Moyenne |
# mcp-finance.nomad.hcl job "mcp-finance" { datacenters = ["dc1"] type = "service" group "server" { count = 1 network { port "http" { to = 8080 } } task "mcp" { driver = "docker" config { image = "mcp-finance:latest" ports = ["http"] } env { MCP_PORT = "8080" MCP_LOG_LEVEL = "INFO" YAHOO_RATE_LIMIT = "30" } template { data = <<EOH MCP_SERVICE_TOKEN={{ key "mcp/service-token" }} EOH destination = "secrets/env" env = true } resources { cpu = 500 memory = 256 } service { name = "mcp-finance" port = "http" check { type = "http" path = "/health" interval = "15s" timeout = "5s" } } } } }
# docker-compose.yml services: mcp-finance: build: . ports: - "8080:8080" environment: - MCP_SERVICE_TOKEN=sk-dev-token - MCP_LOG_LEVEL=DEBUG restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s retries: 3 caddy: image: caddy:2 ports: - "443:443" - "80:80" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data volumes: caddy_data:
# /etc/systemd/system/mcp-finance.service
[Unit]
Description=MCP Finance Server
After=network.target
[Service]
User=mcp
WorkingDirectory=/opt/mcp-finance
ExecStart=/opt/mcp-finance/.venv/bin/python server.py
Restart=always
RestartSec=5
Environment=MCP_SERVICE_TOKEN=sk-prod-xxx
Environment=MCP_LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target
En production, les logs sont votre seule fenêtre sur ce qui se passe. Utilisez du JSON structuré pour que les outils d'analyse (Loki, ELK, CloudWatch) puissent indexer et filtrer :
# logging_config.py — Logs JSON structurés import json import logging import time class JSONFormatter(logging.Formatter): def format(self, record): log = { "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)), "level": record.levelname, "msg": record.getMessage(), "logger": record.name, } # Ajouter le contexte MCP si présent if hasattr(record, "tool"): log["tool"] = record.tool if hasattr(record, "ticker"): log["ticker"] = record.ticker if hasattr(record, "client"): log["client"] = record.client if hasattr(record, "latency_ms"): log["latency_ms"] = record.latency_ms if record.exc_info: log["error"] = self.formatException(record.exc_info) return json.dumps(log)
Exemple de logs en production :
# Appel réussi {"ts":"2026-06-20T21:15:03Z","level":"INFO","msg":"tool_call","tool":"QueryData","ticker":"AAPL","client":"scanner-bot","latency_ms":45} # Cache hit {"ts":"2026-06-20T21:15:04Z","level":"DEBUG","msg":"cache_hit","tool":"QueryData","ticker":"AAPL","cache_type":"technicals"} # Erreur upstream {"ts":"2026-06-20T21:15:10Z","level":"ERROR","msg":"upstream_error","tool":"QueryData","ticker":"ZZZZ","error":"KeyError: 'chart'"} # Rate limited {"ts":"2026-06-20T21:15:15Z","level":"WARNING","msg":"rate_limited","client":"dev-user","retry_after_s":3.2}
/health vérifié toutes les 15-30s par l'orchestrateurflush_interval -1 (Caddy) ou proxy_buffering off (Nginx)restart: unless-stopped ou Restart=alwaysflush_interval -1 force l'envoi immédiat de chaque événement.