Skip to content

Deployment

Kelly Ferrone edited this page Sep 21, 2026 · 5 revisions

Deployment

Running the server. For pointing a client at one, see Installing; for a first run on your own machine, Quick start.

The image is the whole artifact. It bakes no skills: give it a config file and a writable cache directory and it serves whatever that config names. It ships the example config, so it runs with no config at all; what goes in your own is The config file.

Flags and environment

Every flag has an environment fallback, because a container is configured with environment variables and a developer reaches for flags. These say how to run; the config file says what to serve, and the two never overlap.

Env Flag Default Notes
CONFIG --config /etc/mcp-kb/config.yaml The config file listing the sources to serve
CACHE_DIR --cache-dir /var/cache/mcp-kb Where a non-file:// source materialises, and where index.json lives
TRANSPORT --transport http http or stdio
HOST --host 0.0.0.0 Bind address, http only
PORT --port 8000 Port, http only
LOG_LEVEL --log-level INFO The least severe line written, by every logger in the process. A fetch, plugin or library changing state is INFO, WARNING (stale, skipped) or ERROR (failed). Traffic is held back to DEBUG: a request line per MCP call from uvicorn, and a line per WebDAV request from httpx

A source's credentials are the other half of the environment: each is an {env: NAME} reference on the source, resolved from the process environment at the moment of the call. A plugin never carries one. Nothing else in the process reads the environment.

mcp-kb schema prints the config JSON Schema and exits, which is what config.schema.json in the repository holds.

A bad config file is fatal at boot, on purpose: exit code 2 and the reason on stderr. So is a server that refuses its credentials while a fetch is first built — a 401 or a 403 from WebDAV, an authentication failure from git: exit code 3, naming the fetch and the status. That failure is the one a wait does not fix — a wrong app password, an account not yet let in — and a pod that exits is restarted where everyone can see it, until it is fixed.

A config that parses but names something that cannot be reached is not fatal — that fetch is reported failed and everything else is served — and nor is a refusal on a later refresh of a fetch already serving, which goes stale. See Operations.

Docker

docker run --name mcp-kb -d -p 127.0.0.1:8000:8000 \
  --mount type=volume,source=mcp-kb-cache,target=/var/cache/mcp-kb \
  --mount "type=bind,source=$PWD/config.yaml,target=/etc/mcp-kb/config.yaml,readonly" \
  -e GITHUB_TOKEN \
  kubed/mcp-kb:latest

A cache volume and your config, and that is the deployment. Leave the config mount off and the image serves its built-in example.

Use --mount, not -v, for the config: -v creates a missing source as a directory, and the container then fails with "not a directory". --mount refuses a missing file up front. The config is read-only; the cache is not, and wants to be a named volume rather than a bind mount — a named volume inherits the image's ownership of /var/cache/mcp-kb and needs nothing further, where a host directory arrives owned by whoever made it.

127.0.0.1 keeps the port on this machine. The server has no auth, so publish it wider only behind something that has.

The container runs as uid 65534. If the cache is not writable by it the server still serves — the catalogue is built in memory either way — but it logs that it cannot write index.json and re-harvests everything on every restart.

-e GITHUB_TOKEN passes a variable through for an {env: GITHUB_TOKEN} reference in the config. Never put a credential in a source URL; the server refuses one that carries it.

Kubernetes

There are no manifests in this repository — deployment is the installer's — but five details are worth copying rather than rediscovering.

The config, and prompts, come from ConfigMaps. Mount the config at /etc/mcp-kb/config.yaml and, if you ship prompts or skills with the deployment rather than from a remote, mount them somewhere and declare a plugin at file:///that/path. A ConfigMap mount is entirely symlinks — every key is key → ..data/key → ..<timestamp>/key — and that is fine: a file:// plugin follows a link that lands inside its own root, and the path a client sees is the one the config asked for, not the timestamped directory behind it.

plugins:
- name: grafana-prompts
  source: file:///srv/prompts/grafana
  prompts: ["*.md"]

The cache is an emptyDir, and the pod needs fsGroup.

spec:
  securityContext:
    fsGroup: 65534
    runAsNonRoot: true
  containers:
  - name: mcp-kb
    volumeMounts:
    - name: cache
      mountPath: /var/cache/mcp-kb
  volumes:
  - name: cache
    emptyDir: {}

An emptyDir or a PVC mounts root-owned. The image runs as 65534, so without fsGroup: 65534 the cache directory is unwritable, index.json is never written, and every restart re-clones everything instead of reading last boot's answer back in milliseconds.

Use a startupProbe, not a tight readinessProbe. The first boot with an empty cache clones every git repository and copies every WebDAV folder before the server binds its port. How long that takes is a property of what you have named, not of this server, and a readiness probe sized for the steady state will restart the pod in the middle of it.

startupProbe:
  httpGet: { path: /health, port: 8000 }
  periodSeconds: 5
  failureThreshold: 60
readinessProbe:
  httpGet: { path: /health, port: 8000 }
livenessProbe:
  httpGet: { path: /health, port: 8000 }

Do not tie liveness to what loaded. /health answers 200 whenever the process is serving, including when a fetch failed to load — the failure is reported inside fetches and on every plugin that reads it, and everything that did load is still being served. That is the design: one unreachable remote must not take a working catalogue down. A probe that parses the body and fails on a failed fetch converts a partial outage into a restart loop, and a restart does not fix an unreachable remote. Alert on the body; probe on the status code. The one restart that is worth it — refused credentials on a first build — the process already does by exiting.

Credentials are Secrets, referenced by name. The config file holds {env: NEXTCLOUD_PASSWORD}, never a value, so it is safe to keep in a ConfigMap and in git:

env:
- name: NEXTCLOUD_PASSWORD
  valueFrom:
    secretKeyRef: { name: mcp-kb, key: nextcloud-password }

An env/secretKeyRef value is fixed when the pod starts, so rotating the Secret needs a kubectl rollout restart.

stdio

CONFIG=./config.yaml CACHE_DIR=./.cache TRANSPORT=stdio mcp-kb

One process, one client, over pipes — the client launches the server. There is no URL, so there are no query parameters and no headers: nothing is scoped and every MCP feature is assumed present, which is right for the clients that launch a server this way. /health, /reindex and /openapi.yaml are HTTP routes and do not exist in this mode.


Home · Installing · Sources · Plugins · Operations

Clone this wiki locally