HTTP and Docker deployment

How to run OpenZIM MCP as a long-running networked service. Covers the streamable HTTP transport, the published Docker image, operational concerns (TLS, reverse proxy, health probes, systemd), and end-to-end deployment recipes for LAN, Tailscale tailnet, and public VPS topologies.

Notation: examples on this page use JSON-RPC tool-call framing ({"name": "...", "arguments": {...}}) for protocol-level snippets and shell snippets for environment / HTTP commands. Tool surfaces referenced match the 8-tool advanced surface.

Source of truth: openzim_mcp/http_app.py and the Dockerfile.

If you only want to use OpenZIM MCP locally with Claude Desktop, Cursor, or another MCP client launching it as a subprocess, stay on the Quick start — that path uses stdio transport and doesn’t need any of this.

When to use HTTP vs stdio#

TransportUse case
stdio (default)Local desktop MCP hosts (Claude Desktop, Inspector, MCP-aware editors). The host owns the process lifetime.
http (streamable HTTP)Long-running service. Multiple clients, possibly across the network. Bearer-token auth, CORS, health probes.
sse (legacy, deprecated)Older clients that haven’t migrated to streamable HTTP. Loopback only — no auth middleware. Deprecated and removed in 4.0.0 — the server logs a deprecation warning on every SSE start; migrate to http.

The rest of this page assumes --transport http.

Quick start (no Docker)#

export OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
openzim-mcp --transport http --host 127.0.0.1 --port 8000 /srv/zim

This binds the loopback interface only. Put a TLS-terminating reverse proxy in front for external access. The server refuses to bind a non-loopback host without an auth token as a safe default.

Quick start (Docker)#

The image defaults to stdio transport (so docker run -i is a local MCP server — see Installation). Opt into the HTTP service explicitly:

docker run --rm -p 8000:8000 \
  -v /srv/zim:/data:ro \
  -e OPENZIM_MCP_TRANSPORT=http \
  -e OPENZIM_MCP_HOST=0.0.0.0 \
  -e OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)" \
  ghcr.io/cameronrye/openzim-mcp

The published image:

  • Multi-arch: linux/amd64, linux/arm64
  • Non-root: runs as appuser (uid 10001, gid 10001)
  • Default transport: stdio — set OPENZIM_MCP_TRANSPORT=http and OPENZIM_MCP_HOST=0.0.0.0 (as above) to run the HTTP service
  • No baked-in healthcheck (the image ships no curl); define one in your orchestrator/compose probing GET /readyz — see the compose example below
  • Entrypoint: python -m openzim_mcp /data (mount your ZIM directory at /data)

Binding a non-loopback host (0.0.0.0) requires OPENZIM_MCP_AUTH_TOKEN as a safe default; the bind is refused without it.

For an end-to-end deployment rather than a one-off docker run — Compose files, TLS, client wiring — see Deployment patterns at the foot of this page.

Tool surface. The image sets OPENZIM_MCP_TOOL_MODE=advanced, so it registers all 8 tools. That matches the .mcpb bundle and the MCP Registry entry, and it is what directory listings show. The code default elsewhere (a bare uvx openzim-mcp) is still simple, the single zim_query entry point. Add -e OPENZIM_MCP_TOOL_MODE=simple to get that narrower surface in a container.

Upgrading an existing HTTP deployment? Earlier images defaulted the container to HTTP, so a token-only docker run -p 8000:8000 -e OPENZIM_MCP_AUTH_TOKEN=… <image> was enough. The image now defaults to stdio, so that same command starts a stdio server instead — nothing listens on :8000. Add -e OPENZIM_MCP_TRANSPORT=http -e OPENZIM_MCP_HOST=0.0.0.0 (as shown above) when you pull the new image.

Authentication#

