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

Auth, Rate Limiting et Cache Intelligent

Sécuriser le serveur avec des tokens Bearer, protéger les APIs upstream avec un rate limiter, et économiser des milliers d'appels grâce à un cache TTL par type de donnée.

Bearer tokens Rate limiting Cache TTL Middleware chain
Construire un MCP Server Finance4/6
Pourquoi sécuriserBearer tokensRate limitingCache TTLMiddleware chainMonitoring

Pourquoi sécuriser un serveur MCP

Un serveur MCP finance en production fait face à trois menaces :

1

Accès non autorisé

Sans auth, n'importe qui peut appeler vos outils et épuiser vos quotas Yahoo/Polygon.

2

Abus de rate limits

Un client bugué qui boucle à 100 req/s va faire bannir votre IP par Yahoo Finance.

3

Latence inutile

Appeler Yahoo 50× pour le même ticker en 10 minutes quand le prix n'a pas bougé.

La solution est une triple couche : authentification → rate limiting → cache. Chaque requête passe par les trois dans cet ordre.

Pipeline de protection
Request → Auth → Rate Limit → Cache → Provider → Response
Section 2

Authentification par Bearer token

Le pattern le plus simple et le plus adapté pour MCP : un Bearer token envoyé dans le header HTTP. Le client MCP le transmet automatiquement si configuré :

# Côté client — connexion avec token
claude mcp add finance-prod \
  --transport sse \
  --url https://mcp.example.com/sse \
  --header "Authorization: Bearer sk-mcp-abc123..."

Implémentation côté serveur

# auth.py — Middleware d'authentification
import os
import hashlib
import hmac
from fastapi import Request, HTTPException

# Tokens autorisés (en prod: base de données ou vault)
VALID_TOKENS = {
    os.environ.get("MCP_SERVICE_TOKEN", ""): {
        "client": "scanner-bot",
        "tier": "unlimited",
        "rate_limit": 0  # 0 = pas de limite
    },
}

async def verify_token(request: Request) -> dict:
    """Vérifie le Bearer token et retourne les infos client."""
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(401, "Missing Bearer token")

    token = auth[7:]
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    client_info = VALID_TOKENS.get(token)
    if not client_info:
        raise HTTPException(403, "Invalid token")

    return client_info

Ne jamais comparer les tokens en clair

En production, hashez les tokens côté serveur et comparez les hash. Si la base de données fuite, les attaquants n'ont que des hash inutiles. Utilisez hmac.compare_digest() pour éviter les attaques timing.

Tiers d'accès

TierRate limitCache bypassOutils premiumUsage typique
free60/minNonNonDéveloppeurs en test
standard300/minNonOuiApplications en production
unlimitedAucunOuiOuiServices internes (scanner bot)

MCP SSE et l'authentification

Le transport SSE pose un problème : la connexion initiale est un GET, et les navigateurs n'envoient pas de header Authorization sur les requêtes SSE. La solution : passer le token dans le query string du endpoint SSE (/sse?token=sk-...) puis le vérifier en mémoire pour les requêtes POST suivantes via le session_id. C'est exactement ce que fait le SDK MCP sous le capot.

Section 3

Rate limiting : protéger les APIs upstream

Le rate limiting protège deux choses : vos APIs upstream (Yahoo a une limite de ~2000 req/heure) et votre serveur lui-même contre les boucles accidentelles. On utilise l'algorithme token bucket :

Token Bucket
Bucket = N tokens max • Recharge R tokens/sec • 1 requête = 1 token
# rate_limiter.py — Token bucket en mémoire
import time
from collections import defaultdict

class RateLimiter:
    def __init__(self):
        self.buckets: dict[str, dict] = defaultdict(
            lambda: {"tokens": 60, "last_refill": time.monotonic()}
        )

    def check(self, client_id: str, max_tokens: int, refill_rate: float) -> bool:
        """Retourne True si la requête est autorisée."""
        now = time.monotonic()
        bucket = self.buckets[client_id]

        # Recharger les tokens
        elapsed = now - bucket["last_refill"]
        bucket["tokens"] = min(
            max_tokens,
            bucket["tokens"] + elapsed * refill_rate
        )
        bucket["last_refill"] = now

        # Consommer un token
        if bucket["tokens"] >= 1:
            bucket["tokens"] -= 1
            return True
        return False

    def retry_after(self, client_id: str, refill_rate: float) -> float:
        """Secondes à attendre avant le prochain token."""
        bucket = self.buckets[client_id]
        return max(0, (1 - bucket["tokens"]) / refill_rate)

