PI-GROUPE
AIKubernetesArchitectureSecurityNetworkDevelopment

Exposing your AI services over MCP on Kubernetes: architecture, security and scale

The MCP protocol standardises how a model calls your business tools. What remains is porting it to Kubernetes without turning every MCP server into a back door into your information system.

The Model Context Protocol solves a concrete problem: before it, every integration between a language model and a business system meant bespoke code. MCP normalises three primitives — the tools the model may call, the resources it may read, and the prompts it may reuse.

On paper the matter looks simple. In production on Kubernetes, several questions arise that no getting-started guide covers: how to isolate MCP servers from one another, how to handle delegated authentication, and how to absorb the scale-up of a transport originally designed for a local process.

Understand the execution model before designing anything

MCP offers two transports. The first, stdio, has a client and a server talk through the standard streams of a child process. The second, Streamable HTTP, exposes the server as a network service.

That distinction shapes everything else.

The stdio transport suits a developer workstation: the client launches the server, drives it, ends it. On Kubernetes it forces one sidecar container per client session — a model that does not scale.

The HTTP transport, by contrast, lets you treat an MCP server like any other service: a Deployment, a Service, an ingress entry, several replicas. It is the only viable choice in production.

Mind the trap. Many open-source MCP servers implement stdio only. Wrapping them in an HTTP adapter works, but introduces server-side session state that rules out naive load balancing.

The reference architecture

text
                    ┌──────────────────────────────┐
   Clients          │   Passerelle MCP             │
   (agents, IDE,    │   • authentification OIDC    │
    applications)──▶│   • limitation de débit      │
                    │   • logging d'audit   │
                    │   • server discovery  │
                    └──────────┬───────────────────┘
                               │ mTLS (maillage)
           ┌───────────────────┼───────────────────┐
           ▼                   ▼                   ▼
    ┌────────────┐      ┌────────────┐      ┌────────────┐
    │ MCP-SIRH   │      │ MCP-Infra  │      │ MCP-Docs   │
    │ lecture    │      │ écriture   │      │ lecture    │
    │ seule      │      │ contrôlée  │      │ seule      │
    └─────┬──────┘      └─────┬──────┘      └─────┬──────┘
          ▼                   ▼                   ▼
      API SIRH          API Kubernetes       Base vectorielle

Three principles govern that layout.

One MCP server per business domain and per privilege level. Mixing document reading and infrastructure writing in the same binary hands the model a privilege escalation by design.

A single gateway at the front. It carries authentication, rate limiting and audit. Downstream servers have no reason to reimplement those concerns.

A service mesh for internal traffic. Mutual encryption between the gateway and the servers, plus authorisation policies keyed on service-account identity.

Deploying an MCP server properly

Here is the skeleton I apply every time. It accounts for three constraints: no privileges, probes suited to a session-stateful service, and a graceful shutdown that lets in-flight sessions finish.

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           # restricted RBAC, see below
      automountServiceAccountToken: true
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
        seccompProfile: { type: RuntimeDefault }
      terminationGracePeriodSeconds: 120       # lets sessions drain
      containers:
        - name: server
          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"]   # leaves the pool before shutdown
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 128Mi }

The preStop deserves a word. Without it, the pod stops accepting traffic before the ingress has removed its address from the endpoint list. Fifteen seconds of waiting absorb that skew.

RBAC, the only real barrier

An MCP server that drives Kubernetes is a major attack surface. The service account must reflect exactly what you accept the model doing — no more, no less.

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"]
---
# Writes stay confined to one namespace and two verbs
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"]   # list blanche explicite

Note resourceNames. Allowing patch on every deployment in a namespace amounts to allowing any container image to change — therefore arbitrary code execution. A named allow-list cuts that risk dramatically.

Delegated authentication: the trickiest point

An MCP server acts on a user's behalf. If every call runs under a single technical identity, you lose traceability and hand the model the combined rights of all your users.

The MCP specification builds on OAuth 2.1. In practice the gateway validates the bearer token, extracts the identity from it, then propagates a restricted token downstream.