Bearer-token auth via BearerTokenAuthMiddleware:

  • Set OPENZIM_MCP_AUTH_TOKEN (env only — never put it in a config file).
  • Stored as a pydantic SecretStr — value never appears in repr(), logs, or the configuration view of zim_health.
  • Comparison is timing-safe (hmac.compare_digest).
  • The attempted token is never logged.
  • /healthz and /readyz are exempt.
  • OPTIONS /mcp is not exempt from auth — there is no blanket OPTIONS bypass, so a bare OPTIONS (no preflight headers) gets the same 401 as any other tokenless request and non-browser callers cannot probe the endpoint without a token. The one carve-out is by design: when OPENZIM_MCP_CORS_ORIGINS is set, a CORS preflight — an OPTIONS carrying Origin and Access-Control-Request-Method from a listed origin — is answered 200 by the outermost CORS layer before auth runs, so it is served without a token. Browsers never attach Authorization to a preflight; the real request that follows is still authenticated.

Client request format:

POST /mcp HTTP/1.1
Host: openzim-mcp.example
Authorization: Bearer <YOUR_TOKEN>
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list

{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

2026-07-28 requests are stateless: there is no initialize handshake, no Mcp-Session-Id (the server issues none), and Mcp-Method must name the same method as the body — tools/call additionally needs Mcp-Name matching the tool name. A client that sends no MCP-Protocol-Version, or one naming a 2025 revision, gets the handshake-era path instead: initialize first, then a server-issued Mcp-Session-Id echoed on every later request.

Failures return 401 {"error":"unauthorized"} with WWW-Authenticate: Bearer.

Rotate by setting a new value and restarting the container; there is no in-flight rotation.

CORS#

For browser clients, set OPENZIM_MCP_CORS_ORIGINS to an explicit JSON list:

export OPENZIM_MCP_CORS_ORIGINS='["https://app.example.com","https://other.example.com"]'
  • Wildcard "*" is rejected at startup — including whitespace-padded variants like " * ". There is no opt-out. Wildcard CORS combined with bearer-token auth is the canonical recipe for token theft via a malicious site.
  • The allowed headers cover both protocol eras the endpoint serves. Mcp-Session-Id and Last-Event-ID are there for browser clients still on the initialize handshake, which hold a session and resume dropped streams; Mcp-Method and Mcp-Name are the headers a 2026-07-28 client must send on every POST. Mcp-Session-Id is also in expose_headers so a handshake-era browser client can read the id the server issued.
  • CORS is the outer middleware layer (LIFO add order) so a 401 from auth still carries Access-Control-Allow-Origin headers — browsers see “401 unauthorized” instead of an opaque CORS error.
  • Allowed methods: GET, POST, OPTIONS, DELETE (DELETE is the handshake-era session-termination method; a 2026-07-28 client only POSTs).

If no browser client connects (you’re hitting the endpoint from another server, a CLI client, or a desktop MCP client), leave this unset.

Public hostname allow-list#

Required when openzim-mcp sits behind a reverse proxy (Caddy, nginx) or Tailscale serve and the public hostname differs from the bind interface. The MCP SDK applies DNS rebinding protection by validating the Host header against an allow-list — loopback values (127.0.0.1, localhost, [::1]) are always permitted, but anything else needs to be listed explicitly:

Host validation is off on a bind-all host with no allow-list. If OPENZIM_MCP_HOST is 0.0.0.0, :: or [::] and OPENZIM_MCP_ALLOWED_HOSTS is empty, the server disables DNS-rebinding Host validation entirely and logs a warning saying so — because the client-facing Host varies by reachable IP, so any allow-list would reject every request with 421. In that configuration the bearer token is the only access control. Setting OPENZIM_MCP_ALLOWED_HOSTS re-enables validation. A bind to a specific non-loopback interface keeps validation on and auto-allows that address.

export OPENZIM_MCP_ALLOWED_HOSTS='["mcp.example.com"]'

Symptom of a missing entry: requests proxied to openzim-mcp return 421 Misdirected Request with body Invalid Host header, and the server log shows Invalid Host header: <hostname>.

Entries can include the :* wildcard-port suffix (mcp.example.com:*) when the proxy preserves a non-default port in the Host header. The wildcard * alone is rejected at startup — the whole point of the allow-list is DNS rebinding protection.

Safe-default startup matrix#

check_safe_startup() refuses to start in two cases:

TransportHostTokenResult
httploopback (127.0.0.1/::1/resolved localhost)unsetOK
httploopbacksetOK
httpnon-loopbackunsetREFUSE — unless OPENZIM_MCP_INSECURE_DISABLE_AUTH=1, which starts anyway with an INSECURE: warning
httpnon-loopbacksetOK
sseloopback(any)OK
ssenon-loopback(any)REFUSE (no auth middleware in SSE path; INSECURE_DISABLE_AUTH does not apply)

If host=localhost and /etc/hosts maps localhost away from 127.0.0.1, the server emits a UserWarning and treats the host as public — which then triggers the safe-default refusal.

Health endpoints#

EndpointPurposeAuthResponse
/healthzLiveness — process is up, event loop responsiveexempt200 {"status":"ok"}
/readyzReadiness — at least one allowed dir is readableexempt200 {"status":"ready"} or 503 {"status":"not_ready","reason":"no readable allowed directories"}

Both endpoints are CORS-friendly and safe to wire into Kubernetes probes, Docker HEALTHCHECK, systemd WatchdogSec, or external uptime monitors.

curl -fsS http://localhost:8000/healthz
# {"status":"ok"}

curl -fsS http://localhost:8000/readyz
# {"status":"ready"}

Resource subscriptions over HTTP#

The polling watcher (MtimeWatcher) is started via a wrapped lifespan handler so it works under streamable HTTP (the SDK’s streamable_http_app() supplies its own lifespan; add_event_handler('startup', …) is silently a no-op).

  • Clients opt in with one long-lived subscriptions/listen request. A .zim appearing or disappearing publishes notifications/resources/list_changed; one replaced in place publishes notifications/resources/updated for zim://{name}.
  • OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false skips the watcher entirely and withholds the capability: resources.subscribe and the listChanged flags read false, and subscriptions/listen fails with method-not-found rather than acknowledging a stream nothing would ever publish to.
  • OPENZIM_MCP_WATCH_INTERVAL_SECONDS (default 5, range 1–60) controls poll cadence.
  • Delivery is the SDK’s: each listen stream is buffered independently, concurrent streams and per-stream backlog are both capped, and a stream whose client stops reading is ended. The server adds its own admission bound before a stream is acknowledged — at most 256 subscription URIs per stream, each at most 2048 characters, rejected with INVALID_PARAMS. There is no replay — a client that loses its stream re-listens and re-reads.

See Resources, prompts and subscriptions for client-side examples.

Reverse proxy / TLS#

There is no built-in TLS. Terminate at a reverse proxy.

Caddy#

openzim-mcp.example.com {
    reverse_proxy 127.0.0.1:8000
}

Caddy auto-provisions Let’s Encrypt certs and forwards Authorization, Mcp-Session-Id, and Content-Type by default.

nginx#

server {
    listen 443 ssl http2;
    server_name openzim-mcp.example.com;
    ssl_certificate     /etc/letsencrypt/live/openzim-mcp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/openzim-mcp.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Mcp-Session-Id;
        proxy_buffering off;       # Streamable HTTP is long-poll friendly
        proxy_read_timeout 300s;
    }
}

