Self-hosting
Self-hosting9 min

Clustering

How MCPG replicas share state. The cluster coordinator selection (single_node, redis, nats, consul, etcd), the real per-backend config keys, and the primitive-inheritance model that makes single-node work out of the box.

A multi-replica MCPG fleet needs shared state: which replica owns a session, where to fan a notifications/cancelled, who holds a lease. The top-level cluster block selects one coordinator backend that provides all of it. This page documents the real per-backend config keys — the cluster block flows verbatim to the coordinator plugin, which validates it at boot.

The coordinator model

The cluster coordinator is the unified backbone for multi-instance state and coordination. A single backend internally provides four primitives:

  • KeyValueStore — session / task / subscription / pipeline state.
  • PubSub — delivery + cancellation fan-out across replicas.
  • Lease — leader election and distributed locks (fencing tokens).
  • Watch — peer presence and change notification.

Capabilities (sessions, pipelines, tasks, subscriptions, delivery, cancellation) inherit those primitives by default. That inheritance is the whole trick: because every capability defaults to "use the cluster coordinator," a single-node deployment works with no extra config — cluster.kind: single_node installs an in-process coordinator and the capabilities transparently use its in-memory primitives.

Selecting a backend

yaml
cluster:
  kind: redis              # single_node | redis | nats | consul | etcd
  # ...kind-specific keys below
