Performance optimization

Cache tuning, rate limiting, batching, and request-pattern guidance for OpenZIM MCP.

Notation: examples on this page use Python pseudo-call syntax (zim_get(entry_path="...")) for tool calls and shell snippets for environment / HTTP commands. The MCP wire format is JSON-RPC; your client handles the framing. Tool names and argument shapes match the 8-tool advanced surface.

Where time goes#

Most OpenZIM MCP latency comes from one of three places:

  1. libzim cold reads — first access to an entry pays disk I/O + decompression. A repeat of the same call is served from the response cache; anything else re-opens the archive and leans on the OS page cache, because open archive handles are not pooled.
  2. HTTP round-trips (when using --transport http) — each tool call is a request/response. Batch where possible.
  3. Search-derived smart-retrieval fallback — when direct path access fails, the server runs a search loop. Cache hits eliminate this on repeat calls.

The cache and the batch-retrieval tools are the two biggest tuning levers.

Cache tuning#

Single LRU+TTL cache shared by all tools and the smart-retrieval path-mapping store.

export OPENZIM_MCP_CACHE__ENABLED=true                # default
export OPENZIM_MCP_CACHE__MAX_SIZE=500                # default 100; up to 10000
export OPENZIM_MCP_CACHE__TTL_SECONDS=14400           # default 3600; up to 86400
export OPENZIM_MCP_CACHE__PERSISTENCE_ENABLED=true    # cache survives restarts
WorkloadRecommended cache settings
Single-user desktop (Claude Desktop, Inspector)defaults are fine
Multi-user HTTP serviceMAX_SIZE=500-2000, TTL_SECONDS=14400+, persistence on
Memory-constrained (RPi, small VPS)MAX_SIZE=25-50, TTL_SECONDS=900-1800
Volatile content (frequent ZIM swaps)shorter TTL or rely on subscriptions to invalidate downstream

Cache stats surface in zim_health().health.cache_performance, which always carries 13 fields: enabled, size, max_size, ancillary_entries, total_entries, size_bytes, max_bytes, ttl_seconds, hits, misses, hit_rate, background_cleanup, persistence_enabled (plus persistence_path and persistence_file_exists when persistence is on). size counts only what max_size bounds; total_entries also counts per-item fragments, which are capped by max_bytes (64 MiB default) instead. There are no 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 path: at the default, the server does not write to ~/.cache/openzim-mcp — it appends a config fingerprint and a .json suffix, so the file is ~/.cache/openzim-mcp-<12 hex chars>.json, a sibling of that path rather than a file inside it. Mounting a volume at ~/.cache/openzim-mcp therefore captures nothing. For containerized deployments set OPENZIM_MCP_CACHE__PERSISTENCE_PATH explicitly to a file inside your mount.

libzim reader caches (advanced)#

Separate from the response cache above, libzim keeps its own in-memory read caches. Two optional knobs expose them; leave both unset to keep libzim’s defaults:

export OPENZIM_MCP_CACHE__LIBZIM_CLUSTER_CACHE_MAX_SIZE_BYTES=67108864  # 64 MiB; default 16 MiB
export OPENZIM_MCP_CACHE__LIBZIM_DIRENT_CACHE_MAX_COUNT=2048            # default 512 dirents
  • Cluster cache is sized in bytes and is process-global (one setting for the whole server, not per-archive). Raising it trades RAM for fewer decompressions on large archives with hot content.
  • Dirent cache is a count of directory entries, applied per opened archive. Raising it helps lookup-heavy workloads (deep namespace walks, many title/path probes) at a small memory cost.

These rarely need tuning; reach for them only when profiling points at decompression or dirent churn on very large ZIMs.

Rate limiting#

Token-bucket limiter (atomic global + per-operation acquire). Tune for your client load:

export OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND=40    # default 20 (work units/s)
export OPENZIM_MCP_RATE_LIMIT__BURST_SIZE=80             # default 40, max 1000

The budget counts work units, not requests — the defaults above are sized so a client can sustain roughly 20 cheap reads or 10 searches per second.

Per-operation costs (defaults from RATE_LIMIT_COSTS):

OperationCost
zim_get(binary=True, ...)3
zim_search (fulltext/title/filtered), zim_links(direction="outbound"|"related")2
All others (incl. per-entry for zim_get(entry_paths=[...]))1

A batch is charged per entry, but the total is clamped to the bucket’s capacity (burst_size, default 40) — so a 50-entry batch costs 40 slots, not 50, and one batch can never cost more than a full bucket. Plan burst size with batch tools in mind.

To carve out a different limit for one operation, use the per-operation overrides. Keys are usually the internal operation names, not the tool names — most tools are multiplexers and resolve the branch they are about to dispatch. The exceptions are zim_query, zim_get_section and zim_health, which are keyed on their wire tool name and charge the default cost. See the full mapping table in Configuration → Rate limiting.

export OPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS='{"search": {"requests_per_second": 4, "burst_size": 8}}'

Batching#

zim_get(entry_paths=[...]) takes up to 50 entry paths per call:

# Instead of N round-trips:
for path in paths:
    zim_get(zim_file_path=zfp, entry_path=path)

# One round-trip:
zim_get(zim_file_path=zfp, entry_paths=paths)

Per-entry success/failure means an LLM can request many candidates without all-or-nothing semantics. Particularly valuable over HTTP transport where round-trip cost dominates.

zim_search(cross_file=True, ...) queries every ZIM file in the allowed directories at once and merges the results. Avoids the “which archive holds X?” guessing game and keeps the LLM from chaining N single-archive zim_search calls.

Search pagination#

zim_search paginates with offset — it rejects a non-empty cursor with an invalid_combination error and nulls next_cursor in its responses:

# Page 1
result1 = zim_search(zim_file_path=zfp, query="biology", limit=10)
# Page 2 — restate the query, advance the offset
result2 = zim_search(zim_file_path=zfp, query="biology", limit=10, offset=10)

zim_browse(mode="walk", ...) uses entry-ID cursor pagination for exhaustive iteration:

cursor = None
while True:
    page = zim_browse(
        zim_file_path=zfp,
        namespace="M",
        mode="walk",
        cursor=cursor,
        limit=200,
    )
    process(page.results)
    if page.done:
        break
    cursor = page.next_cursor

HTTP transport considerations#

If you’re running behind --transport http:

  • Keep-alive matters. A reverse proxy or client that closes connections per request burns the TLS handshake every call.
  • Auth overhead is negligible — bearer-token comparison is hmac.compare_digest.
  • Health probes — use /healthz for liveness (auth-exempt, returns 200 OK), /readyz for readiness (auth-exempt, returns 503 if no allowed dir is readable). Don’t probe /mcp from your platform’s health checker; that requires a token and a JSON-RPC body.
  • Subscription watcher costOPENZIM_MCP_WATCH_INTERVAL_SECONDS (default 5) controls poll cadence. Raise it to 30-60 if you don’t need sub-minute freshness — the field is capped at 60 and a larger value fails config validation at startup; set OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false to skip watching entirely.
  • Let clients skip the read entirely — reads of the zim://{name} overview advertise a one-hour ttlMs, so a 2026-07-28 client re-serves them from its own cache instead of asking again; that pays off because each overview read costs three blocking archive opens. (Per-entry reads keep the watcher-bounded TTL: no resources/updated is ever published for an entry URI, so a longer promise could not be invalidated.) Tune with OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS; see Read caching and freshness for the staleness trade-off if you replace archives in place.

Monitoring#

Liveness / readiness#

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

# Readiness — 200 if at least one allowed dir is readable, 503 otherwise
curl -f http://localhost:8000/readyz
# {"status":"ready"}

Both endpoints are auth-exempt and CORS-friendly; safe to wire into Kubernetes probes, systemd WatchdogSec, or external uptime checks.

Health detail#

zim_health() returns {health, configuration, loaded_archives, _meta}. The .health block (abbreviated):

{
  "timestamp": "2026-05-02T15:30:00.000000",
  "status": "healthy",
  "server_name": "openzim-mcp",
  "uptime_info": {
    "process_id": "[REDACTED]",
    "started_at": "2026-05-02T15:00:00.000000"
  },
  "configuration": {
    "allowed_directories": 1,
    "cache_enabled": true,
    "config_hash": "abc12345..."
  },
  "cache_performance": {
    "enabled": true,
    "size": 42,
    "max_size": 100,
    "ttl_seconds": 3600,
    "hits": 1024,
    "misses": 256,
    "hit_rate": 0.8
  },
  "health_checks": {
    "directories_accessible": 1,
    "zim_files_found": 5,
    "permissions_ok": true
  },
  "recommendations": ["Server is running optimally"],
  "warnings": []
}

process_id is [REDACTED] over the HTTP/SSE transports; on local stdio the real PID is shown. Path entries inside warnings are always redacted. There are no instance_tracking, request_metrics, or smart_retrieval blocks — those were either removed (instance tracking) or never collected.

Calling zim_health from outside an MCP client#

You can hit the JSON-RPC endpoint directly. Under the 2026-07-28 revision that is a single stateless POST — no handshake, no session id — but the request envelope is strict: Mcp-Method must name the same method as the body, tools/call also needs Mcp-Name matching the tool, and params._meta must carry the protocol version and client capabilities. Replace $TOKEN with OPENZIM_MCP_AUTH_TOKEN:

curl -sS -X POST http://localhost: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/call" \
    -H "Mcp-Name: zim_health" \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"zim_health","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

