Série Construire un MCP Server Finance — Partie 5 sur 6

Déployer : Docker, Nomad, et Production

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.

Docker multi-stage Nomad / systemd TLS automatique Health + logs
Construire un MCP Server Finance5/6
DockerfileConfigurationReverse proxyOrchestrationLogs & alertingChecklist prod

Dockerfile multi-stage

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"]
1

Multi-stage

Image finale ~120 MB au lieu de ~450 MB. Pas de compilateur, pas de pip dans le runtime.

2

Non-root

L'app tourne en tant que user mcp. Si compromis, pas d'accès root dans le container.

3

Healthcheck

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}
Section 2

Configuration par variables d'environnement

Les 12-factor apps passent la configuration par variables d'environnement. Un serveur MCP finance a besoin de ces variables :

VariableDéfautDescription
MCP_SERVICE_TOKENRequisToken d'authentification principal
MCP_PORT8080Port d'écoute
MCP_LOG_LEVELINFONiveau de log (DEBUG, INFO, WARNING)
YAHOO_RATE_LIMIT30Requêtes/minute vers Yahoo Finance
CACHE_MAX_SIZE10000Nombre max d'entrées en cache
CACHE_QUOTE_TTL15TTL des quotes en secondes
WORKERS1Nombre 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"))

Le token DOIT être une erreur fatale

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.

Section 3

Reverse proxy et TLS avec Caddy

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"
    }
}

SSE et le buffering

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.

Alternative : Nginx

# 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;
    }
}
Section 4

Orchestration : Nomad, Docker Compose, ou systemd

Trois options selon votre échelle :

OptionQuand l'utiliserComplexité
systemdUn seul serveur, pas de scalingMinimal
Docker ComposeDev/staging, multi-container simpleFaible
NomadProduction, rolling deploys, multi-nodeMoyenne

Option 1 : Nomad (recommandé en production)

# 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"
        }
      }
    }
  }
}

Option 2 : Docker Compose (dev/staging)

# 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:

Option 3 : systemd (bare metal minimal)

# /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
Section 5

Logging structuré et alerting

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}
Section 6

Checklist de mise en production

Quiz : Pourquoi le SSE ne doit jamais être buffered ?
Le buffering accumule les réponses du serveur et les envoie en gros paquets pour optimiser le bandwidth. Pour SSE, ça signifie que le client MCP ne reçoit pas les événements en temps réel — il attend que le buffer soit plein. Résultat : le LLM attend 5-10 secondes pour recevoir une réponse qui prenait 200ms à générer. flush_interval -1 force l'envoi immédiat de chaque événement.

Ce qu'on retient

  • Dockerfile multi-stage — image petite (~120 MB), non-root, healthcheck intégré
  • Env vars pour la config — token requis au démarrage (fail fast)
  • Caddy + TLS auto — 10 lignes de config, SSE unbuffered
  • Nomad pour la prod — rolling deploys, health checks, secrets via Consul
  • Logs JSON — outil, ticker, client, latence dans chaque entrée
  • 10 points de checklist — TLS, non-root, unbuffered, restart, timeouts
Partie suivante (finale)
Intégrations Avancées : Multi-Source, Backtest, Portfolio