PI-GROUPE
IAKubernetesArchitectureSécuritéRéseauDéveloppement

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

text
                    ┌──────────────────────────────┐
   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 vectorielle

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

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

yaml
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 explicite

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

yaml
# 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.

yaml
# 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: 30s

Pour le dimensionnement automatique, le nombre de sessions actives constitue un bien meilleur indicateur que la consommation processeur. KEDA sait s'en servir.

yaml
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éplique

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

yaml
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: 60s

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

promql
# 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.