yaml
# Envoy policy on the gateway: OIDC validation then propagation
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: mcp-oidc
  namespace: mcp
spec:
  selector:
    matchLabels: { app: gateway-mcp }
  jwtRules:
    - issuer: "https://sso.interne.pi-groupe.tech/realms/company"
      jwksUri: "https://sso.interne.pi-groupe.tech/realms/company/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/gateway-mcp"]
      when:
        - key: request.headers[x-mcp-groupes]
          values: ["sre", "platform"]

Only members of the sre and platform groups reach the server able to write. Everyone else gets a denial at the mesh level, before reaching application code at all.

Scaling: session state complicates everything

An MCP session carries context. If the client opens a session on replica A then sends its next call to replica B, B does not recognise the session.

Three possible answers, in order of preference.

Make the server stateless. The best solution when the domain allows: every tool call carries its whole context. No affinity needed, horizontal scaling becomes trivial.

Externalise session state. Redis or Valkey stores the context, keyed by session identifier. Any replica serves any session. Cost: one dependency and a few extra milliseconds of latency.

Fall back on session affinity. The last resort. The ingress steers the requests of one session to the same replica. Simple, but losing a pod kills the sessions it carried, and balancing degrades.

yaml
# Session-header affinity, handled by the mesh
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     # long-lived streams: no recycling
    outlierDetection:
      consecutive5xxErrors: 5
      interval: 10s
      baseEjectionTime: 30s

For autoscaling, the number of active sessions is a far better signal than CPU consumption. KEDA knows how to use it.

yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: mcp-infra
  namespace: mcp
spec:
  scaleTargetRef: { name: mcp-infra }
  minReplicaCount: 3
  maxReplicaCount: 24
  cooldownPeriod: 300        # lets sessions finish
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.observabilite.svc:9090
        query: sum(mcp_sessions_actives{service="mcp-infra"})
        threshold: "40"       # 40 concurrent sessions per replica

Rate limiting, non-negotiable

An agent stuck in a loop can fire thousands of calls a minute. Without a guard, it will saturate your HR API or trigger a block at the provider end.

Limiting belongs at two levels: per user at the gateway, and per tool at the server. A list_pods tool tolerates a hundred calls a minute; a restart_deployment tool should not exceed five.

yaml
apiVersion: networking.istio.io/v1
kind: EnvoyFilter
metadata:
  name: mcp-per-tool-limit
  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_tools
            token_bucket:
              max_tokens: 100
              tokens_per_fill: 100
              fill_interval: 60s
            descriptors:
              - entries:
                  - key: outil
                    value: restart_deployment
                token_bucket:
                  max_tokens: 5
                  tokens_per_fill: 5
                  fill_interval: 60s

Watching what the model actually does

Auditing is a requirement, not a comfort. Every tool call must leave a trail: who, which tool, which arguments, which result, how long.

promql
# Most-called tools per user — catches agent loops
topk(10,
  sum by (outil, utilisateur) (
    rate(mcp_appels_outil_total[5m])
  )
)

# Failure rate per tool — exposes brittle integrations
sum by (outil) (rate(mcp_appels_outil_total{statut="error"}[15m]))
/
sum by (outil) (rate(mcp_appels_outil_total[15m]))

Complete the picture with distributed tracing. An MCP call crossing the gateway, the server, then three business APIs produces a readable trace in Tempo or Jaeger — and you will know at once which of the three slows the whole thing down.

Version the tool catalogue. A model calls a tool according to its description. Changing the signature without versioning breaks agents in production, silently.

Separate reading from writing right down to the images. Two binaries, two service accounts, two policies. Separation by a mere flag always gives way in the end.

Require human confirmation on irreversible actions. MCP has no native provision for it: implement it in the tool, which returns a single-use confirmation token to be validated by a second call.

Test your servers like APIs. Contracts, edge cases, injection through arguments. An MCP server remains an exposed API — and the fact that a model calls it makes it no less vulnerable.