Kubernetes Integration

New to this? Two Hangars, one verdict is the concept behind this page.

Deploy and manage MCP servers as native Kubernetes resources using the MCP-Hangar Operator.

The MCP-Hangar Operator is shipped from a separate repository: mcp-hangar-operator. Helm charts live in helm-charts, and both packages are listed on Artifact Hub: mcp-hangar, mcp-hangar-operator.

Overview

The MCP-Hangar Operator provides:

  • MCPServer - Declarative MCP server management
  • MCPServerGroup - Aggregates member health by label selector against a healthPolicy
  • MCPDiscoverySource - Automatic MCP server discovery
  • MCPEgressPolicy - Declarative, deny-by-default egress control (which upstreams a server may reach, which tool calls it may make, and what happens on a violation). See the Egress Policy guide.

CRD API version. These examples use apiVersion: mcp-hangar.io/v1alpha2.

Installation

Prerequisites

  • Kubernetes 1.25+
  • Helm 3.x or 4.x (see Helm versions)
  • kubectl configured for your cluster

Helm versions

Helm 3 and Helm 4 are both supported and CI-tested on every helm-charts PR: lint/render under both majors, identical rendered output across them, the full install → test → upgrade → rollback lifecycle under each, and the cross path (installed by Helm 3, upgraded by Helm 4). The pinned versions live in the helm-charts CI; the Helm versions section of the helm-charts README is the source of truth.

What the majors do differently, as observed by those CI assertions:

  • One release = one apply model. A fresh Helm 4 install uses server-side apply (SSA); a release created by Helm 3 keeps client-side apply across Helm 4 upgrades until you opt in with helm upgrade --server-side=true (the flag takes a value). Don't mix majors on the same release ad hoc.
  • SSA turns silent overwrites into explicit conflicts — relevant here because the operator chart ships its CRDs as templates. On an SSA-installed release, a plain out-of-band kubectl apply --server-side to a helm-owned field is refused by the apiserver (conflict with "helm"). If the other writer forces the conflict and takes the field, the next helm upgrade fails with an explicit conflict error naming the competing manager — it does not silently take the field back; helm upgrade --force-conflicts is the documented way to reclaim it. A Helm-3-created (client-side) release has none of this protection: the same out-of-band write goes through silently.
  • --wait and helm test are stricter under Helm 4 (kstatus judges real readiness — probes and conditions, not the Helm 3 pod-status heuristic). A deploy that "passed" under Helm 3 and fails under 4 is the check getting honest, not the chart regressing. One concrete flip in the other direction: helm3 test --logs exits non-zero on a passing test of the mcp-hangar chart, because Helm 3 deletes the hook-succeeded test pod before fetching its logs (Helm 4 prints the logs first); run helm test without --logs under Helm 3.

Install CRDs

The Helm chart owns the CRDs (crds.install, on by default) and keeps them on uninstall (crds.keep). There is no separate manual step:

kubectl get crds | grep mcp-hangar.io

Install Operator via Helm

# Install operator (latest published chart; pin --version from the compatibility matrix)
helm install mcp-hangar-operator oci://ghcr.io/mcp-hangar/charts/mcp-hangar-operator \
  --namespace mcp-hangar \
  --create-namespace \
  --set hangar.url=http://mcp-hangar-core:8080

# Verify
kubectl get pods -n mcp-hangar

Configuration

# values.yaml
operator:
  logLevel: info
  metrics:
    enabled: true
    port: 8080
  leaderElection:
    enabled: true

hangar:
  url: "http://mcp-hangar-core.mcp-hangar.svc.cluster.local:8080"
  existingSecret: "mcp-hangar-credentials"
  secretKey: "api-key"

resources:
  limits:
    cpu: 500m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi

MCPServer

Basic MCP Server

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: sqlite-tools
  namespace: mcp-servers
spec:
  mode: container
  image: ghcr.io/modelcontextprotocol/mcp-sqlite:latest
  replicas: 1

  startupTimeout: "60s"

  resources:
    requests:
      memory: "128Mi"
      cpu: "100m"
    limits:
      memory: "512Mi"
      cpu: "500m"

  env:
    - name: SQLITE_DB_PATH
      value: /data/database.db

The operator checks health on its own reconcile cadence — Hangar's health endpoint for remote servers, pod phase for container ones. There is no per-server interval to set.

MCP Server with Secrets

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: github-tools
  namespace: mcp-servers
