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.
Un serveur MCP finance en production fait face à trois menaces :
Sans auth, n'importe qui peut appeler vos outils et épuiser vos quotas Yahoo/Polygon.
Un client bugué qui boucle à 100 req/s va faire bannir votre IP par Yahoo Finance.
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.
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..."
# 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
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.
| Tier | Rate limit | Cache bypass | Outils premium | Usage typique |
|---|---|---|---|---|
| free | 60/min | Non | Non | Développeurs en test |
| standard | 300/min | Non | Oui | Applications en production |
| unlimited | Aucun | Oui | Oui | Services internes (scanner bot) |
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.
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 :
# 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()
| Niveau | Cible | Limite | Quand ça déclenche |
|---|---|---|---|
| Client | Par token d'authentification | 60-300/min selon tier | Un client fait trop de requêtes |
| Upstream | Par provider (Yahoo, SEC...) | 30/min pour Yahoo | Tous 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
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ée | TTL recommandé | Justification |
|---|---|---|
| Quote (prix live) | 15 secondes | Les prix changent vite pendant la séance, mais 15s est suffisant pour la majorité des usages |
| Bars (OHLCV) | 12 heures | L'historique ne change qu'une fois par jour (à la clôture) |
| Technicals | 1 heure | Calculés à partir des bars — mêmes données, mais recalcul à chaque TTL expire |
| Fondamentaux | 24 heures | P/E, margins changent trimestriellement — 24h est conservateur |
| Earnings | 24 heures | Résultats trimestriels — changent 4× par an |
| MarketOverview | 30 secondes | Agrégat multi-source, appels fréquents par les briefings |
| Screener results | 1 heure | Le 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()
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 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.
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
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.
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étrique | Seuil d'alerte | Action |
|---|---|---|
| Cache hit rate | < 60% | TTLs trop courts ou trop de tickers uniques |
| Upstream errors | > 5% | Yahoo down ? Basculer sur un provider backup |
| Latency P95 | > 2s | Cache miss sur données lourdes (bars longues) |
| Rate limit hits | > 10/min | Client bugué ou bot qui scrappe |
| Token rejections | Tout | Tentative d'accès non autorisé — investiguer |