kindPlugin idWhen to pick
single_node (default)built-in, no pluginSingle instance. In-process; no external dependency.
redisdev.mcpg.cluster.redisYou already run Redis. Lowest operational overhead.
natsdev.mcpg.cluster.natsNATS is already in your stack. JetStream KV + pub/sub + leases.
consuldev.mcpg.cluster.consulYou run Consul for service discovery / KV.
etcddev.mcpg.cluster.etcdYou run etcd (e.g. alongside Kubernetes' own).

single_node is the default when cluster is omitted — it ignores every other field in the block. Any other kind maps to a dev.mcpg.cluster.<kind> cdylib plugin that must be declared under the top-level plugins[] array. The inline cluster.* fields are the single source of truth for the coordinator's runtime config — they replace any config: block on the matching plugins[] entry, so you keep the cdylib location (source.oci / source.path) in plugins[] and the operational knobs in cluster.

If cluster.kind selects a plugin id with no matching plugins[] entry, the gateway fails fast at boot.

A note on validation layers

mcpg config check validates the gateway's AppConfig shape. The cluster block's kind-specific fields are passed through as an opaque JSON map and are parsed by the coordinator plugin at boot, where each plugin enforces deny_unknown_fields. The practical consequence: a key that mcpg config check accepts can still be rejected by the plugin at startup. Use the exact key names below — they are taken directly from each plugin's config schema.

Transport security

The cluster coordinator carries all shared multi-instance state — sessions (including SSE replay windows with tool results), credential-cache events, delivery payloads, tasks, pipelines, and lease/fence tokens. A plaintext coordinator link exposes them across the whole deployment, so the gateway is secure by default: it refuses to boot against a plaintext (non-TLS) coordinator unless you explicitly opt in.

yaml
cluster:
  kind: redis
  url: "rediss://redis.svc:6379"   # TLS scheme → boots
  allow_insecure_transport: false  # default; set true ONLY for local/dev

What counts as plaintext, per coordinator:

KindRefused at boot when…Secure form
redisurl uses redis://rediss:// (+ out-of-URL password)
consuladdress uses http://https://
etcdany endpoint uses http://https:// + a tls: block
natstls.require_tls: falseTLS required by default (omit the override)

Set allow_insecure_transport: true to accept a plaintext transport (local/dev/CI on a trusted network). The Helm chart exposes this as cluster.insecureTransport; the bundled redis/nats subcharts run plaintext in-namespace and the chart sets the opt-out automatically for that quick-start path only — point at an external, TLS-terminated coordinator for production.

Note. The boot guard inspects the configured value, which is evaluated before ${env.X} substitution. If you supply the coordinator URL via an env placeholder (url: "${env.MCPG_REDIS_URL}"), the guard can't see the resolved scheme — set a TLS scheme in the env value itself (rediss:// / tls:// / https://). The Helm chart renders literal URLs, so it is always covered.

Coordinator health + readiness

For a non-single_node coordinator that exposes a KV (redis / nats), the gateway runs a periodic KV-ping probe and publishes a mcpg_cluster_backend_up gauge (1 reachable / 0 down) — independent of any lease consumer, so the McpgClusterCoordinatorDown alert fires even on a deployment running neither cedar nor identity.workload. cluster.readiness_gate controls whether that feeds /ready:

readiness_gateEffect when the coordinator is unreachable
off (default)/ready stays green (fail-open); only the gauge + alert react.
degradeA not-ready cluster_backend check appears in the /ready body, but the overall status stays green (no LB flapping).
fail/ready returns not-ready (fail-closed) — the load balancer pulls the instance until the coordinator recovers.
yaml
cluster:
  kind: redis
  url: "rediss://redis.svc:6379"
  readiness_gate: fail   # off | degrade | fail

Coordination-only kinds (consul / etcd, no KV accessor) can't be probed, so the gate is inert for them (the gateway logs a WARN if you set it).

Redis (dev.mcpg.cluster.redis)

yaml
cluster:
  kind: redis
  url: "${env.MCPG_REDIS_URL}"     # required — rediss:// (TLS) for production
  password: "${env.REDIS_PASSWORD}" # optional — keep credentials OUT of the url
  key_prefix: "mcpg:cluster:"      # namespace per deployment
  lease_ttl_ms: 30000              # default lock / leadership TTL
  peer_ttl_ms: 60000               # per-instance peer-presence TTL
KeyDefaultNotes
url— (required)redis://… or rediss://…. Any other scheme is rejected. A plaintext redis:// url is refused at boot unless allow_insecure_transport: true (see Transport security).
usernamenoneOptional ACL username, supplied out-of-URL.
passwordnoneOptional password, supplied out-of-URL (preferred over embedding user:pass@ in the url). Redacted in logs. Helm injects it via MCPG_CLUSTER__PASSWORD.
key_prefixmcpg:cluster:Prepended to every owned key. Give each gateway deployment sharing one Redis a distinct prefix.
lease_ttl_ms30000Default TTL for acquire_lock / acquire_leadership when the caller passes none.
peer_ttl_ms60000TTL on the per-instance peer-presence key; a missed refresh expires the peer.
peer_refresh_interval_mspeer_ttl_ms / 2How often the background refresher re-registers the peer.
lease_renew_before_expiry_percent80Renewal fires at this fraction of TTL elapsed. Clamped to 1..=99.
subscribe_pattern_buffer256Buffer for subscriber + peer-event streams.
node_idsynthesisedStable node id. Defaults to service_name-$HOSTNAME, falling back to a random suffix.
service_namemcpgLogical service name; prefix for the synthesised node_id.

There is no pool_size key on the Redis coordinator — connection pooling is internal.

NATS JetStream (dev.mcpg.cluster.nats)

NATS uses a list of servers and a structured jetstream block — not a single url / bucket.

yaml
cluster:
  kind: nats
  servers:
    - "${env.MCPG_NATS_URL}"       # nats:// | tls:// | nats+tls:// | ws:// | wss://
  node:
    id: "${env.HOSTNAME}"          # required — unique per replica
  jetstream:
    replicas: 3                    # 1 for dev, 3 for a real NATS cluster
    storage: file                  # file (durable) | memory
KeyDefaultNotes
servers— (required)One or more NATS URLs; the client load-balances + reconnects across them.
node.id— (required)Unique per replica — pin to the pod / hostname.
node.heartbeat_interval_sec10Heartbeat publish cadence.
node.peer_expiry_sec30Peer reclassified Unreachable after this gap. Must be > heartbeat interval.
jetstream.replicas1KV/stream replication factor. Set 3 on a 3+ node NATS cluster.
jetstream.storagefilefile survives NATS restarts; memory is faster but loses lease state.
jetstream.leases_bucketmcpg-leasesKV bucket for leases + locks.
jetstream.fencing_bucketmcpg-fencingKV bucket for fencing-token counters.
jetstream.notifications_streammcpg-notificationsStream for pub/sub fan-out.
jetstream.state_bucketmcpg-stateKV bucket for capability (session/task/…) state.
jetstream.domainnoneJetStream domain (leaf-node segmentation).
lease.default_ttl_sec30Default lease TTL (seconds).
lease.renew_before_expiry_percent50Renewal point as a fraction of TTL. Range 1..=99.
authnone{ method: token | user_password | credentials_file, … }.
tlsTLS required{ ca_cert, require_tls }. require_tls defaults to true even when the tls block is omitted — a plaintext NATS link needs an explicit tls: { require_tls: false } (refused at boot unless allow_insecure_transport: true). When TLS is negotiated the server cert is rustls-verified (rooted in ca_cert if given); there is no skip-verify knob.
connection.connect_timeout_ms5000Connect deadline.
connection.operation_timeout_ms10000Per-operation deadline.

NATS auth is tagged on method:

yaml
cluster:
  kind: nats
  servers: ["tls://nats.svc:4222"]
  node:
    id: "${env.HOSTNAME}"
  auth:
    method: credentials_file
    path: "/etc/mcpg/nats.creds"
  tls:
    ca_cert: "/etc/mcpg/certs/nats-ca.pem"
    require_tls: true   # default — set false only for a plaintext dev link

Consul (dev.mcpg.cluster.consul)

Consul uses address (the HTTP API base URL), not url.

yaml
cluster:
  kind: consul
  address: "https://consul.svc:8500"  # required — https:// for production
  service_name: "mcpg"                # required
  kv_prefix: "mcpg/prod/"             # distinct per deployment
KeyDefaultNotes
address— (required)Consul HTTP API base URL. A plaintext http:// address is refused at boot unless allow_insecure_transport: true (see Transport security); use https://.
service_name— (required)Name this gateway registers / looks up peers under.
kv_prefixmcpg/KV path prefix for plugin state. Set distinct per deployment on a shared Consul.
tokennoneConsul ACL token; sent as X-Consul-Token.
datacenteragent-localCross-DC ?dc= parameter.
node_idservice_name-$HOSTNAMEStable id for self-publish dedup.
subscribe_wait_ms30000Long-poll wait for the subscribe path. Range 1..=600000 (Consul's 10-minute max).
lease_renew_before_expiry_percent30Renewal point as a fraction of TTL. Clamped 1..=99.

etcd (dev.mcpg.cluster.etcd)

etcd uses a list of endpoints, and key_prefix must end in /.

yaml
cluster:
  kind: etcd
  endpoints:
    - "https://etcd-0:2379"
    - "https://etcd-1:2379"
  key_prefix: "/mcpg/prod/"           # MUST end with '/'
  tls:
    ca_cert: "/etc/mcpg/certs/etcd-ca.pem"   # omit to use system roots
    # client_cert / client_key: "..."        # for mTLS
    # domain_name: "etcd.internal"            # SNI / cert-name override
  auth:
    username: "mcpg"
    password: "${env.ETCD_PASSWORD}"
KeyDefaultNotes
endpoints— (required)One or more etcd endpoints; the client load-balances + retries across them. A plaintext http:// endpoint is refused at boot unless allow_insecure_transport: true.
key_prefix/mcpg/Key prefix; must end with /. Set distinct per deployment on a shared etcd.
event_ttl_ms60TTL (seconds) for transient pub/sub events.
lease_renew_before_expiry_percent30Renewal point as a fraction of TTL. Clamped 1..=99.
node_idsynthesisedStable node id. Defaults to a {key_prefix}node-{hostname} value.
tlsnone{ ca_cert?, client_cert?, client_key?, domain_name? } for https:// endpoints. Config is fail-closed: https:// without a tls block, a tls block over http://-only endpoints, mixed http://+https://, or an mTLS half-pair (client_cert without client_key) are all rejected at boot.
authnone{ username, password } for Auth-enabled clusters. Rejected over plaintext http:// (the password would cross the wire in cleartext) — use https:// + tls.

Per-capability overrides

By default every capability inherits the cluster coordinator's primitives — that is what you want for HA, and what makes single-node work with zero config. You can override an individual capability's store or bus, but only to in-process kinds:

yaml
mcp:
  configurations:
    sessions:
      store:
        kind: cluster     # default — delegate to the coordinator (same as omitting)
    delivery:
      bus:
        kind: memory      # pin to in-process — single-replica only
Override kindstore:bus:Meaning
clusteryesyesDelegate to the coordinator. Identical to omitting the override.
memoryyesyesIn-process. Single-replica only.
fileyesnoFile-backed (dir: required). Single-node persistent.

redis and nats are not valid per-capability override kinds. To put capability state on Redis or NATS, set cluster.kind: redis | nats and let the capability inherit (via kind: cluster or by omitting the override). Pinning delivery.bus or cancellation.bus to memory in a multi-replica deployment silently breaks cross-replica delivery — leave them inherited.

Validating

bash
mcpg config check ./config.yaml
# ✓ ./config.yaml: valid (1 bindings, audit on, observability on, cluster: nats)

Remember the two-layer model: mcpg config check confirms the AppConfig shape; the coordinator plugin validates the kind-specific keys (URL scheme, required fields, ranges) at boot. Use the key names above to avoid a boot-time rejection that the config check can't see.

What's next