Configuration
Every supported environment variable, CLI flag, and configuration field for OpenZIM MCP.
Source of truth: openzim_mcp/config.py and openzim_mcp/defaults.py. If this page disagrees with code, code wins — please file an issue.
How configuration is loaded#
OpenZIM MCP is a pydantic-settings BaseSettings model. Every field can be set three ways, in priority order:
- CLI flag (where one is wired through
main.py— currently--mode,--transport,--host,--port) - Environment variable, prefixed with
OPENZIM_MCP_ - Default value from the field’s own declaration — many nested defaults (
cache.*,content.*,meta.*,search.*,rate_limit.*) come from the shared constants in defaults.py, while the server-level fields (transport,host,port,tool_mode,server_name,logging.*, and the watcher/subscription knobs) are declared inline in config.py.
Nested fields use a double-underscore separator: OPENZIM_MCP_CACHE__MAX_SIZE, OPENZIM_MCP_RATE_LIMIT__BURST_SIZE, etc.
A bad value raises OpenZimMcpConfigurationError at startup with a human-readable message — pydantic’s raw ValidationError dump is wrapped before it reaches the operator.
Tool mode#
# Default: simple — exposes one natural-language tool (zim_query)
# Set to advanced for the full 8-tool advanced surface
export OPENZIM_MCP_TOOL_MODE=advanced
Or via CLI:
openzim-mcp --mode advanced /path/to/zim/files
| Mode | Tool surface | When to use |
|---|---|---|
simple (default) | 1 tool: zim_query (NL intent router) | small/local LLMs, MCP hosts that struggle with large tool catalogs |
advanced | 8 tools: zim_query, zim_search, zim_get, zim_get_section, zim_browse, zim_metadata, zim_links, zim_health (see API reference) | hosts that handle a richer tool surface, scripting, fine-grained control |
The advanced surface is 8 consolidated tools — collapsed from the prior 22-tool v1 surface. The advanced-mode wire footprint is ~23.3KB (23,887 bytes, held under a hard 25,600-byte budget by tests/test_phase_f_schema_budget.py), down from ~36KB, clearing the MCP Tax pain band (25–50KB schema) for small-model dispatch. See the v1 → v2 migration table if you’re upgrading.
Transport#
# Default: stdio (no network)
# Other values: http (streamable HTTP), sse (legacy, localhost-only, deprecated — removed in 4.0.0)
export OPENZIM_MCP_TRANSPORT=http
export OPENZIM_MCP_HOST=127.0.0.1 # default 127.0.0.1
export OPENZIM_MCP_PORT=8000 # default 8000
export OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)" # required for non-localhost
export OPENZIM_MCP_CORS_ORIGINS='["https://app.example"]' # JSON list; "*" rejected
| Field | Env var | Default | Notes |
|---|---|---|---|
auth_token | OPENZIM_MCP_AUTH_TOKEN | unset | Bearer token for streamable HTTP. Stored as SecretStr; never logged. Set via env only — never put it in a file. |
cors_origins | OPENZIM_MCP_CORS_ORIGINS | [] | JSON list of allowed origins. Wildcard "*" is rejected at startup (whitespace-padded " * " too). |
host | OPENZIM_MCP_HOST | 127.0.0.1 | Bind address. Non-loopback hosts require auth_token for http; sse always rejects non-loopback. |
port | OPENZIM_MCP_PORT | 8000 | 1-65535. |
transport | OPENZIM_MCP_TRANSPORT | stdio | One of stdio/http/sse. sse has no auth middleware and is loopback-only, and is deprecated — it warns on every start and is removed in 4.0.0. |
Safe-default startup check — the server refuses to bind if either:
transport=http+ non-loopback host + noauth_token→OpenZimMcpConfigurationError: HTTP transport bound to {host} requires authentication.transport=sse+ non-loopback host →OpenZimMcpConfigurationError: SSE transport bound to {host} is not allowed.
Exception: setting OPENZIM_MCP_INSECURE_DISABLE_AUTH=1 lets http bind a non-loopback host without a token — intended for closed networks (Docker bridge, Tailscale-only, isolated LAN). The server logs a WARNING naming the bound host; the SSE refusal has no escape hatch. Behind a Host-preserving reverse proxy, also set OPENZIM_MCP_ALLOWED_HOSTS to the public hostname(s). Loopback Host values are always accepted, and every entry in OPENZIM_MCP_ALLOWED_HOSTS is added on top of them on any bind (a bare entry also gets a :* wildcard-port form, so a proxy’s Host: mcp.example.com:443 matches); on a bind-all host (0.0.0.0, ::) with an empty allow-list the server turns DNS-rebinding Host validation off and warns, since any allow-list would 421 every direct-IP client — setting OPENZIM_MCP_ALLOWED_HOSTS turns it back on.
For full HTTP deployment guidance see HTTP and Docker Deployment.
Resource subscriptions#
Polling-based mtime watcher. A .zim appearing in or disappearing from an allowed directory publishes notifications/resources/list_changed; a .zim replaced in place publishes notifications/resources/updated for that archive’s zim://{name}. Both reach clients that opted in via subscriptions/listen, and only under the HTTP transport.
export OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=true # default true
export OPENZIM_MCP_WATCH_INTERVAL_SECONDS=5 # default 5, range 1-60
export OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS=3600 # default 3600, range 0-86400
Setting OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false skips the polling task and withholds the capability: resources.subscribe and the listChanged flags read false, and subscriptions/listen fails with method-not-found instead of acknowledging a stream that would never fire. Tune OPENZIM_MCP_WATCH_INTERVAL_SECONDS upward for low-priority watching, downward (to a floor of 1s) for faster detection.
Read caching#
Results carry the 2026-07-28 ttlMs/cacheScope hints, which tell a client how long it may reuse an answer instead of asking again. OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS sets that window for the zim://{name} overview, where it pays off most: a ZIM file is sealed, so the overview changes only when the file itself is replaced, and each read costs three blocking archive opens. Per-entry reads (zim://{name}/entry/{path}) stay on the watcher-bounded TTL — no resources/updated is ever published for an entry URI, so a longer promise could not be invalidated.
zim://files is deliberately excluded. It is a live scan of the allowed directories, so it stays bounded by OPENZIM_MCP_WATCH_INTERVAL_SECONDS and a cached copy is never staler than the server’s own detection latency.
The trade-off is staleness after a replacement. A client on an open subscriptions/listen stream is told immediately and re-reads, so the TTL costs it nothing. A client that is not subscribed — anything on stdio, or an HTTP client that never opened a stream — can keep serving the old overview for up to the TTL. If you hot-swap archives in place and cannot rely on subscribers, lower it, or set 0 to turn the override off entirely and put overview reads back on the same watcher-bounded TTL as zim://files.
Cache#
Single LRU+TTL cache shared by all tools and the smart-retrieval path-mapping store.
export OPENZIM_MCP_CACHE__ENABLED=true # default true
export OPENZIM_MCP_CACHE__MAX_SIZE=100 # default 100, range 1-10000
export OPENZIM_MCP_CACHE__TTL_SECONDS=3600 # default 3600, range 60-86400
export OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=false # default false
export OPENZIM_MCP_CACHE__PERSISTENCE_PATH="$HOME/.cache/openzim-mcp" # default
export OPENZIM_MCP_CACHE__LIBZIM_CLUSTER_CACHE_MAX_SIZE_BYTES=16777216 # default unset (libzim 16 MiB)
export OPENZIM_MCP_CACHE__LIBZIM_DIRENT_CACHE_MAX_COUNT=512 # default unset (libzim 512)
| Field | Default | Range |
|---|---|---|
cache.enabled | true | bool |
cache.max_size | 100 | 1-10000 |
cache.persistence_enabled | false | bool |
cache.persistence_path | ~/.cache/openzim-mcp | normalized to absolute path; falls in a predictable location even when CWD is unpredictable (containers, systemd) |
cache.ttl_seconds | 3600 | 60-86400 (1 min - 24 h) |
cache.libzim_cluster_cache_max_size_bytes | unset (libzim default 16 MiB) | 0 – 4 GiB, bytes; process-global (libzim’s cluster cache) |
cache.libzim_dirent_cache_max_count | unset (libzim default 512) | 0 – 10,000,000, count of dirents; per-archive |
These last two are independent of the response cache above: they size libzim’s own reader caches. Leave them unset to keep libzim’s defaults. The cluster cache is sized in bytes and is process-global; the dirent cache is a count of directory entries applied per opened archive. See Performance optimization for tuning guidance.
Cache stats surface inside zim_health under .health.cache_performance — there are no explicit warm_cache/cache_stats/cache_clear tools. Restarting flushes the cache only when CACHE__PERSISTENCE_ENABLED is off; with persistence on it is saved at exit and reloaded at start.
Persistence note: when
persistence_enabled=true,cache.set()validates that the value is JSON-serializable at write time and raisesOpenZimMcpValidationErrorif not (no silentstr()coercion). Internal callers always pass JSON-safe values (strings, dicts, lists, numbers, bools), so this only matters if you’ve patched in a custom caller that stashes aPath,datetime, or other non-JSON object. Pure in-memory caches (persistence off) still accept arbitrary Python objects.
Content#
export OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH=100000 # default 100000, min 100
export OPENZIM_MCP_CONTENT__SNIPPET_LENGTH=3000 # default 3000, min 100
export OPENZIM_MCP_CONTENT__DEFAULT_SEARCH_LIMIT=10 # default 10, range 1-100
The config field content.max_content_length must be >= 100; a lower value fails pydantic validation and aborts startup as an OpenZimMcpConfigurationError, never as a tool response. The separate per-call max_content_length argument — accepted only by zim_get and zim_query — is validated independently and only has to be >= 1; below that the call returns a ToolErrorPayload, {"error": true, "operation": "invalid_max_content_length", "message": "..."}, which the server delivers as JSON text with isError: true.
Logging#
export OPENZIM_MCP_LOGGING__LEVEL=INFO # default INFO; one of DEBUG/INFO/WARNING/ERROR/CRITICAL
export OPENZIM_MCP_LOGGING__FORMAT="%(asctime)s - %(name)s - %(levelname)s - %(message)s" # default
Only level and format are configurable — there is no separate JSON-mode toggle. To emit JSON, supply a JSON format string (or wrap stdout with a JSON-converting handler in your deployment).
Rate limiting#
Token-bucket limiter; global + per-operation acquire is atomic (one pass, no transient over-consumption).
The budget is denominated in work units, not requests. Most operations cost 1, searches cost 2, and a binary fetch costs 3 — so requests_per_second is really “work units per second”. Size any override against the cost of the operation you are limiting, or it will admit fewer calls than the number suggests.
export OPENZIM_MCP_RATE_LIMIT__ENABLED=true # default true
export OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND=20.0 # default 20.0 (work units/s)
export OPENZIM_MCP_RATE_LIMIT__BURST_SIZE=40 # default 40, max 1000
# Per-operation limits (nested dict, JSON):
export OPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS='{"search": {"requests_per_second": 4, "burst_size": 8}}'
Per-internal-operation cost defaults are defined in defaults.RATE_LIMIT_COSTS. The v2 tools dispatch internally; each branch resolves to a specific operation key the limiter charges against.
Note on key vocabulary. The internal operation keys below are stable pydantic-validated identifiers — they happen to share their string form with the v1 tool names, but they are config keys, not v1 tool surfaces. The keys remained stable through the v1 → v2 tool collapse so that existing
PER_OPERATION_LIMITSoverrides in production deployments did not need to be rewritten when the wire-level tool surface changed.
| v2 tool call | Internal operation key (literal) | Cost |
|---|---|---|
zim_search(mode="fulltext") | "search" | 2 |
zim_search(mode="title") | "find_entry_by_title" | 2 |
zim_search(mode="fulltext") with namespace/content_type | "search_with_filters" | 2 |
zim_search(mode="suggest") | "suggestions" | 1 |
zim_get(entry_path=...) | "get_entry" | 1 |
zim_get(entry_paths=[...]) | "get_zim_entries" (×N per-entry) | 1 |
zim_get(binary=True) | "get_binary_entry" | 3 |
zim_get(view="structure") / view="toc" / view="summary") | "get_structure" | 1 |
zim_browse(...) | "browse_namespace" | 1 |
zim_metadata(...) | "get_metadata" | 1 |
zim_links(direction="outbound") | "extract_article_links" | 2 |
zim_links(direction="inbound") | "get_inbound_links" | 1 |
zim_links(direction="related") | "get_related_articles" | 2 |
zim_health / zim_get_section / zim_query | "zim_health" / "zim_get_section" / "zim_query" (bucket keyed on the tool name; the cost falls back to the default cost) | 1 |
zim_get(entry_paths=[...]) charges per-entry so a batch does not trivially bypass the per-second limit. The charge is min(len(entry_paths), 50) units — MAX_BATCH_SIZE is 50 — taken as two debits: one unit up front, before validation, and the remainder after, clamped to the bucket’s capacity. An over-size batch is rejected after the first unit, so it costs 1 rather than a full bucket.
mode wins over the filter arguments when the bucket key is resolved: zim_search(mode="title", namespace=...) keys on "find_entry_by_title", not "search_with_filters". Rejected calls are charged before they are rejected, deliberately — malformed input is never free.
How the limiter identifies a client#
Buckets are keyed per client, derived in this order: a hash of the presented bearer token, then the peer IP, then the literal "default".
Only one token value is ever accepted — OPENZIM_MCP_AUTH_TOKEN is a single credential, not a list — so on the deployment this page recommends, every authenticated client shares one bucket. Per-client isolation is real but it separates tokens, and there is only one. Size the limits for your total expected load, not per consumer. Issuing a token per consumer would need a change to the server.
There is no X-Forwarded-For handling in this project. Unauthenticated callers are separated by peer IP, which behind a reverse proxy on another host or container is the proxy’s address — again one bucket. (Uvicorn trusts X-Forwarded-For only when the immediate peer is 127.0.0.1; its FORWARDED_ALLOW_IPS environment variable is the only lever, and this project exposes no setting for it.)
stdio callers, and the deprecated sse transport, all get "default". The client table is LRU-bounded at 10,000 entries, which is not configurable; an evicted client returns to a fresh bucket at full burst.
Per-operation overrides use the literal key strings shown in the right column. To throttle binary fetches:
# `get_binary_entry` costs 3 units, so burst_size 6 admits two fetches:
export OPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS='{"get_binary_entry": {"requests_per_second": 3, "burst_size": 6}}'
Server identity#
export OPENZIM_MCP_SERVER_NAME=openzim-mcp # default openzim-mcp; reported in serverInfo
serverInfo.version always reports openzim-mcp’s installed version (read from importlib.metadata), no longer the MCP SDK default.
Allowed directories#
ZIM file directories. Pass on the CLI:
openzim-mcp /srv/zim /home/user/zim-files
At least one directory is required; each is canonicalized (resolves symlinks and ..) and verified to exist and be a directory at startup. Paths in MCP error responses are redacted to a ...filename.zim form so the canonical layout is never leaked.
Complete reference table#
Sorted alphabetically by field name within each grouping.
| Field | Env var | Default | Notes |
|---|---|---|---|
allowed_hosts | OPENZIM_MCP_ALLOWED_HOSTS | [] | JSON list of extra accepted Host header values (HTTP). Loopback values are always accepted and these entries are added on top of them on any bind; leaving this empty on a bind-all host (0.0.0.0, ::) disables Host validation with a warning. Set it behind Host-preserving reverse proxies. An entry of exactly * is rejected at startup; a :* port suffix is allowed |
auth_token | OPENZIM_MCP_AUTH_TOKEN | unset | SecretStr, never logged, env-only |
cache.enabled | OPENZIM_MCP_CACHE__ENABLED | true | bool |
cache.max_bytes | OPENZIM_MCP_CACHE__MAX_BYTES | 64 MiB | approximate byte cap on cached values; 0 disables |
cache.libzim_cluster_cache_max_size_bytes | OPENZIM_MCP_CACHE__LIBZIM_CLUSTER_CACHE_MAX_SIZE_BYTES | unset (libzim 16 MiB) | 0 – 4 GiB, bytes, process-global |
cache.libzim_dirent_cache_max_count | OPENZIM_MCP_CACHE__LIBZIM_DIRENT_CACHE_MAX_COUNT | unset (libzim 512) | 0 – 10,000,000, count, per-archive |
cache.max_size | OPENZIM_MCP_CACHE__MAX_SIZE | 100 | 1-10000 |
cache.persistence_enabled | OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED | false | bool |
cache.persistence_path | OPENZIM_MCP_CACHE__PERSISTENCE_PATH | ~/.cache/openzim-mcp | normalized absolute |
cache.ttl_seconds | OPENZIM_MCP_CACHE__TTL_SECONDS | 3600 | 60-86400 |
content.default_search_limit | OPENZIM_MCP_CONTENT__DEFAULT_SEARCH_LIMIT | 10 | 1-100 |
content.max_content_length | OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH | 100000 | min 100 |
content.snippet_length | OPENZIM_MCP_CONTENT__SNIPPET_LENGTH | 3000 | min 100 |
content.table_row_threshold | OPENZIM_MCP_CONTENT__TABLE_ROW_THRESHOLD | 8 | min 1; tables with more rows collapse in compact mode |
content.table_char_threshold | OPENZIM_MCP_CONTENT__TABLE_CHAR_THRESHOLD | 600 | min 50; char size past which a table collapses |
content.infobox_kv_limit | OPENZIM_MCP_CONTENT__INFOBOX_KV_LIMIT | 30 | 1-200; key/value pairs kept from an infobox |
cors_origins | OPENZIM_MCP_CORS_ORIGINS | [] | JSON list; * rejected |
host | OPENZIM_MCP_HOST | 127.0.0.1 | non-loopback requires auth (http) or refuses (sse) |
insecure_disable_auth | OPENZIM_MCP_INSECURE_DISABLE_AUTH | false | escape hatch: allows token-less non-loopback HTTP with a WARNING (closed networks only) |
logging.format | OPENZIM_MCP_LOGGING__FORMAT | structured | format string |
meta.footer_enabled | OPENZIM_MCP_META__FOOTER_ENABLED | true | Append the one-line _meta footer to simple-mode responses. Turning it off does not remove _meta itself — see the _meta envelope |
meta.tokenizer_encoding | OPENZIM_MCP_META__TOKENIZER_ENCODING | cl100k_base | Declared but not wired. meta.py hard-codes cl100k_base; setting this has no effect today |
logging.level | OPENZIM_MCP_LOGGING__LEVEL | INFO | DEBUG/INFO/WARNING/ERROR/CRITICAL |
port | OPENZIM_MCP_PORT | 8000 | 1-65535 |
presets_override_path | OPENZIM_MCP_PRESETS_OVERRIDE_PATH | unset | TOML file deep-merged over the bundled archive-type presets — format and the confidence gate |
rate_limit.burst_size | OPENZIM_MCP_RATE_LIMIT__BURST_SIZE | 40 | 1-1000 (work units) |
rate_limit.enabled | OPENZIM_MCP_RATE_LIMIT__ENABLED | true | bool |
rate_limit.per_operation_limits | OPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS | {} | nested JSON dict |
rate_limit.requests_per_second | OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND | 20.0 | positive float (work units/s) |
resource_cache_ttl_seconds | OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS | 3600 | 0-86400; 0 disables |
server_name | OPENZIM_MCP_SERVER_NAME | openzim-mcp | reported in serverInfo |
subscriptions_enabled | OPENZIM_MCP_SUBSCRIPTIONS_ENABLED | true | watcher master switch |
tool_mode | OPENZIM_MCP_TOOL_MODE | simple | simple or advanced |
transport | OPENZIM_MCP_TRANSPORT | stdio | stdio/http/sse (sse deprecated, removed in 4.0.0) |
watch_interval_seconds | OPENZIM_MCP_WATCH_INTERVAL_SECONDS | 5 | 1-60 |
Thread-pool sizing (env-only)#
Two knobs are read straight from the environment rather than through the config
model, so they have no openzim_mcp.config field and do not appear above. They
bound the worker pools that back timed-out libzim and regex work — a timed-out
worker cannot be killed, so the cap is what makes sustained timeouts show up as
rising queue depth instead of OS-thread exhaustion. Garbage values fall back to
the default with a warning.
| Environment variable | Default | Notes |
|---|---|---|
OPENZIM_MCP_TIMEOUT_MAX_WORKERS | 16 | I/O pool; sized for a typical 4–8 vCPU server |
OPENZIM_MCP_REGEX_MAX_WORKERS | 8 | Regex pool |
The /readyz probe has its own dedicated single-slot pool, which is not
configurable — sharing the I/O pool let repeated probes against a wedged mount
burn workers that every MCP tool call also needs.
Further nested groups exist for specialized tuning — search.* (e.g. OPENZIM_MCP_SEARCH__SEARCH_ALL_TOTAL_TIMEOUT_SECONDS), query_rewrite.*, synthesize.*, meta.*, and ml.reranker.* (documented on Search reranking). Their fields and defaults live in openzim_mcp/config.py.
Archive-type presets#
The server classifies each archive — wikipedia, wiktionary, stackexchange, ted or
generic — and tunes retrieval for it. OPENZIM_MCP_PRESETS_OVERRIDE_PATH points at a TOML file
that is deep-merged over the bundled presets, per key, so you override one value without
restating the rest. The bundled file ships presets for wikipedia and stackexchange only.
# Adjust a bundled preset — the other keys stay as shipped.
[preset.wikipedia]
snippet_length = 1200
# Add one for a type that has none.
[preset.ted]
summary_style = "first_section"
max_paragraphs = 2
# Pin one specific archive. The table key is the archive's M/Name metadata
# value, NOT its filename.
[archive.wikipedia_en_climate_change]
type = "stackexchange"
snippet_length = 900
Three fields, all optional; a missing one inherits the global default:
| Field | Type | Constraint |
|---|---|---|
snippet_length | integer | ≥ 100 |
max_paragraphs | integer | ≥ 1 |
summary_style | string | "first_section" or "q_and_a" — those two only |
[archive.*] tables take those three plus type, which forces a classification.
The load-bearing rule is the confidence gate. Detection returns a confidence of high,
medium or none. A type preset applies only at high; at medium or none the archive
gets generic behaviour, however well the type matched. A per-archive pin applies at every
confidence level — so pinning is how you force behaviour onto an archive the classifier will
not commit to. A pin also merges over its type’s preset rather than replacing it.
Two things to know before you write one:
- The pin key is
M/Name, not the filename. Forwikipedia_en_climate_change_mini_2024-06.zimthe key iswikipedia_en_climate_change. A table keyed on the filename matches nothing and fails silently — you get the type preset, and no warning. Read the value back withzim_metadata, undermetadata_entries.Name. - A broken override never stops the server. An unreadable file, invalid TOML, or an unknown key logs a warning and falls back to the bundled defaults. That is deliberate — a typo in an operator file must not take the server down — but it does mean a silently ineffective override looks exactly like a working one. Check the startup log.
Profiles#
These are the canonical deployment profiles; Performance optimization explains which knob to move for a given symptom and links back here for the recipes. Every setting is an environment variable, so the same names and values carry into a systemd EnvironmentFile or a container’s environment: block — drop the export keyword there. Each comment names the code default, so you can see what the line is actually changing.
Local development (stdio)#
export OPENZIM_MCP_LOGGING__LEVEL=DEBUG
export OPENZIM_MCP_CACHE__MAX_SIZE=50 # default 100
export OPENZIM_MCP_CACHE__TTL_SECONDS=1800 # default 3600
openzim-mcp ~/zim-files
Single-user desktop (stdio, e.g. Claude Desktop)#
The defaults already fit this shape — one client, one archive set. Reach for these only if you keep a lot of articles hot and want the cache to survive a restart:
export OPENZIM_MCP_CACHE__MAX_SIZE=500 # default 100
export OPENZIM_MCP_CACHE__TTL_SECONDS=14400 # default 3600
export OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true # default false; survives restarts
export OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH=200000 # default 100000
openzim-mcp /srv/zim
Production HTTP service#
export OPENZIM_MCP_TRANSPORT=http # default stdio
export OPENZIM_MCP_HOST=127.0.0.1 # default 127.0.0.1
export OPENZIM_MCP_PORT=8000 # default 8000
export OPENZIM_MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
export OPENZIM_MCP_CORS_ORIGINS='["https://app.example.com"]'
# The code default is simple mode, which exposes zim_query alone. A service
# whose clients call zim_get / zim_search / zim_health directly needs the
# advanced surface. (The published Docker image already sets this.)
export OPENZIM_MCP_TOOL_MODE=advanced
export OPENZIM_MCP_LOGGING__LEVEL=INFO
export OPENZIM_MCP_CACHE__MAX_SIZE=1000 # default 100
export OPENZIM_MCP_CACHE__TTL_SECONDS=14400 # default 3600
export OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true
# Set this explicitly. Left unset, the server appends a config fingerprint and
# a .json suffix to the default path, so the file lands at
# ~/.cache/openzim-mcp-<12 hex>.json — a sibling of ~/.cache/openzim-mcp, not a
# file inside it, and a volume mounted at that directory captures nothing.
export OPENZIM_MCP_CACHE__PERSISTENCE_PATH=/var/lib/openzim-mcp/cache.json
export OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH=200000 # default 100000
# Defaults are 20/s with a burst of 40. Raise them for a multi-client service —
# but note every request carrying the bearer token shares one bucket, because
# the limiter keys on a hash of the token before falling back to peer IP.
export OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND=40
export OPENZIM_MCP_RATE_LIMIT__BURST_SIZE=80
# Subscriptions stay on, polled less often for less I/O churn (default 5).
export OPENZIM_MCP_WATCH_INTERVAL_SECONDS=15
openzim-mcp /srv/zim
Front it with a TLS-terminating reverse proxy (Caddy, nginx, traefik) — there is no built-in TLS.
Read-heavy single archive#
One large archive, mostly repeat reads. Spend the memory on cache residency:
export OPENZIM_MCP_CACHE__MAX_SIZE=2000 # default 100
export OPENZIM_MCP_CACHE__TTL_SECONDS=28800 # default 3600 (8 h)
export OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true
export OPENZIM_MCP_CONTENT__DEFAULT_SEARCH_LIMIT=20 # default 10
openzim-mcp /srv/zim
MAX_SIZE counts entries, not bytes, and OPENZIM_MCP_CACHE__MAX_BYTES (64 MiB) bounds the store independently — on an archive of long articles the byte cap is what binds, so raising MAX_SIZE alone buys less than the number suggests.
Memory-constrained (e.g. small VPS, RPi)#
export OPENZIM_MCP_CACHE__MAX_SIZE=25 # default 100
export OPENZIM_MCP_CACHE__TTL_SECONDS=900 # default 3600
export OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH=50000 # default 100000
export OPENZIM_MCP_CONTENT__SNIPPET_LENGTH=500 # default 3000
export OPENZIM_MCP_WATCH_INTERVAL_SECONDS=30 # default 5
export OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false # default true
openzim-mcp ~/zim-files
Validating configuration#
There is no offline --validate flag. Two options:
- Start the server with stdio. Bad config raises
OpenZimMcpConfigurationErrorimmediately. The error message names the offending field. - Call
zim_health— but only on the advanced surface. It is not registered in simple mode, which is the default, so this needs--mode advanced,OPENZIM_MCP_TOOL_MODE=advanced, or the published Docker image.
zim_health()
On a default simple-mode install the nearest equivalent is asking zim_query for "list available ZIM files", which confirms the allowed directories resolved and each archive is readable but reports no configuration. Phrasing it as a request for health or configuration does not reach zim_health: there is no health intent in simple mode, so the words are treated as a search topic.
The zim_health() response includes (abbreviated):
.health— server status, uptime, cache performance, health checks, warnings, recommendations.configuration— resolved values (no secrets;server_pidand directory paths redacted over HTTP/SSE, shown on local stdio).loaded_archives— list of every ZIM file in the allowed directories
Setting environment variables#
Linux / macOS:
echo 'export OPENZIM_MCP_CACHE__MAX_SIZE=200' >> ~/.bashrc
source ~/.bashrc
Windows (PowerShell):
$env:OPENZIM_MCP_CACHE__MAX_SIZE = "200"
[Environment]::SetEnvironmentVariable("OPENZIM_MCP_CACHE__MAX_SIZE", "200", "User")
systemd unit:
[Service]
Environment=OPENZIM_MCP_TRANSPORT=http
Environment=OPENZIM_MCP_HOST=127.0.0.1
EnvironmentFile=/etc/openzim-mcp/secrets.env # OPENZIM_MCP_AUTH_TOKEN here
ExecStart=/usr/local/bin/openzim-mcp /srv/zim
Docker: see HTTP and Docker Deployment.
Stale env vars (not in code)#
The following env-var namespaces appeared in pre-1.0 / early-v2 documentation and do not exist in the current codebase. If a tool or example tells you to set them, the source is stale:
OPENZIM_MCP_INSTANCE__*— multi-instance conflict tracking was removed entirely.OPENZIM_MCP_SECURITY__*— there is noSecurityConfig. Path validation, input sanitization, and limits are all controlled by the values listed above.OPENZIM_MCP_SMART_RETRIEVAL__*— smart retrieval shares the global cache; there are no dedicated knobs.OPENZIM_MCP_METRICS__*andOPENZIM_MCP_MONITORING__*— no first-party metrics endpoint; use/healthz,/readyz, andzim_healthinstead.OPENZIM_MCP_SERVER__MAX_CONCURRENT,OPENZIM_MCP_SERVER__REQUEST_TIMEOUT,OPENZIM_MCP_SERVER_DESCRIPTION,OPENZIM_MCP_SERVER__ENABLE_MONITORING— never existed.OPENZIM_MCP_CONTENT__CONVERT_HTML,OPENZIM_MCP_CONTENT__PRESERVE_FORMATTING— content processing is unconditional.OPENZIM_MCP_LOGGING__JSON,OPENZIM_MCP_LOGGING__SECURITY_EVENTS— onlylevelandformatare configurable.
Tuning? See Performance Optimization. Deploying over HTTP? See HTTP and Docker Deployment.
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.