Quand on a ouvert notre serveur MCP à l’adresse
/mcp sur datacampus.fr, la question qui tombe immédiatement après le premier curl de test,
c’est : comment on empêche que ça devienne une décharge publique ? Un serveur MCP exposé sans protection,
c’est une API JSON-RPC que n’importe quel agent IA du monde peut interroger en boucle, sans authentification,
pour déclencher l’exécution d’outils côté serveur. La surface d’attaque parfaite si on laisse la porte grande ouverte.
Cet article détaille ce qu’on applique réellement sur notre serveur MCP en production, et ce qu’on recommande à tous ceux qui déploient un MCP public en 2026 : rate-limiting, validation des arguments, gestion propre des erreurs JSON-RPC, logs conformes LCEN, CORS au bon niveau, authentification optionnelle, et supervision. Avec du code concret, pas de la théorie.
Pourquoi un MCP public est une surface d’attaque particulière
Un serveur MCP, ce n’est pas juste une API REST de plus. C’est une API qui expose des outils exécutables
à des clients IA non humains, souvent autonomes. Un agent Claude ou GPT peut boucler 500 fois sur recommend_offering
en quelques secondes sans que personne ne s’en rende compte, juste parce qu’un prompt mal écrit l’a mis dans une boucle.
Exécution d’outils serveur
Chaque appel déclenche potentiellement de la logique métier, des requêtes base, un appel LLM, voire l’envoi d’un email. Un abus coûte du CPU, de la RAM et parfois des euros.
Clients non authentifiés
L’esprit MCP est l’ouverture. Un agent Claude Desktop tombe sur /.well-known/mcp.json et utilise le serveur sans compte. Ergonomie géniale, sécurité native zéro.
Payloads JSON-RPC arbitraires
Les arguments sont structurés mais rien dans le protocole n’impose que votre implémentation les valide. C’est à vous de fermer la boutique.
La spec MCP 2025-11-25, publiée pour le premier anniversaire du protocole, reconnaît explicitement que le rate-limiting doit être géré côté serveur. PKCE est désormais obligatoire, et les Client ID Metadata Documents remplacent Dynamic Client Registration précisément pour éviter la croissance non bornée des bases clients.
Rate-limiting par IP : le premier rempart
Sur notre serveur MCP, on applique un rate-limiting en token bucket par adresse IP. Pourquoi token bucket plutôt que fixed window ? Parce que le fixed window (« N requêtes par minute, reset à :00 ») autorise des pics à 2N à cheval sur deux fenêtres. Token bucket lisse la charge : un seau qui se remplit à débit constant, chaque requête prend un jeton, pas de jeton donne un 429.
Seuils appliqués sur datacampus.fr/mcp
| Fenêtre | Plafond par IP | Burst | Action |
|---|---|---|---|
| Seconde | 10 req/s | non | 429 immédiat |
| Minute | 60 req/min | 20 | 429 + Retry-After: 60 |
| Heure | 1000 req/h | non | 429 strict, blacklist temporaire |
Ces seuils sont larges. Un agent légitime qui dialogue avec un humain ne dépasse jamais 5 appels par minute. Les 60/min absorbent un outil qui listerait tout notre catalogue pour comparer, et un CI/CD qui tape le serveur dans ses tests. Au-delà, c’est suspect.
Implémentation en PHP pur, sans dépendance
Pas besoin de Redis si vous n’en avez pas déjà. Un stockage fichier tient la route pour quelques milliers d’IPs actives. Le coeur de notre middleware :
<?php
// /mcp/rate_limit.php
function rate_limit_check(string $ip, int $capacity = 20, float $refill_per_sec = 1.0): bool {
$dir = '/var/lib/datacampus-mcp/rl';
$file = $dir . '/' . hash('sha256', $ip) . '.json';
$now = microtime(true);
$fp = fopen($file, 'c+');
flock($fp, LOCK_EX);
$raw = stream_get_contents($fp);
$state = $raw ? json_decode($raw, true) : ['tokens' => $capacity, 'ts' => $now];
// Recharge du bucket
$elapsed = $now - $state['ts'];
$state['tokens'] = min($capacity, $state['tokens'] + $elapsed * $refill_per_sec);
$state['ts'] = $now;
$allowed = false;
if ($state['tokens'] >= 1.0) {
$state['tokens'] -= 1.0;
$allowed = true;
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($state));
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $allowed;
}
Si rate_limit_check() renvoie false, on répond par un JSON-RPC error code -32000 avec un header HTTP Retry-After: 60 et un status 429. On hash l’IP en SHA-256 pour ne pas stocker d’IP en clair sur disque (cf. section logs plus bas).
$ find /var/lib/datacampus-mcp/rl -type f -mmin +1440 -delete
Validation stricte des arguments JSON-RPC
Une requête JSON-RPC 2.0 ressemble à ça :
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "recommend_offering",
"arguments": { "use_case": "mail", "users": 50 }
}
}
Le protocole garantit la structure (jsonrpc, method, params) mais pas le contenu des arguments. C’est vous qui devez valider chaque champ, avec le même niveau de paranoia qu’une API publique. Règle simple : pour chaque outil, on écrit un schéma d’arguments et on refuse tout ce qui ne colle pas.
Ce qu’on vérifie systématiquement
- Taille du payload brut — coupe à 64 Kio. Un MCP n’a rien à faire de plus gros, ça évite le DoS par gros JSON.
- Type de chaque argument —
is_string,is_int,is_array, jamais desettypesilencieux. - Enum pour les champs à valeurs fermées —
use_casedoit être dans une liste blanche. Toute autre valeur renvoie une erreur. - Longueurs max sur les strings — 500 caractères pour une description, 100 pour un email, 2000 pour un message libre.
- Ranges pour les entiers —
usersentre 1 et 10000, pas de négatif, pas dePHP_INT_MAX. - Sanitization des strings renvoyées —
htmlspecialcharssystématique si la valeur revient dans le JSON de réponse et qu’un client web la rend en HTML.
function validate_recommend_args(array $args): array {
$allowed_use_cases = ['mail', 'nextcloud', 'gitlab', 'matomo', 'n8n', 'jitsi', 'ia', 'vps', 'colocation'];
if (!isset($args['use_case']) || !in_array($args['use_case'], $allowed_use_cases, true)) {
throw new InvalidArgumentException('use_case invalide');
}
if (!isset($args['users']) || !is_int($args['users']) || $args['users'] < 1 || $args['users'] > 10000) {
throw new InvalidArgumentException('users doit etre un entier entre 1 et 10000');
}
return [
'use_case' => $args['use_case'],
'users' => $args['users'],
];
}
On ne passe jamais le tableau $args brut à la logique métier. On reconstruit un tableau propre avec uniquement les clés attendues. Ça neutralise d’un coup les injections de champs parasites (API3:2023 Broken Object Property Level Authorization de l’OWASP API Security Top 10 2023).
Gestion propre des erreurs JSON-RPC : ne pas fuiter d’info
La spec JSON-RPC 2.0 réserve la plage -32768 à -32000 pour les erreurs prédéfinies du protocole, et la sous-plage -32000 à -32099 pour les erreurs serveur implémentation-dépendantes. C’est là-dedans qu’on place nos codes maison.
| Code | Signification Datacampus | Niveau |
|---|---|---|
-32000 | Rate limit dépassé | Client |
-32001 | Argument invalide (message générique) | Client |
-32002 | Outil indisponible (maintenance) | Serveur |
-32003 | Payload trop volumineux | Client |
-32004 | Authentification requise | Auth |
La règle qu’on applique : le message renvoyé au client ne doit jamais leaker d’info interne. Pas de stack trace PHP, pas de nom de fichier, pas de requête SQL, pas d’ID interne. Le vrai détail part dans les logs, côté client il reçoit :
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "Invalid argument",
"data": { "field": "users", "constraint": "integer 1-10000" }
}
}
Le champ data ne contient que ce qu’on veut bien donner au client : le nom du champ fautif et la contrainte violée. Jamais la valeur reçue (on ne la renvoie pas au risque d’amplifier un payload malveillant), jamais l’emplacement du code qui l’a rejetée. Côté serveur, l’erreur complète est loguée.
Les codes prédéfinis à respecter scrupuleusement : -32700 Parse error, -32600 Invalid Request, -32601 Method not found, -32602 Invalid params, -32603 Internal error. Beaucoup d’implémentations MCP utilisent -32603 comme fourre-tout, c’est une erreur : un param invalide c’est -32602, une erreur métier c’est -32000 et au-dessus.
Logs de requêtes : conformes LCEN, utilisables pour le debug
Décret n° 2021-1362 du 20 octobre 2021, pris en application du II de l’article 6 de la LCEN (loi n° 2004-575) : durée de conservation des données de connexion plafonnée à 1 an, communicables sur réquisition judiciaire. Ce décret a remplacé celui du 25 février 2011.
Pour un serveur MCP public, on est plus dans la logique d’une API que d’un hébergeur de contenu utilisateur. Mais la prudence recommande le même plafond : logs applicatifs conservés 12 mois glissants, purgés automatiquement au-delà. Au-dessus, vous accumulez des données sans base légale côté RGPD, et vous faites une faveur à votre prochain attaquant.
Format JSON structuré, une ligne par requête
{
"ts": "2026-04-28T14:32:07.412Z",
"ip_hash": "a3f8...2c",
"method": "tools/call",
"tool": "recommend_offering",
"status": 200,
"error_code": null,
"duration_ms": 42,
"user_agent": "ClaudeBot/1.0",
"payload_size": 184
}
Bonnes pratiques qu’on applique
IP anonymisée par hash salté
On stocke hash('sha256', $ip . $daily_salt). On détecte un abus (même hash récurrent sur 24h) sans stocker l’IP en clair. Le salt tourne chaque jour.
Rotation quotidienne via logrotate
daily, rotate 365, compress, delaycompress, missingok. Expiration automatique, pas d’interventions manuelles.
Permissions strictes
Fichiers en /var/log/datacampus-mcp/, 0640, propriétaire www-data:adm, jamais accessibles via HTTP.
Payload partiel par défaut
Uniquement les métadonnées. Le payload complet n’est capturé que si error_code est non null, pour debug.
La séparation access log / app log / audit log est essentielle : les trois n’ont pas la même rétention ni la même sensibilité. L’access log (volumineux) tourne vite, l’audit log (rare) tourne lentement, l’app log est entre les deux.
CORS : au niveau Apache, jamais dans l’appli
Un serveur MCP public doit accepter les requêtes cross-origin pour que les clients web (Claude.ai, ChatGPT dans le navigateur, Mistral Le Chat) puissent l’interroger. Deux règles d’or :
CORS au niveau serveur web
Apache et Nginx traitent les OPTIONS de préflight avant d’invoquer PHP. Si c’est PHP qui répond, vous payez un process complet pour un preflight qui devrait être gratuit.
Jamais * + credentials
Ne jamais renvoyer Access-Control-Allow-Origin: * avec Access-Control-Allow-Credentials: true. Interdit par la spec, et recette parfaite pour une CSRF inter-domaines.
/mcp
<LocationMatch "^/mcp(/|$)">
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Methods "POST, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Accept, Mcp-Session-Id"
Header always set Access-Control-Max-Age "86400"
# Preflight OPTIONS : 204 sans invoquer PHP
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
</LocationMatch>
Sur des clients IA qui bouclent, ce Max-Age: 86400 divise le nombre de requêtes par deux. Petit détail, grosse économie.
Authentification optionnelle : quand et comment
Par défaut, notre MCP est non authentifié. C’est un choix : on expose un catalogue public, des infos d’entreprise publiques, et un formulaire de contact. Personne n’a à s’inscrire pour utiliser list_offerings.
Mais dès qu’un outil manipule de la donnée client (lister ses VMs, consulter une facture, déclencher un redeploy), l’authentification devient obligatoire. Deux approches.
API key en header HTTP : simple, suffisant pour du B2B
POST /mcp HTTP/1.1
Authorization: Bearer dck_a3f8...2c9e
Content-Type: application/json
Côté serveur, on compare en temps constant (hash_equals) contre un hash bcrypt stocké en base. Une clé par client, révocable individuellement, scope explicite (quels outils elle autorise). Simple à implémenter, suffisant pour les intégrations machine-to-machine.
OAuth 2.0 avec PKCE : le standard MCP 2025-11-25
La spec MCP du 25 novembre 2025 a rendu PKCE obligatoire pour tout flux OAuth. Elle a aussi remplacé Dynamic Client Registration par les Client ID Metadata Documents (CIMD), précisément parce que DCR créait des endpoints publics qui nécessitaient du rate-limiting strict et faisaient exploser la taille des bases de clients. CIMD permet à un client de s’identifier par un document métadonnées publié sur son propre domaine, sans enregistrement préalable.
Pour un MCP qui expose des données sensibles, on recommande OAuth 2.0 avec un Keycloak en frontal (voir notre article sur Keycloak SSO). C’est plus d’infra à maintenir mais on récupère l’audit trail, la révocation centralisée, le MFA et l’intégration avec un annuaire existant.
Monitoring : quoi regarder tous les jours
Un MCP sans métriques, c’est une boite noire. Voici ce qu’on suit sur notre dashboard Matomo + Grafana.
| Métrique | Seuil d’alerte | Signal |
|---|---|---|
| Taux de 429 | > 5% sur 5 min | Bot ou client buggy |
| Taux de 500 | > 1% sur 5 min | Bug serveur |
| Top IP hash 24h | > 10k req / IP | Abus, candidat fail2ban |
| Latence p95 | > 200 ms | Pousse les agents à retry |
| Latence p99 | > 500 ms | Investigation requise |
Ratio -32602 | > 10% | Clients mal config, signal produit |
Distribution des user-agents : ClaudeBot, GPTBot, PerplexityBot sont attendus. python-requests/2.x sans header custom, beaucoup moins. Un outil qui ne sert à rien qui passe soudain en tête du top 10 : louche.
Sur les modèles d’attaque concrets, on renvoie au OWASP API Security Top 10 2023, notamment API4:2023 Unrestricted Resource Consumption (sans rate-limit, votre MCP est vulnérable par design) et API8:2023 Security Misconfiguration (CORS permissif, codes d’erreur qui leakent, verbes HTTP non restreints).
Retour d’expérience Datacampus
Depuis l’ouverture publique de /mcp en mars 2026, on observe un profil de trafic qu’on n’avait pas anticipé. La grande majorité des requêtes viennent de ClaudeBot, GPTBot et PerplexityBot, qui indexent notre catalogue pour le rendre disponible dans les réponses de leurs agents. C’est exactement l’usage qu’on visait.
On voit aussi, une à deux fois par semaine, des tentatives d’abus grossières. Le rate-limit les coupe au bout de 20 appels, la validation des enums rejette les payloads injectés, et les logs agrégés nous permettent de blacklister les IP récidivistes via fail2ban.
Scan d’outils cachés
Requêtes sur exec, shell, eval, run_command. Réponse : -32601 Method not found, log+1.
Injection SQL dans enum
use_case: "mail' OR 1=1--". Rejet immédiat -32001, la valeur ne passe même pas le in_array strict.
Flood mono-IP
500 requêtes en 10 secondes. Token bucket vide après 20, 480 réponses -32000, IP poussée à fail2ban au bout de 3 épisodes.
Conclusion pratique : un MCP public bien durci n’est pas plus exposé qu’une API REST bien durcie. Mais il demande d’appliquer simultanément les 6 couches décrites ici (rate-limit, validation, erreurs neutres, logs conformes, CORS strict, monitoring). Oubliez une seule de ces couches, et vous offrez une surface d’attaque disproportionnée par rapport à la valeur réelle du service.
Check-list avant mise en production
| Couche | Vérification | Statut cible |
|---|---|---|
| Rate-limit | Token bucket actif, 3 fenêtres (s / min / h) | OK |
| Validation | Schéma par outil, taille payload < 64 Kio | OK |
| Erreurs | Codes JSON-RPC standards, aucun leak interne | OK |
| Logs | JSON struct., IP hashée, rétention 12 mois | OK |
| CORS | Headers Apache, preflight 204, pas de * + creds | OK |
| Monitoring | 4xx/5xx, latence p95/p99, top IP, top outils | OK |
| Auth (si données sensibles) | API key ou OAuth 2.0 + PKCE | Selon usage |
Si vous déployez un MCP pour votre entreprise et que vous voulez qu’on s’occupe de l’infra, du durcissement et de la supervision, notre offre infogérance couvre exactement ça. Pour comprendre le protocole en amont, commencez par notre vulgarisation MCP. Et pour voir le nôtre en action : datacampus.fr/mcp.
Les articles captcha HMAC sans dépendance et protéger son site web, les bases complètent bien ce sujet si vous démarrez sur la sécurité des interfaces publiques.