Deployment Guide¶
This guide covers deploying mcp-data-platform in various environments, from local development to production Kubernetes clusters.
Deployment Options¶
| Environment | Best For | Complexity |
|---|---|---|
| Docker Compose | Development, small teams, testing | Low |
| Kubernetes (plain manifests) | Production, multi-user, enterprise | Medium |
Docker Compose (Development/Small Teams)¶
A complete full-stack deployment including DataHub, Trino, mcp-data-platform, Keycloak, and PostgreSQL.
Prerequisites¶
- Docker 24.0+
- Docker Compose 2.20+
- 16GB RAM minimum (DataHub requires significant memory)
- 20GB free disk space
Full-Stack Example¶
Create a docker-compose.yml:
services:
# PostgreSQL for metadata storage
postgres:
image: postgres:16-alpine@sha256:acf5271bce6b4b62e352341e3b175c2b1e9e0b6f6e3f2e7e3b7f8c9d0e1f2a3b
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_MULTIPLE_DATABASES: datahub,keycloak,audit
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-multiple-dbs.sh:/docker-entrypoint-initdb.d/init-multiple-dbs.sh
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Keycloak for authentication
keycloak:
image: quay.io/keycloak/keycloak:24.0@sha256:b3c4a5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4
command: start-dev --import-realm
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
volumes:
- ./keycloak-realm.json:/opt/keycloak/data/import/realm.json
ports:
- "8180:8080"
depends_on:
postgres:
condition: service_healthy
# DataHub GMS (Metadata Service)
datahub-gms:
image: acryldata/datahub-gms:v0.13.0@sha256:c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2
environment:
DATAHUB_GMS_HOST: datahub-gms
DATAHUB_GMS_PORT: 8080
EBEAN_DATASOURCE_HOST: postgres:5432
EBEAN_DATASOURCE_USERNAME: postgres
EBEAN_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ELASTICSEARCH_HOST: elasticsearch
ELASTICSEARCH_PORT: 9200
KAFKA_BOOTSTRAP_SERVER: kafka:9092
KAFKA_SCHEMAREGISTRY_URL: http://schema-registry:8081
depends_on:
postgres:
condition: service_healthy
elasticsearch:
condition: service_healthy
kafka:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
# Elasticsearch for DataHub search
elasticsearch:
image: elasticsearch:7.17.18@sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"]
interval: 10s
timeout: 5s
retries: 10
# Kafka for DataHub events
kafka:
image: confluentinc/cp-kafka:7.6.0@sha256:b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
depends_on:
- zookeeper
healthcheck:
test: ["CMD", "kafka-topics", "--bootstrap-server", "kafka:9092", "--list"]
interval: 30s
timeout: 10s
retries: 5
# Zookeeper for Kafka
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0@sha256:a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
# Schema Registry for Kafka
schema-registry:
image: confluentinc/cp-schema-registry:7.6.0@sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
environment:
SCHEMA_REGISTRY_HOST_NAME: schema-registry
SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:9092
depends_on:
kafka:
condition: service_healthy
# Trino for SQL queries
trino:
image: trinodb/trino:440@sha256:d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5
ports:
- "8081:8080"
volumes:
- ./trino-catalog:/etc/trino/catalog
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/v1/info"]
interval: 10s
timeout: 5s
retries: 10
# MCP Data Platform
mcp-data-platform:
image: ghcr.io/txn2/mcp-data-platform:latest
environment:
DATAHUB_TOKEN: ${DATAHUB_TOKEN}
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/audit
OAUTH_SIGNING_KEY: ${OAUTH_SIGNING_KEY}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
volumes:
- ./platform.yaml:/etc/mcp/platform.yaml:ro
command: ["--config", "/etc/mcp/platform.yaml", "--transport", "http", "--address", ":8080"]
ports:
- "8080:8080"
depends_on:
datahub-gms:
condition: service_healthy
trino:
condition: service_healthy
keycloak:
condition: service_started
volumes:
postgres_data:
elasticsearch_data:
Platform Configuration¶
Create platform.yaml:
server:
name: mcp-data-platform
transport: http
address: ":8080"
toolkits:
datahub:
enabled: true
instances:
primary:
url: http://datahub-gms:8080
token: ${DATAHUB_TOKEN}
default: primary
trino:
enabled: true
instances:
primary:
host: trino
port: 8080
user: trino
catalog: memory
ssl: false
default: primary
oauth:
enabled: true
issuer: "http://localhost:8080"
signing_key: ${OAUTH_SIGNING_KEY}
clients:
- id: "claude-desktop"
secret: "claude-secret"
redirect_uris:
- "http://localhost"
- "http://127.0.0.1"
upstream:
issuer: "http://keycloak:8080/realms/mcp"
client_id: "mcp-data-platform"
client_secret: ${KEYCLOAK_CLIENT_SECRET}
redirect_uri: "http://localhost:8080/oauth/callback"
personas:
analyst:
display_name: "Data Analyst"
roles: ["analyst"]
tools:
allow: ["*"]
deny: ["*_delete_*"]
connections:
allow: ["*"]
admin:
display_name: "Administrator"
roles: ["admin"]
tools:
allow: ["*"]
connections:
allow: ["*"]
enrichment:
trino_semantic_enrichment: true
datahub_query_enrichment: true
column_context_filtering: true # Only enrich columns referenced in SQL (default: true)
audit:
enabled: true
log_tool_calls: true
database:
dsn: ${DATABASE_URL}
Start the Stack¶
# Generate secrets
export POSTGRES_PASSWORD=$(openssl rand -base64 32)
export OAUTH_SIGNING_KEY=$(openssl rand -base64 32)
export KEYCLOAK_CLIENT_SECRET=$(openssl rand -base64 32)
export DATAHUB_TOKEN="your-datahub-token"
# Start all services
docker compose up -d
# Wait for services to be healthy
docker compose ps
# View logs
docker compose logs -f mcp-data-platform
Local Development Workflow¶
For rapid iteration during development:
# Start dependencies only
docker compose up -d postgres elasticsearch kafka zookeeper schema-registry datahub-gms trino keycloak
# Run mcp-data-platform locally
go run ./cmd/mcp-data-platform --config platform.yaml --transport http --address :8080
Kubernetes (Production)¶
Production deployment is plain Kubernetes manifests applied with kubectl.
This repository ships no Helm chart and no operator, and the observability
manifests under
deployments/observability/
follow the same shape. The manifests below are complete as written: save them
into a directory, change the image tag, host names, and resource figures, and
apply the directory.
Prerequisites¶
- Kubernetes 1.28+
kubectlconfigured for the target cluster- TLS certificates for the ingress (cert-manager recommended)
- PostgreSQL reachable from the cluster. It backs audit, portal, knowledge, memory, and the OAuth/PKCE state that multi-replica deployments share
Namespace and secrets¶
kubectl create namespace mcp-data-platform
# Use external-secrets, sealed-secrets, or a secrets manager in production.
kubectl create secret generic mcp-data-platform-secrets \
--namespace mcp-data-platform \
--from-literal=datahub-token="$DATAHUB_TOKEN" \
--from-literal=oauth-signing-key="$OAUTH_SIGNING_KEY" \
--from-literal=keycloak-client-secret="$KEYCLOAK_CLIENT_SECRET" \
--from-literal=encryption-key="$ENCRYPTION_KEY" \
--from-literal=database-url="$DATABASE_URL"
ENCRYPTION_KEY is 32 bytes of key material (64 hex characters, 44-character
base64, or 32 raw bytes) and encrypts stored connection credentials, gateway
OAuth tokens, and PKCE state at rest. Without it the platform logs a warning at
startup and stores those values in plaintext.
ServiceAccount¶
apiVersion: v1
kind: ServiceAccount
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
# The platform never calls the Kubernetes API; do not mount a token for it.
automountServiceAccountToken: false
ConfigMap¶
The platform reads its YAML from a file and expands ${VAR} from the process
environment, so credentials stay in the Secret and never enter the ConfigMap.
The full schema is in the Configuration reference.
apiVersion: v1
kind: ConfigMap
metadata:
name: mcp-data-platform-config
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
data:
platform.yaml: |
server:
name: mcp-data-platform
transport: http
address: ":8080"
tls:
enabled: false # TLS terminates at the ingress
database:
dsn: ${DATABASE_URL}
toolkits:
datahub:
enabled: true
instances:
primary:
url: http://datahub-gms.datahub:8080
token: ${DATAHUB_TOKEN}
default: primary
trino:
enabled: true
instances:
primary:
host: trino.trino
port: 8080
user: mcp-platform
catalog: hive
ssl: false
default: primary
semantic:
provider: datahub
instance: primary
enrichment:
trino_semantic_enrichment: true
datahub_query_enrichment: true
column_context_filtering: true # Only enrich columns referenced in SQL (default: true)
oauth:
enabled: true
issuer: "https://mcp.example.com"
signing_key: ${OAUTH_SIGNING_KEY}
# MCP clients either pre-register under `clients:` or register
# themselves through DCR; a deployment with neither admits no client.
# Constrain the redirect URIs a self-registering client may claim.
dcr:
enabled: true
allowed_redirect_patterns:
- "^http://localhost.*"
- "^http://127.0.0.1.*"
upstream:
issuer: "https://auth.example.com/realms/mcp"
client_id: "mcp-data-platform"
client_secret: ${KEYCLOAK_CLIENT_SECRET}
redirect_uri: "https://mcp.example.com/oauth/callback"
personas:
analyst:
display_name: "Data Analyst"
roles: ["analyst"]
tools:
allow: ["*"]
deny: ["*_delete_*"]
connections:
allow: ["*"]
admin:
display_name: "Administrator"
roles: ["admin"]
tools:
allow: ["*"]
connections:
allow: ["*"]
audit:
enabled: true
log_tool_calls: true
A ConfigMap change does not restart the pods on its own. Roll them after editing it:
Deployment¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: mcp-data-platform
template:
metadata:
labels:
app.kubernetes.io/name: mcp-data-platform
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
spec:
serviceAccountName: mcp-data-platform
automountServiceAccountToken: false
# Defaults total ~40s (2s pre-shutdown + 25s drain + 10s lifecycle stop
# + a few seconds of close). See the Tuning and Scaling guide.
terminationGracePeriodSeconds: 60
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/name: mcp-data-platform
topologyKey: kubernetes.io/hostname
containers:
- name: mcp-data-platform
image: ghcr.io/txn2/mcp-data-platform:v1.120.0
imagePullPolicy: IfNotPresent
args:
- --config
- /etc/mcp-data-platform/platform.yaml
- --transport
- http
- --address
- :8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
env:
# The Go runtime is not cgroup-aware; match it to the limits below.
- name: GOMEMLIMIT
value: "450MiB" # ~90% of limits.memory
- name: GOMAXPROCS
value: "1" # limits.cpu rounded up
- name: LOG_LEVEL
value: "info"
- name: OTEL_METRICS_ENABLED
value: "true"
- name: OTEL_METRICS_ADDR
value: ":9090"
- name: DATAHUB_TOKEN
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: datahub-token
- name: OAUTH_SIGNING_KEY
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: oauth-signing-key
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: keycloak-client-secret
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: encryption-key
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: database-url
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 512Mi
volumeMounts:
- name: config
mountPath: /etc/mcp-data-platform
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: mcp-data-platform-config
- name: tmp
emptyDir: {}
/readyz reports draining (503) as soon as SIGTERM arrives, so the load
balancer stops routing to a terminating pod before the drain begins;
/healthz stays 200 for as long as the process is alive. Size the probe and
resource figures from the
Tuning and Scaling guide, which covers
GOMEMLIMIT/GOMAXPROCS selection, the four-stage shutdown budget, and
measured per-replica throughput.
Service¶
apiVersion: v1
kind: Service
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: mcp-data-platform
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
- name: metrics
port: 9090
targetPort: metrics
protocol: TCP
Ingress¶
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
# SSE and streamable HTTP hold the response open; a short read timeout
# cuts live MCP sessions.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- secretName: mcp-data-platform-tls
hosts:
- mcp.example.com
rules:
- host: mcp.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mcp-data-platform
port:
name: http
The metrics port is deliberately not routed through the ingress. It carries no authentication of its own, and keeping it cluster-internal is what makes that acceptable; see Observability.
HorizontalPodAutoscaler¶
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-data-platform
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
CPU-driven autoscaling is only meaningful once GOMAXPROCS matches the CPU
limit; without it the runtime sizes itself from the node's core count and the
utilization figure is not comparable across nodes.
PodDisruptionBudget¶
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: mcp-data-platform
Apply¶
kubectl apply -f ./manifests -n mcp-data-platform
kubectl rollout status deployment/mcp-data-platform -n mcp-data-platform
kubectl get pods,hpa -n mcp-data-platform
Split deployment: portal and script workers¶
Every replica of the deployment above serves traffic, executes queued managed scripts, and turns their due schedules into runs. That is the right shape until scripts start doing real work: the Starlark interpreter has no hard per-script memory cap, so a heavy approved script pushes on the memory of a pod that agents are also talking to, and execution capacity is tied to serving capacity even though the two scale on different signals.
Splitting them changes one configuration key and adds one Deployment. Both use the same image and the same ConfigMap; nothing else about the platform differs between them. Schedules follow the worker: the serving pods still accept a schedule being set, and the worker pods are what fire it, so a split deployment with no worker replicas running stores schedules that nothing materializes.
Make the switch an environment variable in the shared ConfigMap, so each deployment sets its own value:
# In the ConfigMap's platform.yaml, alongside the other blocks.
scripts:
worker:
# Serving pods set SCRIPTS_WORKER_ENABLED=false; worker pods set it true.
# Unset means enabled, which keeps the single-binary deployment unchanged.
enabled: ${SCRIPTS_WORKER_ENABLED:-true}
Then add the variable to the serving Deployment's container env:
And apply a second Deployment for the workers. It is the serving manifest with
four changes: the name and labels, SCRIPTS_WORKER_ENABLED=true, more memory
(a run holds its result set in the interpreter's heap), and no HPA, Service, or
Ingress. It is the same binary and still starts the HTTP listener, so its
health and metrics endpoints work as they do everywhere else; it takes its work
from the database queue rather than from a request, so nothing needs to route
to it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-data-platform-scripts
namespace: mcp-data-platform
labels:
app.kubernetes.io/name: mcp-data-platform
app.kubernetes.io/component: script-worker
spec:
# A replica executes one run at a time, so concurrency is the replica count.
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: mcp-data-platform
app.kubernetes.io/component: script-worker
template:
metadata:
labels:
app.kubernetes.io/name: mcp-data-platform
app.kubernetes.io/component: script-worker
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
spec:
serviceAccountName: mcp-data-platform
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: mcp-data-platform
image: ghcr.io/txn2/mcp-data-platform:v1.120.0
imagePullPolicy: IfNotPresent
args:
- --config
- /etc/mcp-data-platform/platform.yaml
- --transport
- http
- --address
- :8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
env:
- name: SCRIPTS_WORKER_ENABLED
value: "true"
# A script's result set lives in the interpreter's heap, which has
# no hard cap, so give the worker room and tell the Go runtime
# where the ceiling is. GOMEMLIMIT is what makes the collector work
# against the limit instead of discovering it.
- name: GOMEMLIMIT
value: "900MiB" # ~90% of limits.memory
- name: GOMAXPROCS
value: "1"
- name: LOG_LEVEL
value: "info"
- name: OTEL_METRICS_ENABLED
value: "true"
- name: OTEL_METRICS_ADDR
value: ":9090"
- name: DATAHUB_TOKEN
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: datahub-token
- name: OAUTH_SIGNING_KEY
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: oauth-signing-key
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: keycloak-client-secret
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: encryption-key
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: mcp-data-platform-secrets
key: database-url
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
volumeMounts:
- name: config
mountPath: /etc/mcp-data-platform
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: mcp-data-platform-config
- name: tmp
emptyDir: {}
Capacity, briefly. A worker executes one run at a time, so concurrent runs equal
worker replicas: two replicas is enough for on-demand runs and a handful of
schedules, and the signal to add more is queue wait — runs sitting pending
while workers are busy — rather than CPU. Memory is the figure to set from
measurement: give a worker the largest result set its approved scripts hold plus
headroom, keep GOMEMLIMIT at about 90% of the limit, and remember that the
approval gate is what decides how large that can get.
The worker pods still expose /healthz, /readyz, and /metrics, which is what
the probes and the Prometheus scrape above use. Rolling them is safe at any
time: a draining worker stops claiming immediately, finishes the run it holds if
the grace period allows, and otherwise releases it back onto the queue for
another replica to pick up at once.
Production Checklist¶
Security¶
- TLS enabled for all external endpoints
- Secrets stored in external secrets manager (Vault, AWS Secrets Manager)
- Network policies restrict pod-to-pod communication
- Pod security context configured (non-root, read-only filesystem)
- Resource limits set for all containers
- OIDC configured with production identity provider
- API keys rotated regularly
High Availability¶
- Multiple replicas deployed (minimum 2)
- PodDisruptionBudget configured
- Anti-affinity rules spread pods across nodes
- Health checks configured for liveness and readiness
- HPA configured for automatic scaling
Monitoring¶
- Prometheus metrics enabled and scraped
- Grafana dashboards deployed
- Alerting rules configured
- Log aggregation set up (ELK, Loki)
- Distributed tracing enabled (Jaeger, Zipkin)
Operations¶
- Backup strategy for PostgreSQL audit logs
- Disaster recovery plan documented
- Runbooks for common issues
- On-call rotation established
MCP gateway (if enabled)¶
The gateway toolkit (kind mcp) has additional production
requirements:
-
ENCRYPTION_KEYis set (32 bytes of key material; accepted as 64 hex characters, 44-character base64, or 32 raw bytes). Required for at-rest encryption of stored credentials, OAuth access and refresh tokens (gateway_oauth_tokens), and PKCE state (oauth_pkce_states.code_verifier). Without it the platform logs a warning and stores those values in plaintext — not acceptable in production. - PostgreSQL is reachable from every replica and shared.
Multi-replica deployments rely on the Postgres-backed PKCE state
store so an
oauth-starton replica A and the redirect callback on replica B can find each other. The platform automatically uses Postgres whendatabase.dsnis set. - OAuth callback path (
/api/v1/admin/oauth/callback) is reachable on the public-facing URL of the platform. The upstream OAuth provider redirects the operator's browser here after sign-in; the path is intentionally public (state token authenticates the callback) and must be allowed through any reverse-proxy auth. - External Client App / OAuth client registration on each
upstream lists the platform's
/api/v1/admin/oauth/callbackURL as an allowed redirect URI. Required forauthorization_codegrants (e.g. Salesforce Hosted MCP). -
ENCRYPTION_KEYrotation plan. Rotating the key invalidates every encrypted value inconnection_instances,gateway_oauth_tokens, andoauth_pkce_states— gateway connections will lose their stored credentials and authorization_code connections will need to be re-Connected through the portal. Plan accordingly.
Upgrades and connected agents¶
The platform ships frequently, and each upgrade can change the tool contract (new
tools, new parameters, updated descriptions). How a connected agent picks up the new
contract depends on its client, because MCP delivers a changed tool list in-band only
on a live session; a binary upgrade is a new process, so the agent must reconnect to
re-handshake (initialize + tools/list) against the new build.
What the server does on shutdown¶
On SIGTERM (a rolling deploy), the server:
- Marks readiness draining so the load balancer stops routing new connections, then
waits
server.shutdown.pre_shutdown_delayfor deregistration. - Drains in-flight HTTP requests, and after a short settle closes live MCP sessions. Long-lived SSE and streamable-HTTP streams never go idle on their own, so until the session is closed the agent stays on the old build. Closing it drops the stream so the client reconnects to a new pod and re-fetches the tool list. The close is graceful: an idle session drops immediately, a session with an in-flight tool call is allowed to finish, bounded by the grace period (after which process exit drops what remains).
Relevant settings under server.shutdown are pre_shutdown_delay and
grace_period; size them so the full sequence fits inside the pod's
terminationGracePeriodSeconds.
Per-client behavior¶
| Client | On upgrade |
|---|---|
| Claude Code | Automatic. It honors notifications/tools/list_changed and auto-reconnects HTTP/SSE servers (exponential backoff). When the old session is closed it reconnects to the new build and re-fetches the tool list with no user action. |
| Claude Desktop | Requires a full app restart to pick up a changed tool list; it has no in-session refresh or reconnect action today. |
| claude.ai managed web connector | Caches the tool schema at the connector level; a connector re-sync (remove and re-add, or the workspace refresh) is needed to pick up changes. |
Keep upgrades safe¶
Because a client may still be running on a cached contract briefly after a deploy, keep tool/schema changes additive: adding a new optional parameter or a new tool is safe (a cached client simply does not see it until it refreshes). Renaming or removing a parameter, or removing a tool, breaks a client mid-session; deprecate across a release before removing.
Monitoring Setup¶
Prometheus ServiceMonitor¶
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mcp-data-platform
namespace: mcp-data-platform
spec:
selector:
matchLabels:
app.kubernetes.io/name: mcp-data-platform
endpoints:
- port: metrics
interval: 30s
path: /metrics
Grafana Dashboard¶
Key metrics to monitor:
- Tool-call rate:
sum(rate(mcp_tool_calls_total[5m])) - Error rate:
sum(rate(mcp_tool_calls_total{status_category!="ok"}[5m])) / sum(rate(mcp_tool_calls_total[5m])) - Latency:
histogram_quantile(0.99, sum by (le) (rate(mcp_tool_call_duration_seconds_bucket[5m]))) - Enrichment overhead:
rate(mcp_enrichment_bytes_total[5m]) / rate(mcp_tool_calls_total[5m]) - In-flight tool calls:
mcp_inflight_tool_calls - Dropped audit events:
rate(audit_events_dropped_total[5m])
Starter recording and alert rules covering these, as ConfigMaps that load
without the Prometheus Operator, ship in
deployments/observability/.
The full metric and label reference is in Observability.
Scaling Considerations¶
Horizontal Scaling¶
mcp-data-platform is stateless and scales horizontally. Key considerations:
- Connection pooling: Each replica maintains its own connections to DataHub/Trino
- Cache coordination: Semantic cache is per-instance; consider Redis for shared caching at scale
- Load balancing: Use sticky sessions for SSE connections
Vertical Scaling¶
Increase resources for:
- High query volume: More CPU for request processing
- Large result sets: More memory for enrichment processing
- Many concurrent connections: More memory for connection state
Bottleneck Analysis¶
Common bottlenecks and solutions:
| Bottleneck | Symptom | Solution |
|---|---|---|
| DataHub API | High enrichment latency | Enable caching, increase DataHub resources |
| Trino queries | Timeout errors | Tune Trino cluster, add query limits |
| PostgreSQL audit | Write latency | Use async writes, add replicas |
| Network | Connection timeouts | Deploy closer to data sources |