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:

  1. CLI flag (where one is wired through main.py — currently --mode, --transport, --host, --port)
  2. Environment variable, prefixed with OPENZIM_MCP_
  3. 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
ModeTool surfaceWhen to use
simple (default)1 tool: zim_query (NL intent router)small/local LLMs, MCP hosts that struggle with large tool catalogs
advanced8 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
FieldEnv varDefaultNotes
auth_tokenOPENZIM_MCP_AUTH_TOKENunsetBearer token for streamable HTTP. Stored as SecretStr; never logged. Set via env only — never put it in a file.
cors_originsOPENZIM_MCP_CORS_ORIGINS[]JSON list of allowed origins. Wildcard "*" is rejected at startup (whitespace-padded " * " too).
hostOPENZIM_MCP_HOST127.0.0.1Bind address. Non-loopback hosts require auth_token for http; sse always rejects non-loopback.
portOPENZIM_MCP_PORT80001-65535.
transportOPENZIM_MCP_TRANSPORTstdioOne 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 + no auth_tokenOpenZimMcpConfigurationError: 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)
FieldDefaultRange
cache.enabledtruebool
cache.max_size1001-10000
cache.persistence_enabledfalsebool
cache.persistence_path~/.cache/openzim-mcpnormalized to absolute path; falls in a predictable location even when CWD is unpredictable (containers, systemd)
cache.ttl_seconds360060-86400 (1 min - 24 h)
cache.libzim_cluster_cache_max_size_bytesunset (libzim default 16 MiB)0 – 4 GiB, bytes; process-global (libzim’s cluster cache)
cache.libzim_dirent_cache_max_countunset (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 raises OpenZimMcpValidationError if not (no silent str() 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 a Path, 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_LIMITS overrides in production deployments did not need to be rewritten when the wire-level tool surface changed.

v2 tool callInternal 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 acceptedOPENZIM_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.

FieldEnv varDefaultNotes
allowed_hostsOPENZIM_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_tokenOPENZIM_MCP_AUTH_TOKENunsetSecretStr, never logged, env-only
cache.enabledOPENZIM_MCP_CACHE__ENABLEDtruebool
cache.max_bytesOPENZIM_MCP_CACHE__MAX_BYTES64 MiBapproximate byte cap on cached values; 0 disables
cache.libzim_cluster_cache_max_size_bytesOPENZIM_MCP_CACHE__LIBZIM_CLUSTER_CACHE_MAX_SIZE_BYTESunset (libzim 16 MiB)0 – 4 GiB, bytes, process-global
cache.libzim_dirent_cache_max_countOPENZIM_MCP_CACHE__LIBZIM_DIRENT_CACHE_MAX_COUNTunset (libzim 512)0 – 10,000,000, count, per-archive
cache.max_sizeOPENZIM_MCP_CACHE__MAX_SIZE1001-10000
cache.persistence_enabledOPENZIM_MCP_CACHE__PERSISTENCE_ENABLEDfalsebool
cache.persistence_pathOPENZIM_MCP_CACHE__PERSISTENCE_PATH~/.cache/openzim-mcpnormalized absolute
cache.ttl_secondsOPENZIM_MCP_CACHE__TTL_SECONDS360060-86400
content.default_search_limitOPENZIM_MCP_CONTENT__DEFAULT_SEARCH_LIMIT101-100
content.max_content_lengthOPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH100000min 100
content.snippet_lengthOPENZIM_MCP_CONTENT__SNIPPET_LENGTH3000min 100
content.table_row_thresholdOPENZIM_MCP_CONTENT__TABLE_ROW_THRESHOLD8min 1; tables with more rows collapse in compact mode
content.table_char_thresholdOPENZIM_MCP_CONTENT__TABLE_CHAR_THRESHOLD600min 50; char size past which a table collapses
content.infobox_kv_limitOPENZIM_MCP_CONTENT__INFOBOX_KV_LIMIT301-200; key/value pairs kept from an infobox
cors_originsOPENZIM_MCP_CORS_ORIGINS[]JSON list; * rejected
hostOPENZIM_MCP_HOST127.0.0.1non-loopback requires auth (http) or refuses (sse)
insecure_disable_authOPENZIM_MCP_INSECURE_DISABLE_AUTHfalseescape hatch: allows token-less non-loopback HTTP with a WARNING (closed networks only)
logging.formatOPENZIM_MCP_LOGGING__FORMATstructuredformat string
meta.footer_enabledOPENZIM_MCP_META__FOOTER_ENABLEDtrueAppend the one-line _meta footer to simple-mode responses. Turning it off does not remove _meta itself — see the _meta envelope
meta.tokenizer_encodingOPENZIM_MCP_META__TOKENIZER_ENCODINGcl100k_baseDeclared but not wired. meta.py hard-codes cl100k_base; setting this has no effect today
logging.levelOPENZIM_MCP_LOGGING__LEVELINFODEBUG/INFO/WARNING/ERROR/CRITICAL
portOPENZIM_MCP_PORT80001-65535
presets_override_pathOPENZIM_MCP_PRESETS_OVERRIDE_PATHunsetTOML file deep-merged over the bundled archive-type presets — format and the confidence gate
rate_limit.burst_sizeOPENZIM_MCP_RATE_LIMIT__BURST_SIZE401-1000 (work units)
rate_limit.enabledOPENZIM_MCP_RATE_LIMIT__ENABLEDtruebool
rate_limit.per_operation_limitsOPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS{}nested JSON dict
rate_limit.requests_per_secondOPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND20.0positive float (work units/s)
resource_cache_ttl_secondsOPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS36000-86400; 0 disables
server_nameOPENZIM_MCP_SERVER_NAMEopenzim-mcpreported in serverInfo
subscriptions_enabledOPENZIM_MCP_SUBSCRIPTIONS_ENABLEDtruewatcher master switch
tool_modeOPENZIM_MCP_TOOL_MODEsimplesimple or advanced
transportOPENZIM_MCP_TRANSPORTstdiostdio/http/sse (sse deprecated, removed in 4.0.0)
watch_interval_secondsOPENZIM_MCP_WATCH_INTERVAL_SECONDS51-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 variableDefaultNotes
OPENZIM_MCP_TIMEOUT_MAX_WORKERS16I/O pool; sized for a typical 4–8 vCPU server
OPENZIM_MCP_REGEX_MAX_WORKERS8Regex 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:

FieldTypeConstraint
snippet_lengthinteger≥ 100
max_paragraphsinteger≥ 1
summary_stylestring"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. For wikipedia_en_climate_change_mini_2024-06.zim the key is wikipedia_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 with zim_metadata, under metadata_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:

  1. Start the server with stdio. Bad config raises OpenZimMcpConfigurationError immediately. The error message names the offending field.
  2. 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_pid and 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 no SecurityConfig. 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__* and OPENZIM_MCP_MONITORING__* — no first-party metrics endpoint; use /healthz, /readyz, and zim_health instead.
  • 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 — only level and format are 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.

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