rate_limiter = RateLimiter()

Deux niveaux de rate limiting

NiveauCibleLimiteQuand ça déclenche
ClientPar token d'authentification60-300/min selon tierUn client fait trop de requêtes
UpstreamPar provider (Yahoo, SEC...)30/min pour YahooTous les clients combinés approchent la limite Yahoo
# Rate limiting upstream — partagé entre tous les clients
yahoo_limiter = RateLimiter()  # 30/min pour Yahoo

async def get_quote_with_limits(ticker: str, client_id: str):
    # 1. Vérifier le rate limit client
    if not rate_limiter.check(client_id, max_tokens=60, refill_rate=1.0):
        wait = rate_limiter.retry_after(client_id, 1.0)
        return {"error": "rate_limited", "retry_after_s": round(wait, 1)}

    # 2. Vérifier le rate limit upstream
    if not yahoo_limiter.check("yahoo", max_tokens=30, refill_rate=0.5):
        # Pas de slot Yahoo → servir depuis le cache ou attendre
        cached = cache.get(f"quote:{ticker}")
        if cached:
            cached["_from_cache"] = True
            return cached
        wait = yahoo_limiter.retry_after("yahoo", 0.5)
        return {"error": "upstream_rate_limited", "retry_after_s": round(wait)}

    # 3. Appeler Yahoo
    result = await provider.get_quote(ticker)
    cache.set(f"quote:{ticker}", result, ttl=15)
    return result
Section 4

Cache TTL intelligent par type de donnée

Le cache est le composant qui fait la plus grosse différence en production. Sans cache, un scanner qui analyse 100 tickers fait 100+ appels Yahoo. Avec un cache TTL adapté, il en fait 10-20.

Type de donnéeTTL recommandéJustification
Quote (prix live)15 secondesLes prix changent vite pendant la séance, mais 15s est suffisant pour la majorité des usages
Bars (OHLCV)12 heuresL'historique ne change qu'une fois par jour (à la clôture)
Technicals1 heureCalculés à partir des bars — mêmes données, mais recalcul à chaque TTL expire
Fondamentaux24 heuresP/E, margins changent trimestriellement — 24h est conservateur
Earnings24 heuresRésultats trimestriels — changent 4× par an
MarketOverview30 secondesAgrégat multi-source, appels fréquents par les briefings
Screener results1 heureLe même screening renvoie les mêmes résultats à ±10% en 1h
# cache.py — Cache en mémoire avec TTL
import time
from typing import Any, Optional

class TTLCache:
    def __init__(self, max_size: int = 10_000):
        self.store: dict[str, tuple[Any, float]] = {}
        self.max_size = max_size
        self.hits = 0
        self.misses = 0

    def get(self, key: str) -> Optional[Any]:
        if key in self.store:
            value, expires_at = self.store[key]
            if time.monotonic() < expires_at:
                self.hits += 1
                return value
            # Expiré — supprimer
            del self.store[key]
        self.misses += 1
        return None

    def set(self, key: str, value: Any, ttl: int):
        # Eviction LRU si plein
        if len(self.store) >= self.max_size:
            oldest = min(self.store, key=lambda k: self.store[k][1])
            del self.store[oldest]
        self.store[key] = (value, time.monotonic() + ttl)

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0

# TTL par défaut par type de donnée
TTL_MAP = {
    "quote": 15,
    "bars_daily": 43200,   # 12h
    "technicals": 3600,    # 1h
    "fundamentals": 86400, # 24h
    "earnings": 86400,     # 24h
    "overview": 30,
    "screener": 3600,      # 1h
}

cache = TTLCache()

Intégrer le cache dans les outils

async def handle_query_data(provider, args: dict) -> dict:
    ticker = args["ticker"].upper()
    types = args["types"]
    result = {"ticker": ticker}

    for data_type in types:
        cache_key = f"{data_type}:{ticker}"
        cached = cache.get(cache_key)

        if cached:
            result[data_type] = cached
            result.setdefault("_cached", []).append(data_type)
            continue

        # Cache miss → appel provider
        data = await fetch_data(provider, ticker, data_type)
        cache.set(cache_key, data, ttl=TTL_MAP.get(data_type, 60))
        result[data_type] = data

    return result

