Knowledge Base MCP - Deployment

4 min read

Prerequisites

kb-mcp has no knowledge-base data of its own: every tool call is a live proxy to your tenant's Unique API, so a Unique platform subscription is a hard requirement. Where kb-mcp itself runs is flexible: the Helm chart below targets Kubernetes, but the same Docker image runs on any other cloud or locally. Only the environment variables change. Zitadel is likewise fixed: it's always the same instance your Unique AI tenant already uses, never one you stand up yourself.

  • Kubernetes 1.25+ with a Gateway API implementation (the chart exposes itself via HTTPRoute, not a classic Ingress)

  • Helm 3.x with OCI support

  • Postgres 14+ for OAuth-proxy state

  • The public (PKCE) OIDC client id for kb-mcp, registered in your tenant's existing Zitadel instance

  • Network access from kb-mcp's pod to the Unique API instance it calls

Helm Chart

kb-mcp ships as a standalone Helm chart, published as an OCI artifact alongside every release:

bash
helm upgrade --install kb-mcp oci://ghcr.io/unique-ag/connectors/helm/kb-mcp \
  --version <version> \
  --namespace <namespace> --create-namespace \
  --values values.yaml

The chart lives in Unique-AG/connectors under services/kb-mcp/deploy/helm-charts/kb-mcp. Unique's own deployments reference it from ArgoCD by targetRevision: kb-mcp@<version>.

Required Secrets

Two values, generated with openssl rand -hex 32, neither with a chart default:

Secret

Purpose

ZITADEL_JWT_SIGNING_KEY

Signs kb-mcp's own downstream OAuth-proxy JWTs; never sent to Zitadel

ENCRYPTION_KEY

Encrypts OAuth-proxy state at rest in Postgres

Deliver them through envVars[].valueFrom.secretKeyRef, or, at Unique, through ExternalSecrets via extraEnvSecrets.

You'll also need mcpConfig.zitadel.clientId, the public (PKCE) OIDC client id for kb-mcp, registered in Zitadel with redirect URI {publicBaseUrl}/auth/callback. It is public, so it sits in plain chart values; see Configuration for the full required-variable reference.

Minimal Values

yaml
mcpConfig:
  enabled: true
  app:
    publicBaseUrl: https://kb-mcp.<tenant>.unique.app   # must match routes.hostname
  zitadel:
    baseUrl: https://id.<tenant>.example.com
    clientId: <public PKCE client id>   # PKCE, not secret

envVars:
  - name: UNIQUE_API_BASE_URL
    value: http://unique-api.<namespace>.svc.cluster.local
  - name: ZITADEL_JWT_SIGNING_KEY
    valueFrom:
      secretKeyRef: { name: kb-mcp-secrets, key: zitadel-jwt-signing-key }
  - name: ENCRYPTION_KEY
    valueFrom:
      secretKeyRef: { name: kb-mcp-secrets, key: encryption-key }

routes:
  hostname: kb-mcp.<tenant>.unique.app
  pathPrefix: /   # chart default is /kb-mcp; this keeps the URL at the bare /mcp this guide uses
  auth:
    jwt: false   # kb-mcp runs its own OIDC, don't double-gate at the gateway
  paths:
    default: { enabled: true }
    probe: { enabled: true }

DATABASE_URL isn't listed here: postgresql.enabled: true is the chart default, but postgresql.connection is required either way and ships empty, so it's never actually minimal. See PostgreSQL below for both paths (bring your own CloudNativePG Cluster, or an external instance).

PostgreSQL

Postgres holds OAuth-proxy state only: client registrations, tokens, and JTI mappings. It never holds knowledge-base data. postgresql.enabled: true is the chart default, and postgresql.connection is required whenever it's on, whichever Postgres you use: the chart neither creates nor owns one.

In-cluster: bring your own CloudNativePG Cluster and point at it:

yaml
postgresql:
  enabled: true
  connection:
    host:
      fromKubernetesService:
        name: <release-name>-pg-cluster-rw
        namespace: <namespace>
        podPort: 5432
        podSelectorLabels:
          cnpg.io/cluster: <release-name>-pg-cluster
    url:
      fromSecret:
        name: <release-name>-pg-cluster-app
        key: fqdn-uri

host and url do different jobs here: host only shapes the network-policy egress rule (same toEndpoints mechanism as internalServices.dependencies, see Network Policies below), while url is the actual DSN, generated by your Cluster into its own secret, and is what becomes DATABASE_URL.

External: point at a managed instance instead by supplying DATABASE_URL as a secret env var and setting postgresql.enabled: false, bypassing the postgresql.connection contract entirely.

Ephemeral storage is local-development only

ALLOW_EPHEMERAL_OAUTH_STORAGE=true skips Postgres entirely using a per-process file store. State does not survive a restart and is not shared across replicas, so every user is logged out on each pod restart.

Network Policies

networkPolicy.enabled defaults to false (flavor: cilium); set it to true for a default-deny CiliumNetworkPolicy. kb-mcp needs egress to DNS, the Unique API, Zitadel and the platform gateway, and its Postgres host if external. The chart's baseline only allows toFQDNs: ["*.unique.app"] on 443, so an in-cluster Unique API address (the UNIQUE_API_BASE_URL in Minimal Values) is not covered: add its egress rule in your overlay, under networkPolicy.egress[] or as an internalServices.dependencies.<key> entry, which builds the rule from servicePort and podPort. The chart does not emit UNIQUE_API_BASE_URL from it, so keep setting that under envVars.

Cilium's eBPF Service DNAT happens before a CiliumNetworkPolicy's toEndpoints rule is evaluated, so the rule must allow the container's actual listening port. When the Service port and the container port differ, set podPort to the container port.

The allowlist is bidirectional

Egress from kb-mcp is only half of it. The Unique API must also allow kb-mcp in its own ingress policy. Otherwise it drops the connection silently, with no application error, just a timeout.

Health Checks

GET /probe serves all three probes (liveness, readiness, startup).

Monitoring

  • Logs: pino-json on stderr, scraped by Loki via the logging.unique.app/format=pino-json pod label the chart sets by default. Access-log lines for /probe, /health, and /metrics are silenced.

  • Metrics/tracing: opt-in via OpenTelemetry; unset in the chart defaults.

Troubleshooting

  • Startup hangs under a default-deny network policy: FastMCP pings pypi.org on startup. Set FASTMCP_CHECK_FOR_UPDATES: "off".

  • Tool calls hang or time out: there is probably no egress rule for the Unique API, or it targets the Service port instead of the pod port. See Network Policies.

  • Server refuses to boot: kb-mcp requires both DATABASE_URL and ENCRYPTION_KEY unless ALLOW_EPHEMERAL_OAUTH_STORAGE=true.

Last updated