The endpoint still serves the deprecated handshake era for clients that haven’t moved: omit MCP-Protocol-Version (or name a 2025 revision) and the transport becomes stateful — session ids are issued by the server in the initialize response header, not chosen by the client, so a made-up Mcp-Session-Id gets 404 Session not found. The full handshake:

# 1. Initialize; capture the server-issued session id from the response headers
SID=$(curl -sS -D - -o /dev/null -X POST http://localhost:8000/mcp \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')

# 2. Complete the handshake
curl -sS -X POST http://localhost:8000/mcp \
    -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
    -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3. Call the tool
curl -sS -X POST http://localhost:8000/mcp \
    -H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SID" \
    -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"zim_health","arguments":{}}}'

For platform-level monitoring prefer /healthz (no auth, no JSON body, no session, 200/503).

External monitoring#

Wire /healthz and /readyz into your platform’s uptime monitor. For Prometheus-style metrics OpenZIM MCP doesn’t ship a metrics endpoint — scrape /healthz for liveness or compose zim_health().health.cache_performance into your own exporter.

Resource patterns#

Pre-warming on startup#

There is no warm_cache tool. To pre-warm, call the entry tools yourself at startup:

# Wake the cache for high-value articles
common = ["A/Photosynthesis", "A/Evolution", "A/Climate_change"]
zim_get(zim_file_path="/srv/zim/wikipedia.zim", entry_paths=common)

Reducing repeated retrievals#

  • Use zim_get(view="summary") or zim_get(view="toc") first to decide whether to fetch the full body.
  • Use zim_search(mode="title") to resolve titles to canonical paths cheaply, then call zim_get with the resolved path (fewer smart-retrieval roundtrips).
  • For multi-archive lookups, prefer zim_search(cross_file=True) over N sequential single-archive zim_search calls.

Multi-archive perf#

zim_search(cross_file=True, ...) fans out serially over the allowed ZIM files under an aggregate time budget (20 s by default, OPENZIM_MCP_SEARCH__SEARCH_ALL_TOTAL_TIMEOUT_SECONDS). Files that can’t be searched (corrupt, no full-text index) are skipped without aborting the rest. It does not necessarily reach every archive: when the budget expires it stops and returns budget_exceeded: true, done: false, and files_searched below files_available — check those before treating the result as exhaustive. The merge happens server-side, so the response size scales with limit × number_of_archives.

For very large allowed-dir sets, consider:

  • Splitting archives across multiple OpenZIM MCP instances behind a reverse proxy.
  • Reducing limit (in cross-file mode it acts as the per-archive cap; defaults to 5 per archive when omitted).

Profiles#

Copy-pasteable deployment profiles — local development, single-user desktop, production HTTP, read-heavy, memory-constrained — live in Configuration → Profiles, which is the single home for them.

What belongs here is the other half: which knob to reach for when something is slow.

SymptomReach for
Repeat reads of the same articles are still slowCACHE__MAX_SIZE, then CACHE__MAX_BYTES — the byte cap binds first on long articles
The cache is cold after every restartCACHE__PERSISTENCE_ENABLED plus an explicit CACHE__PERSISTENCE_PATH
Clients see rate_limited under normal loadRATE_LIMIT__BURST_SIZE before REQUESTS_PER_SECOND — bursts are what a batching client produces
Responses are large and slow to renderCONTENT__MAX_CONTENT_LENGTH, or ask for compact output and page with content_offset
Many small round-tripszim_get(entry_paths=[...]) batching, not a config change
Memory pressure on a small hostCACHE__MAX_SIZE and CONTENT__SNIPPET_LENGTH down; SUBSCRIPTIONS_ENABLED=false if you do not swap archives
First read of a large archive is slowNothing to tune — that is libzim cold I/O; archive handles are not pooled, so it is the OS page cache that makes the second read faster

Targets#

Benchmark numbers captured against v1.x’s 22-tool surface in 2026-04 and not re-measured since. Tool-name dispatch overhead is unchanged on the 8-tool surface; treat the per-call latency numbers as indicative rather than current.

Indicative latency targets on a modern desktop / midrange VPS, hot cache:

OperationTarget
/healthz< 5 ms
Cached zim_get (single entry)< 50 ms
Cold zim_get (single entry)< 500 ms
zim_search (10 hits)< 200 ms
zim_search(cross_file=True) (5 archives)< 1 s
zim_get(entry_paths=[...]) (50 batch, mixed cache)< 2 s

Cold libzim opens of large archives (multi-GB Wikipedia) can be slow on first use, and the server does not pool archive handles — every tool call re-opens the file. What amortises the repeat is the OS page cache plus the response cache, so a cache hit skips the open entirely.


Configuration reference? Configuration. Smart retrieval details? Smart retrieval. Architecture? Architecture overview.

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 ↗