Le champ _cached

Le champ _cached dans le résultat indique quels types de données viennent du cache. Le LLM peut ainsi signaler « Données techniques de 14:32, prix live de 14:45 » si nécessaire. C'est la même logique que le champ source — transparence totale sur la fraîcheur des données.

Quiz : Pourquoi le cache quote a un TTL de 15s et pas 0s ?
Parce que dans un contexte MCP, le LLM fait souvent plusieurs requêtes en rafale pour le même ticker (d'abord quote, puis technicals qui re-fetch les bars). 15 secondes couvre une conversation typique sans données périmées. Si le LLM pose la question « quel est le prix ? » puis « et le RSI ? », les deux verront le même prix — pas de confusion. Un TTL de 0 (pas de cache) causerait potentiellement des incohérences si le prix bouge entre les deux appels.
Section 5

Assembler la middleware chain

On compose les trois couches en middleware FastAPI. Chaque requête traverse auth → rate limit → handler :

# server.py — version sécurisée
from fastapi import FastAPI, Request, Depends
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="MCP Finance Server")

# CORS — nécessaire si le client est un navigateur
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # En prod: restreindre
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
)

# Middleware de logging + rate limiting
@app.middleware("http")
async def security_middleware(request: Request, call_next):
    # Skip health check
    if request.url.path == "/health":
        return await call_next(request)

    # 1. Auth
    try:
        client_info = await verify_token(request)
    except HTTPException as e:
        return JSONResponse(
            status_code=e.status_code,
            content={"error": e.detail}
        )

    # 2. Rate limit
    limit = client_info.get("rate_limit", 60)
    if limit > 0:
        if not rate_limiter.check(
            client_info["client"],
            max_tokens=limit,
            refill_rate=limit / 60
        ):
            wait = rate_limiter.retry_after(client_info["client"], limit / 60)
            return JSONResponse(
                status_code=429,
                content={"error": "rate_limited", "retry_after_s": round(wait, 1)},
                headers={"Retry-After": str(int(wait) + 1)}
            )

    # 3. Injecter les infos client dans la requête
    request.state.client = client_info

    # 4. Passer au handler
    response = await call_next(request)

    # 5. Headers de diagnostic
    response.headers["X-Cache-Hit-Rate"] = f"{cache.hit_rate:.1%}"
    return response

Le /health doit rester ouvert

L'endpoint /health est utilisé par les load balancers et les systèmes de monitoring. Si vous le mettez derrière l'auth, votre monitoring ne pourra pas vérifier que le serveur est up. Exemptez toujours /health de l'authentification.

Section 6

Monitoring et métriques

Un serveur en production sans monitoring est un serveur qui va casser en silence. Les métriques essentielles :

# Endpoint de métriques
@app.get("/metrics")
async def metrics():
    return {
        "cache": {
            "hit_rate": f"{cache.hit_rate:.1%}",
            "size": len(cache.store),
            "hits": cache.hits,
            "misses": cache.misses,
        },
        "rate_limits": {
            client: {
                "tokens_remaining": bucket["tokens"],
            }
            for client, bucket in rate_limiter.buckets.items()
        },
        "uptime_s": time.monotonic() - start_time,
        "tools_count": len(TOOLS),
        "total_requests": request_counter,
    }
MétriqueSeuil d'alerteAction
Cache hit rate< 60%TTLs trop courts ou trop de tickers uniques
Upstream errors> 5%Yahoo down ? Basculer sur un provider backup
Latency P95> 2sCache miss sur données lourdes (bars longues)
Rate limit hits> 10/minClient bugué ou bot qui scrappe
Token rejectionsToutTentative d'accès non autorisé — investiguer

Ce qu'on retient

  • Bearer token — simple, efficace, supporté nativement par les clients MCP
  • Token bucket — rate limiting par client ET par provider upstream
  • Cache TTL adaptatif — 15s pour les prix, 24h pour les fondamentaux, configurable par type
  • Champ _cached — transparence totale sur la fraîcheur de chaque donnée
  • Middleware chain — auth → rate limit → handler, composable et testable
  • /health ouvert — jamais derrière l'auth, sinon le monitoring ne marche pas
Partie suivante
Déployer : Docker, Nomad, et Production