spec:
  mode: container
  image: ghcr.io/modelcontextprotocol/mcp-github:latest

  env:
    - name: GITHUB_TOKEN
      valueFrom:
        secretKeyRef:
          name: github-credentials
          key: token

Restricting which tools this server may expose is not an MCPServer field. That is MCPEgressPolicy — see the Egress Policy guide. Core's own tools.allow_list in config.yaml is a separate mechanism for a server Hangar runs itself.

Remote MCP Server

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: external-api
  namespace: mcp-servers
spec:
  mode: remote
  endpoint: https://api.example.com/mcp

  startupTimeout: "30s"

Circuit breaking lives in core (config.yaml), not on the CR. The operator's own consecutive-failure cap before it marks a server Degraded is a constant, not a setting.

Cold Start (Scale to Zero)

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: expensive-tool
spec:
  mode: container
  # A placeholder for your own expensive provider -- deliberately not a
  # pullable name. For a provider you can actually run today, see
  # OFFICIAL_SERVERS.md.
  image: registry.example.com/your-org/expensive-tool:latest

  # Start with 0 replicas - will start on first request
  replicas: 0

Idle shutdown is core's, not the CR's. Hangar stops an idle backend on idle_ttl_s; a server it discovers in the cluster takes core's create default of 300s. The MCPServer spec has no idle field, and the discovery-entry TTL annotation (mcp-hangar.io/ttl) is a different quantity — how long core keeps an entry it has stopped seeing.

MCPServerGroup

A group is a status aggregator: it selects MCPServers by label, counts their states, and reports Ready / Degraded / Available against a healthPolicy. Traffic is not routed through it — there is no strategy, failover or session affinity to configure, and none of those were ever honoured.

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServerGroup
metadata:
  name: database-tools-ha
  namespace: mcp-servers
spec:
  # Select mcp_servers by label
  selector:
    matchLabels:
      mcp-hangar.io/category: database

  # When does this group report Degraded?
  healthPolicy:
    minHealthyPercentage: 50
    unhealthyThreshold: 3

Load balancing across members of a Hangar group is a core feature (config.yaml / POST /api/groups), on a different object. The operator does not call that API.

Label MCP servers for Grouping

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: sqlite-primary
  labels:
    mcp-hangar.io/category: database
    mcp-hangar.io/tier: primary
spec:
  mode: container
  image: ghcr.io/modelcontextprotocol/mcp-sqlite:latest
---
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: sqlite-replica
  labels:
    mcp-hangar.io/category: database
    mcp-hangar.io/tier: replica
spec:
  mode: container
  image: ghcr.io/modelcontextprotocol/mcp-sqlite:latest

MCPDiscoverySource

Namespace Discovery

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPDiscoverySource
metadata:
  name: team-mcp-servers
  namespace: mcp-hangar
spec:
  type: Namespace
  mode: Authoritative  # Additive or Authoritative
  refreshInterval: "5m"

  namespaceSelector:
    matchLabels:
      mcp-hangar.io/enabled: "true"

  providerTemplate:
    spec:
      startupTimeout: "60s"
      resources:
        requests:
          memory: "64Mi"
          cpu: "50m"

ConfigMap Discovery

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPDiscoverySource
metadata:
  name: config-mcp-servers
spec:
  type: ConfigMap
  refreshInterval: "1m"

  configMapRef:
    name: mcp-server-definitions
    namespace: mcp-config

Security

Pod Security

All MCP server pods run with secure defaults:

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 65534
containerSecurityContext:
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL

The two fields mirror Kubernetes' own split: podSecurityContext is corev1.PodSecurityContext and applies to the pod, containerSecurityContext is corev1.SecurityContext and applies to the provider container. Settings that exist at only one level -- readOnlyRootFilesystem, capabilities, allowPrivilegeEscalation -- belong to the container one.

Override if needed:

apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
  name: my-mcp-server
spec:
  podSecurityContext:
    runAsUser: 1000
  containerSecurityContext:
    readOnlyRootFilesystem: false  # If mcp_server needs writable fs

RBAC

The operator requires cluster-level permissions:

# Automatically created by Helm chart
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: mcp-hangar-operator
rules:
  - apiGroups: [mcp-hangar.io]
    resources: [mcpservers, mcpservergroups, mcpdiscoverysources]
    verbs: [get, list, watch, create, update, patch, delete]
  - apiGroups: [""]
    resources: [pods, secrets, configmaps]
    verbs: [get, list, watch, create, update, patch, delete]