nginx will not auto-provision certs, so pair it with Certbot or a similar tool.

Traefik#

http:
  routers:
    openzim-mcp:
      rule: "Host(`openzim-mcp.example.com`)"
      entryPoints: [websecure]
      tls:
        certResolver: letsencrypt
      service: openzim-mcp
  services:
    openzim-mcp:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:8000"

systemd unit#

[Unit]
Description=OpenZIM MCP
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=openzim-mcp
Group=openzim-mcp
Environment=OPENZIM_MCP_TRANSPORT=http
Environment=OPENZIM_MCP_HOST=127.0.0.1
Environment=OPENZIM_MCP_PORT=8000
Environment=OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true
Environment=OPENZIM_MCP_CACHE__PERSISTENCE_PATH=/var/cache/openzim-mcp/cache.json
EnvironmentFile=/etc/openzim-mcp/secrets.env   # OPENZIM_MCP_AUTH_TOKEN, etc.
ExecStart=/usr/local/bin/openzim-mcp /srv/zim
Restart=on-failure
RestartSec=5

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadOnlyPaths=/srv/zim
CacheDirectory=openzim-mcp
ReadWritePaths=/var/cache/openzim-mcp

[Install]
WantedBy=multi-user.target

Kubernetes example#

apiVersion: apps/v1
kind: Deployment
metadata:
  name: openzim-mcp
