Exposer ses services d'IA en MCP sur Kubernetes : architecture, sécurité et montée en charge
Le protocole MCP standardise la manière dont un modèle appelle vos outils métier. Reste à le porter sur Kubernetes sans transformer chaque serveur MCP en porte dérobée vers votre système d'information.
Le Model Context Protocol résout un problème concret : avant lui, chaque intégration entre un modèle de langage et un système métier relevait du code sur mesure. MCP normalise trois primitives — les outils que le modèle peut appeler, les ressources qu'il peut lire, et les invites qu'il peut réutiliser.
Sur le papier, l'affaire semble simple. En production sur Kubernetes, plusieurs questions se posent qui ne figurent dans aucune documentation de démarrage : comment isoler les serveurs MCP les uns des autres, comment gérer l'authentification déléguée, et comment encaisser la montée en charge d'un transport conçu à l'origine pour un processus local.
Comprendre le modèle d'exécution avant d'architecturer
MCP propose deux transports. Le premier, stdio, fait dialoguer un client et un serveur par les flux d'entrée et de sortie d'un processus enfant. Le second, Streamable HTTP, expose le serveur comme un service réseau.
Cette distinction structure tout le reste.
Le transport stdio convient à un poste de développement : le client lance le serveur, le pilote, le termine. Sur Kubernetes, il impose un conteneur adjacent par session cliente — un modèle qui ne monte pas en charge.
Le transport HTTP, lui, permet de traiter un serveur MCP comme n'importe quel service : un Deployment, un Service, une entrée d'API, plusieurs répliques. C'est le seul choix viable en production.
Attention au piège. Beaucoup de serveurs MCP publiés en source ouverte n'implémentent que
stdio. Les envelopper dans un adaptateur HTTP fonctionne, mais introduit un état de session côté serveur qui interdit la répartition de charge naïve.
L'architecture de référence
┌──────────────────────────────┐
Clients │ Passerelle MCP │
(agents, IDE, │ • authentification OIDC │
applications)──▶│ • limitation de débit │
│ • journalisation d'audit │
│ • découverte des serveurs │
└──────────┬───────────────────┘
│ mTLS (maillage)
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ MCP-SIRH │ │ MCP-Infra │ │ MCP-Docs │
│ lecture │ │ écriture │ │ lecture │
│ seule │ │ contrôlée │ │ seule │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
▼ ▼ ▼
API SIRH API Kubernetes Base vectorielleTrois principes gouvernent ce découpage.
Un serveur MCP par domaine métier et par niveau de privilège. Mélanger la lecture de documents et l'écriture sur l'infrastructure dans un même binaire, c'est offrir au modèle une élévation de privilège par construction.
Une passerelle unique en frontal. Elle porte l'authentification, la limitation de débit et l'audit. Les serveurs en aval n'ont pas à réimplémenter ces préoccupations.
Un maillage de services pour le trafic interne. Le chiffrement mutuel entre la passerelle et les serveurs, plus les politiques d'autorisation par identité de compte de service.
Déployer un serveur MCP proprement
Voici le squelette que j'applique systématiquement. Il tient compte de trois contraintes : absence de privilèges, sondes adaptées à un service à état de session, et arrêt gracieux qui laisse les sessions en cours se terminer.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-infra
namespace: mcp
labels:
app.kubernetes.io/name: mcp-infra
pi-groupe.tech/niveau-privilege: ecriture-controlee
spec:
replicas: 3
selector:
matchLabels: { app.kubernetes.io/name: mcp-infra }
template:
metadata:
labels:
app.kubernetes.io/name: mcp-infra
annotations:
prometheus.io/scrape: "true"
spec:
serviceAccountName: mcp-infra # RBAC restreint, voir plus bas
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile: { type: RuntimeDefault }
terminationGracePeriodSeconds: 120 # laisse finir les sessions
containers:
- name: serveur
image: registry.interne/mcp-infra:1.4.2
args: ["--transport=http", "--port=8080", "--session-ttl=900s"]
ports:
- { name: http, containerPort: 8080 }
- { name: metriques, containerPort: 9090 }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: "2", memory: 1Gi }
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"] # sortie du pool avant arrêt
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: { sizeLimit: 128Mi }Le preStop mérite un mot. Sans lui, le pod cesse d'accepter du trafic avant que l'entrée n'ait retiré son adresse de la liste des points de terminaison. Quinze secondes d'attente suffisent à absorber ce décalage.
Le RBAC, seule vraie barrière
Un serveur MCP qui pilote Kubernetes constitue une surface d'attaque majeure. Le compte de service doit refléter exactement ce que vous acceptez que le modèle fasse — ni plus, ni moins.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mcp-infra-lecture
rules:
- apiGroups: [""]
resources: ["pods", "services", "events", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
# L'écriture reste cantonnée à un espace de noms et à deux verbes
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mcp-infra-redemarrage
namespace: applications
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "patch"]
resourceNames: ["frontal", "api-commandes"] # liste blanche expliciteRemarquez resourceNames. Autoriser le patch sur tous les déploiements d'un espace de noms revient à autoriser la modification de n'importe quelle image de conteneur — donc l'exécution de code arbitraire. La liste blanche nominative réduit considérablement le risque.
L'authentification déléguée : le point le plus délicat
Un serveur MCP agit pour le compte d'un utilisateur. Si tous les appels s'exécutent sous une identité technique unique, vous perdez la traçabilité et vous accordez au modèle les droits cumulés de tous vos utilisateurs.
La spécification MCP s'appuie sur OAuth 2.1. En pratique, la passerelle valide le jeton du porteur, en extrait l'identité, puis propage un jeton restreint vers le serveur en aval.
# Politique Envoy sur la passerelle : validation OIDC puis propagation
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: mcp-oidc
namespace: mcp
spec:
selector:
matchLabels: { app: passerelle-mcp }
jwtRules:
- issuer: "https://sso.interne.pi-groupe.tech/realms/entreprise"
jwksUri: "https://sso.interne.pi-groupe.tech/realms/entreprise/protocol/openid-connect/certs"
forwardOriginalToken: true
outputClaimToHeaders:
- header: x-mcp-utilisateur
claim: preferred_username
- header: x-mcp-groupes
claim: groups
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: mcp-infra-ecriture-restreinte
namespace: mcp
spec:
selector:
matchLabels: { app.kubernetes.io/name: mcp-infra }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/mcp/sa/passerelle-mcp"]
when:
- key: request.headers[x-mcp-groupes]
values: ["sre", "plateforme"]Seuls les membres des groupes sre et plateforme atteignent le serveur capable d'écrire. Les autres reçoivent un refus au niveau du maillage, avant même d'atteindre le code applicatif.
Montée en charge : l'état de session complique tout
Une session MCP porte un contexte. Si le client ouvre une session sur la réplique A puis envoie son appel suivant à la réplique B, celle-ci ne reconnaît pas la session.
Trois réponses possibles, par ordre de préférence.
Rendre le serveur sans état. La meilleure solution quand le domaine s'y prête : chaque appel d'outil porte tout son contexte. Aucune affinité nécessaire, montée en charge horizontale triviale.
Externaliser l'état de session. Redis ou Valkey stocke le contexte, indexé par identifiant de session. Toute réplique sert n'importe quelle session. Coût : une dépendance et une latence supplémentaire de quelques millisecondes.
Recourir à l'affinité de session. Le dernier recours. L'entrée oriente les requêtes d'une même session vers la même réplique. Simple, mais la perte d'un pod tue les sessions qu'il portait, et l'équilibrage se dégrade.
# Affinité par en-tête de session, au niveau du maillage
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: mcp-infra-affinite
namespace: mcp
spec:
host: mcp-infra.mcp.svc.cluster.local
trafficPolicy:
loadBalancer:
consistentHash:
httpHeaderName: mcp-session-id
connectionPool:
http:
h2UpgradePolicy: UPGRADE
maxRequestsPerConnection: 0 # flux longs : pas de recyclage
outlierDetection:
consecutive5xxErrors: 5
interval: 10s
baseEjectionTime: 30sPour le dimensionnement automatique, le nombre de sessions actives constitue un bien meilleur indicateur que la consommation processeur. KEDA sait s'en servir.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: mcp-infra
namespace: mcp
spec:
scaleTargetRef: { name: mcp-infra }
minReplicaCount: 3
maxReplicaCount: 24
cooldownPeriod: 300 # laisse les sessions se terminer
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.observabilite.svc:9090
query: sum(mcp_sessions_actives{service="mcp-infra"})
threshold: "40" # 40 sessions simultanées par répliqueLa limitation de débit, indispensable
Un agent en boucle peut émettre des milliers d'appels par minute. Sans garde-fou, il saturera votre API SIRH ou déclenchera un blocage côté fournisseur.
La limitation se pose à deux niveaux : par utilisateur à la passerelle, et par outil au niveau du serveur. Un outil lister_pods supporte cent appels par minute ; un outil redemarrer_deploiement ne devrait pas dépasser cinq.
apiVersion: networking.istio.io/v1
kind: EnvoyFilter
metadata:
name: mcp-limite-par-outil
namespace: mcp
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_INBOUND
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: mcp_outils
token_bucket:
max_tokens: 100
tokens_per_fill: 100
fill_interval: 60s
descriptors:
- entries:
- key: outil
value: redemarrer_deploiement
token_bucket:
max_tokens: 5
tokens_per_fill: 5
fill_interval: 60sObserver ce que le modèle fait réellement
L'audit constitue une exigence, pas un confort. Chaque appel d'outil doit laisser une trace : qui, quel outil, quels arguments, quel résultat, combien de temps.
# Outils les plus appelés, par utilisateur — détecte les boucles d'agent
topk(10,
sum by (outil, utilisateur) (
rate(mcp_appels_outil_total[5m])
)
)
# Taux d'échec par outil — révèle les intégrations fragiles
sum by (outil) (rate(mcp_appels_outil_total{statut="erreur"}[15m]))
/
sum by (outil) (rate(mcp_appels_outil_total[15m]))Complétez par le traçage distribué. Un appel MCP qui traverse la passerelle, le serveur, puis trois API métier produit une trace lisible dans Tempo ou Jaeger — et vous saurez immédiatement lequel des trois ralentit l'ensemble.
Quelques décisions que je recommande
Versionnez le catalogue d'outils. Un modèle appelle un outil selon sa description. Changer la signature sans versionner casse silencieusement les agents en production.
Séparez lecture et écriture jusque dans les images. Deux binaires, deux comptes de service, deux politiques. La séparation par simple paramètre finit toujours par sauter.
Imposez une confirmation humaine sur les actions irréversibles. MCP ne la prévoit pas nativement : implémentez-la dans l'outil, qui renvoie un jeton de confirmation à usage unique à valider par un second appel.
Testez vos serveurs comme des API. Contrats, cas limites, injections dans les arguments. Un serveur MCP reste une API exposée — et le fait qu'un modèle l'appelle ne le rend pas moins vulnérable.