Sécurité

Sécuriser un serveur MCP public : rate-limit, validation, logs

2026-04-28 · Pierre

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.

6
couches de durcissement à empiler
60/min
rate-limit par IP en production
12 mois
rétention logs (plafond LCEN)

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.

Critique

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.

Critique

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.

Élevé

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êtrePlafond par IPBurstAction
Seconde10 req/snon429 immédiat
Minute60 req/min20429 + Retry-After: 60
Heure1000 req/hnon429 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 :

1 Token bucket PHP avec lock fichier
<?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;
}
Middleware renvoie false si le bucket est vide, ce qui déclenche un JSON-RPC -32000.

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).

2 Purge des buckets inactifs (cron 6h)
$ find /var/lib/datacampus-mcp/rl -type f -mmin +1440 -delete
Cron supprime les fichiers non touchés depuis 24h, évite l’inflation disque.

Validation stricte des arguments JSON-RPC

Une requête JSON-RPC 2.0 ressemble à ça :

3 Exemple de requête JSON-RPC MCP
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "recommend_offering",
    "arguments": { "use_case": "mail", "users": 50 }
  }
}
Payload la structure est garantie, pas le contenu des arguments.

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 argumentis_string, is_int, is_array, jamais de settype silencieux.
  • Enum pour les champs à valeurs ferméesuse_case doit ê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 entiersusers entre 1 et 10000, pas de négatif, pas de PHP_INT_MAX.
  • Sanitization des strings renvoyéeshtmlspecialchars systématique si la valeur revient dans le JSON de réponse et qu’un client web la rend en HTML.
4 Validateur d’arguments par outil
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'],
    ];
}
Validation on reconstruit un tableau propre, on ne passe jamais $args brut à la logique métier.

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.

CodeSignification DatacampusNiveau
-32000Rate limit dépasséClient
-32001Argument invalide (message générique)Client
-32002Outil indisponible (maintenance)Serveur
-32003Payload trop volumineuxClient
-32004Authentification requiseAuth

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 :

5 Réponse JSON-RPC d’erreur minimaliste
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32001,
    "message": "Invalid argument",
    "data": { "field": "users", "constraint": "integer 1-10000" }
  }
}
Principe nom du champ fautif, contrainte violée. Jamais la valeur reçue.

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

6 Ligne de log MCP type
{
  "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
}
Log JSON structuré ingeste direct dans Loki ou Elastic.

Bonnes pratiques qu’on applique

OK

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.

OK

Rotation quotidienne via logrotate

daily, rotate 365, compress, delaycompress, missingok. Expiration automatique, pas d’interventions manuelles.

OK

Permissions strictes

Fichiers en /var/log/datacampus-mcp/, 0640, propriétaire www-data:adm, jamais accessibles via HTTP.

OK

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 :

Règle 1

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.

Règle 2

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.

7 Config Apache /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>
Apache Max-Age: 86400 évite que le navigateur refasse un preflight 24h durant.

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

8 Header Authorization Bearer
POST /mcp HTTP/1.1
Authorization: Bearer dck_a3f8...2c9e
Content-Type: application/json
B2B comparaison temps constant via hash_equals, une clé par client, scope explicite.

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étriqueSeuil d’alerteSignal
Taux de 429> 5% sur 5 minBot ou client buggy
Taux de 500> 1% sur 5 minBug serveur
Top IP hash 24h> 10k req / IPAbus, candidat fail2ban
Latence p95> 200 msPousse les agents à retry
Latence p99> 500 msInvestigation 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.

Pattern 1

Scan d’outils cachés

Requêtes sur exec, shell, eval, run_command. Réponse : -32601 Method not found, log+1.

Pattern 2

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.

Pattern 3

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

CoucheVérificationStatut cible
Rate-limitToken bucket actif, 3 fenêtres (s / min / h)OK
ValidationSchéma par outil, taille payload < 64 KioOK
ErreursCodes JSON-RPC standards, aucun leak interneOK
LogsJSON struct., IP hashée, rétention 12 moisOK
CORSHeaders Apache, preflight 204, pas de * + credsOK
Monitoring4xx/5xx, latence p95/p99, top IP, top outilsOK
Auth (si données sensibles)API key ou OAuth 2.0 + PKCESelon 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.

Hébergement souverain, éco-responsable et infogéré

Serveurs en France, énergie renouvelable, support humain. Découvrez ce que Datacampus peut faire pour vous.

Découvrir nos solutions Nous contacter

Articles sur le même sujet

← Retour au blog