spec:
  replicas: 2
  selector:
    matchLabels: { app: openzim-mcp }
  template:
    metadata:
      labels: { app: openzim-mcp }
    spec:
      containers:
        - name: openzim-mcp
          image: ghcr.io/cameronrye/openzim-mcp:3.3.1 # x-release-please-version
          ports:
            - containerPort: 8000
          env:
            - name: OPENZIM_MCP_TRANSPORT
              value: "http"          # the image defaults to stdio
            - name: OPENZIM_MCP_HOST
              value: "0.0.0.0"
            - name: OPENZIM_MCP_AUTH_TOKEN
              valueFrom:
                secretKeyRef:
                  name: openzim-mcp-auth
                  key: token
            - name: OPENZIM_MCP_CACHE__MAX_SIZE
              value: "500"
          volumeMounts:
            - name: zim-data
              mountPath: /data
              readOnly: true
          livenessProbe:
            httpGet: { path: /healthz, port: 8000 }
            periodSeconds: 30
          readinessProbe:
            httpGet: { path: /readyz, port: 8000 }
            periodSeconds: 10
          resources:
            requests: { memory: "256Mi", cpu: "100m" }
            limits:   { memory: "1Gi",   cpu: "1000m" }
      volumes:
        - name: zim-data
          persistentVolumeClaim:
            claimName: zim-archives

Each replica has its own in-memory cache. Persistent cache on shared storage is not recommended unless you handle concurrent-writer issues at the storage layer (the on-disk cache uses simple file rewrites).

Clients reaching the pods through a Service/Ingress hostname should have that hostname in OPENZIM_MCP_ALLOWED_HOSTS. Note what the Deployment above does without it: because it binds 0.0.0.0 with an empty allow-list, Host validation is switched off and the bearer token is the only control — add the hostname to turn DNS-rebinding protection back on.

Scaling considerations#

Horizontal scaling works — add replicas and front them with any L7 load balancer that supports HTTP/1.1 keep-alive and forwards Authorization unchanged. A 2026-07-28 client is stateless, so any replica can answer any request; a long-lived subscriptions/listen stream is naturally pinned to the replica holding it, and each replica runs its own watcher over the same directories, so it publishes to its own listeners.

Handshake-era clients still need session-stickiness: their session state lives in the process that issued it, and a replica that doesn’t recognize the presented Mcp-Session-Id returns 404 Session not found. If any of your clients still use initialize, forward Mcp-Session-Id unchanged and use cookie- or header-based affinity (e.g. hash on Mcp-Session-Id), or run one replica.

Hardening checklist#

  • Bind to a specific interface, not 0.0.0.0, unless behind a reverse proxy that already restricts ingress.
  • Set OPENZIM_MCP_AUTH_TOKEN to a high-entropy value (openssl rand -hex 32).
  • Set OPENZIM_MCP_CORS_ORIGINS to the explicit list of allowed origins (never *).
  • Terminate TLS at a reverse proxy.
  • Run as a non-root user (Docker image already does this).
  • Mount ZIM directories read-only.
  • Tune OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND for your client load.
  • Wire /healthz and /readyz into your platform’s health-check tooling.
  • Subscribe alerting to repo Security Advisories.

For a full security review of the model behind these recommendations, see Security best practices.

Troubleshooting#