Network Policies

Restrict MCP server communication:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: mcp-server-isolation
  namespace: mcp-servers
spec:
  podSelector:
    matchLabels:
      mcp-hangar.io/mcp_server: "true"
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              mcp-hangar.io/core: "true"
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              mcp-hangar.io/core: "true"

Monitoring

Prometheus Metrics

The operator exposes metrics at :8080/metrics:

MetricTypeDescription
mcp_operator_reconcile_totalCounterTotal reconciliations
mcp_operator_reconcile_duration_secondsHistogramReconciliation duration
mcp_operator_provider_stateGaugeMCP server state (1 = active)
mcp_operator_provider_tools_countGaugeTools per MCP server
mcp_operator_provider_health_check_failures_totalCounterHealth check failures

ServiceMonitor

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: mcp-hangar-operator
  namespace: mcp-hangar
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: mcp-hangar-operator
  endpoints:
    - port: metrics
      interval: 30s

Alerts

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: mcp-hangar-alerts
spec:
  groups:
    - name: mcp-hangar
      rules:
        - alert: MCPServerDegraded
          expr: mcp_operator_provider_state{state="Degraded"} == 1
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "MCP MCP Server {{ $labels.name }} is degraded"

        - alert: MCPServerDead
          expr: mcp_operator_provider_state{state="Dead"} == 1
          for: 2m
          labels:
            severity: critical
          annotations:
            summary: "MCP MCP Server {{ $labels.name }} is dead"

Troubleshooting

Check MCP Server Status

# List all mcp_servers
kubectl get mcpservers -A

# Describe specific mcp_server
kubectl describe mcpserver my-mcp-server -n mcp-servers

# Check conditions
kubectl get mcpserver my-mcp-server -o jsonpath='{.status.conditions}'

Check Operator Logs

kubectl logs -n mcp-hangar deployment/mcp-hangar-operator -f

Common Issues

MCP Server stuck in Initializing:

  • Check pod logs: kubectl logs mcp-MCP server-<name> -n <namespace>
  • Verify image exists and is pullable
  • Check resource limits

MCP Server in Degraded state:

  • Health checks failing
  • Check network connectivity to MCP server
  • Verify MCP-Hangar core is running

Hangar pod in CrashLoopBackOff right after a 2.1.0 upgrade:

  • Check the logs for Configured subsystem is not reachable on this server. The 2.1.0 startup check refuses the boot when the config gates a tool behind tools.approval_list and no approval gate service exists.
  • Either remove the approval_list entry, or stop disabling the gate (approvals.enabled: false).
  • startup_checks: {enforce: false} downgrades the refusal to an error log if you need the pod up while you fix the config. See Configuration → startup_checks.

Discovery not finding MCP servers:

  • Verify namespace labels match selector
  • Check MCPDiscoverySource status
  • Review operator logs for discovery errors

API Reference

MCPServer Spec

FieldTypeRequiredDefaultDescription
modestringYes-container or remote
imagestringFor container-Container image
endpointstringFor remote-HTTP endpoint URL
replicasintNo1Desired replicas (0 = cold)
startupTimeoutdurationNo30sStartup timeout
shutdownGracePerioddurationNo30sPod termination grace period
resourcesobjectNo-Resource requirements
envarrayNo-Environment variables
volumesarrayNo-Pod volumes (corev1.Volume)
volumeMountsarrayNo-Where the provider container mounts them (corev1.VolumeMount)
podSecurityContextobjectNosecure defaultsPod-level security context (corev1.PodSecurityContext)
containerSecurityContextobjectNosecure defaultsContainer-level security context (corev1.SecurityContext)
serviceAccountNamestringNo-ServiceAccount
nodeSelectormapNo-Node selection
tolerationsarrayNo-Tolerations
capabilities.networkobjectNo-Declared egress; feeds the generated NetworkPolicy
capabilities.toolsobjectNo-maxCount / expectedTools; drives violation events
capabilities.enforcementModestringNo-audit or block

MCPServer Status

FieldTypeDescription
statestringCold, Initializing, Ready, Degraded, Dead
replicasintCurrent replicas
readyReplicasintReady replicas
toolsCountintAvailable tools
toolsarrayTool names
lastStartedAttimeLast start time
lastHealthChecktimeLast health check
consecutiveFailuresintFailure count
conditionsarrayStatus conditions

Examples

See examples/kubernetes/ for complete examples.