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
┌──────────────────────────────┐
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 vectorielleThree 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.
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.
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 expliciteNote 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.
# 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.
# 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: 30sFor autoscaling, the number of active sessions is a far better signal than CPU consumption. KEDA knows how to use it.
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 replicaRate 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.
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: 60sWatching 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.
# 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.
A few decisions I recommend
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.