Common failure modes:

  • Container exits immediately on start. Almost always one of three startup checks: HTTP transport bound to a non-loopback host without OPENZIM_MCP_AUTH_TOKEN, or OPENZIM_MCP_CORS_ORIGINS / OPENZIM_MCP_ALLOWED_HOSTS containing an entry that is exactly * (a :* port suffix such as mcp.example.com:* is fine — only the bare wildcard is rejected). The startup error message identifies which.
  • Clients get 421 Misdirected Request / Invalid Host header. The proxied Host header (e.g. zim.example.com) isn’t in OPENZIM_MCP_ALLOWED_HOSTS. Add it. Loopback (127.0.0.1, localhost, [::1]) is always allowed without configuration; everything else needs an explicit entry.
  • Clients get 401 Unauthorized. Token mismatch. Verify with curl -H "Authorization: Bearer $TOKEN" http://host/mcp/.... Don’t paste the token into chat or tickets.
  • Browser clients get 403 / CORS errors. Either OPENZIM_MCP_CORS_ORIGINS is unset (and a browser is calling) or the client’s origin isn’t in the allow-list. The browser console shows the offending origin; add it.
  • /readyz returns 503. None of the configured ZIM directories are readable from inside the container. (With multiple allowed directories, one bad mount is fine — readiness flips only when all of them fail.) Check the volume mount path (host side and /data side) and that the mount isn’t empty. Permissions: the in-container user is UID 10001; the host directory needs to be world-readable or owned by UID 10001.
  • Subscriptions stop firing. Confirm OPENZIM_MCP_SUBSCRIPTIONS_ENABLED isn’t set to false — a disabled server refuses subscriptions/listen with method-not-found, so that misconfiguration is loud. If the listen was acknowledged but nothing arrives, check the acknowledgement’s echoed filter: a client that asked for zim://files under resourceSubscriptions gets an ack and silence, because directory-membership changes are published as notifications/resources/list_changed (opt in with resourcesListChanged).

Deployment patterns#

The reference above is the surface area. The recipes below are end-to-end deployments for the three topologies most operators land on: LAN host, Tailscale tailnet, and public VPS with TLS.

Recipe 1: Docker Compose on a LAN host#

This recipe puts OpenZIM MCP on a single host, reachable from the rest of your LAN over plain HTTP plus a bearer token. There’s no TLS — the bearer token is the only thing protecting the endpoint, so this recipe is appropriate for trusted networks (a home LAN, a Tailscale tailnet) and not for anything reachable from the public internet.

Prerequisites#

  • Docker and Docker Compose v2 installed on the host
  • A directory of .zim files (download from the Kiwix Library)
  • A generated bearer token: openssl rand -hex 32

docker-compose.yml#

services:
  openzim-mcp:
    image: ghcr.io/cameronrye/openzim-mcp:3.3.1 # x-release-please-version
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"
    volumes:
      - /srv/zim:/data:ro
      - openzim-cache:/home/appuser/.cache/openzim-mcp
    environment:
      OPENZIM_MCP_TRANSPORT: "http"
      OPENZIM_MCP_HOST: "0.0.0.0"
      OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
      OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED: "true"
      # Explicit path so the cache file lands inside the mounted volume
      # (the default path gets a config-fingerprint suffix *next to* it).
      OPENZIM_MCP_CACHE__PERSISTENCE_PATH: "/home/appuser/.cache/openzim-mcp/cache.json"
    healthcheck:
      # The image ships no curl; probe /readyz with the bundled Python.
      test:
        - "CMD"
        - "python"
        - "-c"
        - "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/readyz').getcode()==200 else 1)"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  openzim-cache:

Still running v1.x? The ghcr.io/cameronrye/openzim-mcp:1.3.0 image (the final v1.x release) stays available, but the v1.x maintenance window closed when v2.5.0 shipped (2026-06-18) — it receives no further fixes. The v2.0.0 advanced tool surface is a breaking rename per CHANGELOG — migrate when convenient.

What’s intentional here:

  • Pinned tag (:3.3.1, not :latest). Upgrades are deliberate — docker compose pull is a thing you do, not a thing that happens to you.
  • Host port bound to 127.0.0.1. The container listens on all interfaces (OPENZIM_MCP_HOST=0.0.0.0, set in the environment block), but Docker’s port publish restricts external exposure to loopback. The Tailscale variant below lifts this restriction by binding to a specific interface.
  • Read-only ZIM mount (:ro). The server only reads ZIM files; mounting read-only ensures a server compromise can’t damage them.
  • Token via env, not hard-coded. Put it in a .env file next to the compose file (and add .env to .gitignore). The image defaults to stdio, so TRANSPORT=http and HOST=0.0.0.0 are set explicitly in the environment block to select the HTTP service.
  • Explicit healthcheck block. The image ships no HEALTHCHECK (it defaults to stdio, where there is no HTTP endpoint to probe), so this block defines one — using the bundled Python rather than curl, which isn’t installed.

/srv/zim is an example. Adjust to wherever your ZIM files live.

Start it#

echo "OPENZIM_MCP_AUTH_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d

Verify#

# Liveness
curl -sf http://127.0.0.1:8000/healthz && echo " healthz ok"

# Readiness (will fail if /srv/zim is empty or unreadable)
curl -sf http://127.0.0.1:8000/readyz && echo " readyz ok"

# Authed RPC call: list available tools (stateless 2026-07-28 framing —
# a bare POST with no MCP-Protocol-Version header is routed down the
# legacy handshake path and rejected with "Missing session ID", which
# `curl -sf` swallows silently)
TOKEN=$(grep OPENZIM_MCP_AUTH_TOKEN .env | cut -d= -f2)
curl -sf -X POST http://127.0.0.1:8000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
  | head -c 500

If tools/list returns a JSON envelope with a result.tools array, you’re up.

Connect a client#

Add OpenZIM MCP to your client’s MCP config. Substitute the host, port, and token. Cursor supports remote HTTP MCP servers natively; Claude Desktop currently does not — it needs the mcp-remote bridge, which translates Claude Desktop’s stdio expectations into HTTP calls.

Cursor (~/.cursor/mcp.json for global, or .cursor/mcp.json for project-local):

{
  "mcpServers": {
    "openzim-mcp": {
      "url": "http://127.0.0.1:8000/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN_HERE"
      }
    }
  }
}

Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows). Requires Node.js on the client host (mcp-remote runs via npx):

{
  "mcpServers": {
    "openzim-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote@^0.1.16",
        "http://127.0.0.1:8000/mcp",
        "--header",
        "Authorization:Bearer YOUR_TOKEN_HERE"
      ]
    }
  }
}

The Authorization:Bearer form (no space after the colon) matches mcp-remote’s documented header syntax. The token lives directly in the config file and is visible in ps output on the client machine while mcp-remote is running — treat the file as a secret: don’t commit claude_desktop_config.json to a shared repo and keep its filesystem permissions tight (chmod 600 on Unix).

Tailscale variant#

To reach the server from your tailnet instead of the LAN, change two lines in docker-compose.yml:

    ports:
      - "100.x.y.z:8000:8000"  # your Tailscale IPv4, from `tailscale ip -4`
    environment:
      OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
      OPENZIM_MCP_ALLOWED_HOSTS: '["<device>.<tailnet>.ts.net"]'

Then make sure the host firewall blocks port 8000 on the public interface (it should already, since you’re not publishing on 0.0.0.0). The bearer token plus tailnet ACLs are your trust boundary; you don’t need TLS because the tailnet itself is encrypted.

OPENZIM_MCP_ALLOWED_HOSTS is required because Tailscale serve and similar reverse proxies preserve the original Host header (the MagicDNS name), which the SDK’s default DNS-rebinding allow-list rejects. List the MagicDNS hostname your clients connect to. If you connect by raw Tailscale IP rather than MagicDNS, add 100.x.y.z to the list instead.

Recipe 2: VPS with Caddy and automatic TLS#

This recipe puts OpenZIM MCP on a public VPS, fronted by Caddy for automatic Let’s Encrypt TLS. The bearer token is still the auth boundary; TLS prevents a network observer from stealing it in transit.

Prerequisites#

  • A VPS with a public IPv4 (and optionally IPv6)
  • A domain name with an A record (and optional AAAA) pointing at the VPS
  • Ports 80 and 443 open on the VPS firewall (Caddy needs both for the HTTP-01 ACME challenge and TLS service)
  • Same bearer token and ZIM directory as Recipe 1

docker-compose.yml#

This is the LAN compose file with three changes: a caddy service is added, the openzim-mcp service no longer publishes a host port (it’s reachable only on the internal Docker network), and OPENZIM_MCP_ALLOWED_HOSTS admits the public hostname Caddy forwards.

services:
  openzim-mcp:
    image: ghcr.io/cameronrye/openzim-mcp:3.3.1 # x-release-please-version
    restart: unless-stopped
    expose:
      - "8000"
    volumes:
      - /srv/zim:/data:ro
    environment:
      OPENZIM_MCP_TRANSPORT: "http"
      OPENZIM_MCP_HOST: "0.0.0.0"
      OPENZIM_MCP_AUTH_TOKEN: "${OPENZIM_MCP_AUTH_TOKEN}"
      OPENZIM_MCP_ALLOWED_HOSTS: '["zim.example.com"]'

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"  # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - openzim-mcp

volumes:
  caddy_data:
  caddy_config:

Caddyfile#

zim.example.com {
    reverse_proxy openzim-mcp:8000
}

That’s it. Caddy provisions a certificate on first request (and renews automatically), terminates TLS, and forwards the Authorization header to OpenZIM MCP unchanged. Replace zim.example.com with your hostname.

nginx alternative: if you already run nginx and prefer it to Caddy, the only requirements are reverse-proxying / to openzim-mcp:8000 and forwarding the Authorization header unchanged. See the nginx snippet above; pair it with Certbot or a similar tool for certificate provisioning.

Start it#

echo "OPENZIM_MCP_AUTH_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d

# Watch Caddy obtain its certificate (first start only)
docker compose logs -f caddy

The first request triggers ACME issuance; subsequent restarts reuse the cached cert from the caddy_data volume.

Verify#

# TLS chain
curl -vsf https://zim.example.com/healthz 2>&1 | grep -E "subject|issuer|HTTP/"

# Authed RPC call (stateless 2026-07-28 framing; see Recipe 1's Verify note)
TOKEN=$(grep OPENZIM_MCP_AUTH_TOKEN .env | cut -d= -f2)
curl -sf -X POST https://zim.example.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
  | head -c 500

Production hardening checklist (per-recipe)#

  • Token rotation. Generate a new token, update .env, run docker compose up -d to pick up the change. Distribute the new token to clients out-of-band.
  • Non-root. The published image already runs as appuser (UID 10001). Confirm with docker compose exec openzim-mcp id — output should be uid=10001(appuser).
  • Log review. docker compose logs -f openzim-mcp shows auth failures (the attempted token is not logged, but the request and outcome are). A burst of 401s from one client usually means a stale token.
  • Read-only ZIM mount. Already in the compose (:ro). Don’t remove it.

Operations#

Upgrades#

# Pull the new image, then recreate containers using it
docker compose pull
docker compose up -d

The image tag in docker-compose.yml is pinned (:3.3.1) — change it to the new version before pulling. Pinning is deliberate: :latest makes upgrades a surprise, and the release notes for each tag tell you when an upgrade involves a breaking change. The v1.x → v2.0.0 jump is a breaking rename of the advanced-mode tool surface (22 tools → 8). The v2.x → 3.0.0 jump breaks exactly the population this page serves: resources/subscribe/unsubscribe are no longer served (a legacy client gets -32601 and loses change notifications — live updates now mean negotiating 2026-07-28 and calling subscriptions/listen; see Resources, prompts & subscriptions), and link-graph sidecars built by 2.x are rejected on load — rebuild each with openzim-mcp build link-graph --force <archive>.zim, or zim_links(direction="inbound") degrades to inbound_sidecar_unavailable. See the CHANGELOG before bumping.

Watching logs#

docker compose logs -f openzim-mcp

A clean startup logs the bind host/port and the configured allowed directory. An auth failure logs the route, status, and client IP — not the attempted token. Subscription updates are delivered silently; the SDK logs a warning when it ends a listen stream whose client stopped reading and overran its event backlog.


Configuration reference: Configuration. Security model: Security best practices. Performance tuning: Performance optimization.

The v1.x maintenance window closed when v2.5.0 shipped (2026-06-18); only the current major line is supported — see SECURITY.md for the policy. The CHANGELOG carries the v1 → v2 migration table and the v3.0.0 breaking-changes entry.

Documentation for v3.3.1 · Edit this page on GitHub ↗