Resources, prompts & subscriptions

OpenZIM MCP exposes three MCP “resources” (URI-addressable data), three slash-command “prompts” (pre-built workflows), and a polling-based subscription system for live update notifications. This page is the canonical reference for each.

Source of truth: openzim_mcp/tools/resource_tools.py, openzim_mcp/tools/prompts.py, openzim_mcp/subscriptions.py.

Resources and prompts are registered only in Advanced mode (--mode advanced / OPENZIM_MCP_TOOL_MODE=advanced) — Simple mode exposes just the zim_query tool. Change notifications additionally require the HTTP transport (--transport http), the only transport that runs the file watcher; on stdio and SSE the capability is not advertised and a subscriptions/listen request fails with method-not-found rather than acknowledging a stream nothing would ever publish to.

Notation: code samples on this page use MCP JSON-RPC tool-call framing ({"name": "...", "arguments": {...}}). Your MCP client handles the wire framing; you supply the tool name and argument shape.


Resources#

MCP resources are URI-addressable data. Clients with a resource browser or @-mention picker (Claude Code, Inspector) surface them automatically.

zim://files#

JSON list of every ZIM file in the allowed directories.

[
  {
    "name": "wikipedia_en_100_2026-02.zim",
    "path": "/srv/zim/wikipedia_en_100_2026-02.zim",
    "directory": "/srv/zim",
    "size": "119.07 MB",
    "size_bytes": 124857600,
    "modified": "2026-02-15T10:30:00",
    "readable": true
  }
]

readable is always present: the listing globs *.zim by name, then probes each file’s ZIM signature, so a file that is named right but is not an archive stays in the list marked "readable": false rather than disappearing. Unreadable rows additionally carry a warning string saying whether the file was denied or simply lacks the signature.

Same shape as the loaded_archives field returned by the zim_health tool.

zim://{name}#

Overview of one ZIM file. {name} is the bare basename without .zim (e.g. wikipedia_en_100_2026-02). Returns metadata, namespace summary, and a main-page preview (truncated to 2000 chars).

{
  "name": "wikipedia_en_100_2026-02",
  "path": "/srv/zim/wikipedia_en_100_2026-02.zim",
  "metadata": {
    "entry_count": 20565,
    "article_count": 3821,
    "uuid": "3159a385-f56b-3f4e-28ff-20f4efe4cae9",
    "has_fulltext_index": true,
    "metadata_entries": {
      "Title": "Wikipedia",
      "Language": "eng",
      "Creator": "Wikipedia",
      "Flavour": "maxi"
    }
  },
  "namespaces": { },
  "main_page_preview": "..."
}

If a section fails to load (rare — corrupt archive, missing metadata), it’s reported in metadata_error / namespaces_error / main_page_error rather than aborting the whole response.

zim://{name}/entry/{path}#

Single entry served with native MIME type. The MIME type is detected from libzim’s Item.mimetype and reported back per request — text entries return text bodies; binary entries (images, PDFs, audio) return raw bytes (the SDK base64-wraps them).

URL encoding requirement#

Clients MUST URL-encode / as %2F in the {path} segment. The SDK’s URI template engine treats / as a segment separator, so a literal slash won’t route. Other RFC 3986 reserved characters in the path also need encoding (e.g. ? as %3F).

zim://wikipedia_en/entry/A%2FClimate_change
zim://wikipedia_en/entry/I%2FFlag_of_France.svg

Without the encoding, the resource fetch returns a “not found” error even when the entry exists.

MIME type behavior#

  • HTML / text → text/html, text/plain, application/json, application/xml, application/javascript returned as decoded text.
  • Binary (anything else) → returned as raw bytes; the SDK base64-wraps them on the wire.
  • Unknown / missing MIME → application/octet-stream.

Charset parameters (text/html; charset=utf-8) are stripped before reporting; only the bare MIME type ends up in the response.

When to use this resource vs the zim_get tool#

ConcernPer-entry resourcezim_get tool
Direct browser/MCP-client rendering (HTML, image, PDF)preferrednot designed for this
Native MIME typeyeswraps article body in markdown envelope
Smart-retrieval fallbackno — direct path only, must know exact pathyes — search-derived term fallback
Truncation / max_content_lengthfixed 256 KiB cap — text truncated with a notice pointing at zim_get(content_offset=...); binaries over the cap are rejected (use zim_get(binary=True))yes — configurable
Binary content with metadata wrappingbare bytes onlyuse zim_get(binary=True) for the {path, title, mime_type, size, encoding, data} envelope

For LLM workflows that need processed text + smart fallback, prefer the tool. For “render this entry” workflows, prefer the resource.


Prompts#

MCP prompts are pre-built workflows clients can invoke as slash commands. Each one returns a list of messages instructing the LLM to chain a specific multi-step ZIM operation against the 8-tool advanced surface.

Hardening: user-supplied arguments are sanitized before interpolation — control characters replaced with spaces, backticks stripped (template delimiter), length capped at 200 characters. Apostrophes and double quotes are preserved (real entry paths contain them, e.g. C/Schrödinger's_cat). If args reduce to empty after sanitization, the prompt body asks the user to re-supply them.

/research#

Signature: research(topic: str).

Searches across every ZIM file for a topic, then drills into the top hits. Workflow:

  1. Dispatch zim_search with cross_file=True to find which ZIM files have relevant content.
  2. For each top hit, dispatch zim_get with view="summary" for a concise overview.
  3. Identify sub-topics worth exploring; ask the user which to pursue.

Step 1 call shape:

{
  "name": "zim_search",
  "arguments": {
    "query": "<topic>",
    "cross_file": true,
    "limit": 10
  }
}

Step 2 call shape (per hit):

{
  "name": "zim_get",
  "arguments": {
    "zim_file_path": "<from step 1 result>",
    "entry_path": "<from step 1 result>",
    "view": "summary"
  }
}

Use case: “I want to research X but I don’t know which archive has the best material.”

/summarize#

Signature: summarize(zim_file_path: str, entry_path: str).

Three-part article summary. Workflow:

  1. zim_get with view="toc" for the structural overview.
  2. zim_get with view="summary" for the lead-paragraph summary.
  3. zim_links with direction="outbound" for the most-mentioned related entries.

Combined into: (a) one-paragraph TL;DR, (b) section list, (c) 5–10 most relevant outbound links.

Step 1 call shape:

{
  "name": "zim_get",
  "arguments": {
    "zim_file_path": "<arg>",
    "entry_path": "<arg>",
    "view": "toc"
  }
}

Step 2 call shape:

{
  "name": "zim_get",
  "arguments": {
    "zim_file_path": "<arg>",
    "entry_path": "<arg>",
    "view": "summary"
  }
}

Step 3 call shape:

{
  "name": "zim_links",
  "arguments": {
    "zim_file_path": "<arg>",
    "entry_path": "<arg>",
    "direction": "outbound"
  }
}

/explore#

Signature: explore(zim_file_path: str).

High-level briefing of one ZIM file. Workflow:

  1. zim_metadata — title, language, creator, flavour, plus the deterministic namespace breakdown (surfaces minority namespaces — M, W, X).
  2. zim_get with main_page=True — the entry point.
  3. zim_browse with mode="walk", namespace="C", limit=5 — sample article content.

Step 1 call shape:

{
  "name": "zim_metadata",
  "arguments": {
    "zim_file_path": "<arg>"
  }
}

Step 2 call shape:

{
  "name": "zim_get",
  "arguments": {
    "zim_file_path": "<arg>",
    "main_page": true
  }
}

Step 3 call shape:

{
  "name": "zim_browse",
  "arguments": {
    "zim_file_path": "<arg>",
    "namespace": "C",
    "mode": "walk",
    "limit": 5
  }
}

Produces a compact briefing of what the archive is, what it covers, and what typical content looks like.

When to use prompts vs raw tool calls#

If your client supports MCP prompts, prefer them — they save the LLM from having to remember the orchestration. If you’re building your own client or your host doesn’t surface prompts, the same workflows are easy to call directly with the underlying tools.

Argument completion#

The server answers completion/complete, so a client that supports it offers real choices instead of a free-text box for the arguments that name an archive:

What you’re filling inWhat you get offered
zim_file_path on /summarize or /explorefull paths of the archives in your allowed directories
{name} in the zim://{name} resource URIbare basenames, without the .zim extension

The two are deliberately different strings — a path in a zim://{name} URI would not resolve. Suggestions filter as you type, matching either the whole path or just the filename, so typing wikipedia finds /srv/zim/wikipedia_en.zim. The listing is read fresh on each request, so an archive you just dropped into a watched directory shows up immediately.

Anything with an unbounded answer returns nothing rather than guessing: a /research topic is free text, and an entry path inside an archive can run to millions of rows. Responses are capped at the protocol’s 100-value page, with the true count in total.


Subscriptions#

The 2026-07-28 protocol revision removed resources/subscribe. A client now opts in with one long-lived subscriptions/listen request naming the notification kinds it wants, and the response is the stream. The SDK owns the listener registry, per-stream filtering, backpressure, and teardown; OpenZIM MCP only decides when a ZIM file changed and publishes it.

The watcher runs under the HTTP transport (--transport http) only, so that is the only transport on which anything is published — and the advertisement follows the same gate. At 2026-07-28 there is no initialize handshake: server/discover reports capabilities instead, and resources.subscribe plus the listChanged flags derive from subscriptions/listen being served. The SDK registers that handler unconditionally, so the server deregisters it outside HTTP-with-subscriptions-enabled: on stdio and SSE (and with OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false) the flags read false and a listen request fails with method-not-found, rather than being acknowledged and staying silent forever. The capability is only ever advertised where events can actually fire.

What the server publishes#

Change on diskNotificationOpt in with
A .zim file appears in or disappears from an allowed directorynotifications/resources/list_changedresourcesListChanged: true
A .zim file is replaced in placenotifications/resources/updated for zim://{name}resourceSubscriptions: ["zim://{name}"]

{name} is the basename without .zim; the template expansion percent-encodes it (e.g. zim://wikipedia_es_ni%C3%B1os), and that canonical form is the recommended subscription target. Delivery matches URI strings exactly, but every spelling that reads also notifies: a replacement is published under the raw stem, the percent-encoded stem, and both .zim-suffixed name forms (the spelling the zim://files listing advertises), so whichever string a client successfully read from is also the string that fires. Detection compares both mtime and size: same-size archive replacement (common when re-downloading from Kiwix) is caught by the mtime change, and same-mtime rewrites by the size change. Allowed directories are scanned recursively, so ZIMs in subdirectories are watched too.

zim://files is not a resourceSubscriptions target. Membership changes arrive as notifications/resources/list_changed, which carries no URI; a stream that lists zim://files under resourceSubscriptions is acknowledged and then never hears anything. Re-read zim://files on each list_changed. (Before 2026-07-28 both kinds of change were flattened into an updated for zim://files, because resources/subscribe gave clients no way to ask about list membership.)

toolsListChanged and promptsListChanged are accepted and acknowledged, but OpenZIM MCP never fires them — the tool and prompt surfaces are fixed at startup by --mode.

Configuration#

Env varDefaultRangeNotes
OPENZIM_MCP_SUBSCRIPTIONS_ENABLEDtrueboolmaster switch — false skips the watcher, withholds the subscribe/listChanged capability flags, and makes subscriptions/listen fail with method-not-found
OPENZIM_MCP_WATCH_INTERVAL_SECONDS51-60polling interval; the watcher rescans allowed directories on this cadence
OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS36000-86400how long a client may reuse a cached read of a zim://{name} overview; 0 disables the override and puts it back on the watcher-bounded TTL (entry reads always stay there)

For production HTTP services with low-priority watching, increase to 15-30s to reduce I/O churn. For interactive desktop use, the default is fine.

Read caching and freshness#

Every result carries the 2026-07-28 ttlMs/cacheScope fields, so a client knows how long it may reuse an answer rather than re-asking. Reads split in two:

URITTLWhy
zim://{name}OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS (default 1h) — but an error body for this URI drops to the watcher intervala ZIM file is sealed, and a replacement publishes resources/updated for exactly this URI, so subscribed clients never go stale. A failed read is not sealed, so it is not promised for an hour
zim://{name}/entry/{path}OPENZIM_MCP_WATCH_INTERVAL_SECONDS (default 5s)no resources/updated is ever published for an entry URI, so a longer promise could not be invalidated
zim://filesOPENZIM_MCP_WATCH_INTERVAL_SECONDS (default 5s)a live directory scan; never staler than the server’s own detection latency

cacheScope is private throughout: these payloads embed server-local absolute paths and configuration, so a shared intermediary must not serve one caller’s response to another.

The long TTL and subscriptions are designed to work together. A client on an open subscriptions/listen stream hears about a replacement immediately and re-reads, so the TTL never costs it freshness. A client that is not subscribed — anything on stdio, or an HTTP client that never opened a stream — can serve a stale overview for up to the TTL after a replacement. Lower it, or set 0, if you swap archives in place and cannot rely on subscribers.

Legacy clients see none of these fields: the SDK stamps them only for sessions opened on the 2026-07-28 revision, since ttlMs has no meaning in the era that preceded it.

Lifespan integration#

Under the streamable-HTTP transport the watcher is started/stopped via a lifespan-context wrapper around the app the SDK’s streamable_http_app() returns. (That app supplies its own Starlette lifespan, so add_event_handler('startup', …) is silently a no-op — the wrapper is the only path that works.)

Delivery and limits#

Delivery belongs to the SDK’s ListenHandler. OpenZIM MCP hands each detected change to a SubscriptionBus and never touches a client connection itself.

  • Each listen stream has its own buffer, so one stalled consumer neither delays another stream nor blocks the watcher.
  • Concurrent listen streams are capped (max_subscriptions, 1024 by default). Past the cap a further subscriptions/listen is rejected with an error before it is acknowledged.
  • Each stream buffers at most max_buffered_events undelivered events (1024 by default). A stream whose client stopped reading and overran that backlog is ended rather than allowed to grow.
  • There is no replay and no resumption. Every event is a level trigger (“this changed, re-read if you care”), so a client whose stream dropped re-listens and re-reads; nothing is lost that the backlog wasn’t already losing.
  • A stream’s requested URI set is bounded by the server before it is acknowledged: at most 256 resourceSubscriptions URIs per stream, each at most 2048 characters. An oversized set is rejected with INVALID_PARAMS — fix the request, unlike the concurrency cap above, whose rejection means try again later. Carried over from the pre-2026 registry’s admission control, and far above any real client: 256 distinct URIs is a client watching 255 archives at once.
  • The two stream caps are the SDK’s defaults — OpenZIM MCP does not override them — and none of these limits are exposed as environment variables.

Client example#

Open the stream, naming both kinds:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": {
    "notifications": {
      "resourcesListChanged": true,
      "resourceSubscriptions": ["zim://wikipedia_en_100_2026-02"]
    }
  }
}

The first frame is the acknowledgement, echoing the filter the server agreed to honor. Every frame on the stream — this one included — carries the listen request’s JSON-RPC id under _meta["io.modelcontextprotocol/subscriptionId"]:

{
  "jsonrpc": "2.0",
  "method": "notifications/subscriptions/acknowledged",
  "params": {
    "_meta": { "io.modelcontextprotocol/subscriptionId": 1 },
    "notifications": {
      "resourcesListChanged": true,
      "resourceSubscriptions": ["zim://wikipedia_en_100_2026-02"]
    }
  }
}

A .zim file added to or removed from a watched directory:

{
  "jsonrpc": "2.0",
  "method": "notifications/resources/list_changed",
  "params": { "_meta": { "io.modelcontextprotocol/subscriptionId": 1 } }
}

That archive replaced in place:

{
  "jsonrpc": "2.0",
  "method": "notifications/resources/updated",
  "params": {
    "_meta": { "io.modelcontextprotocol/subscriptionId": 1 },
    "uri": "zim://wikipedia_en_100_2026-02"
  }
}

Re-read on each notification to refresh your local view: resources/list after a list_changed, resources/read on the named URI after an updated. Specifics depend on your MCP client SDK — check its listen API for the idiomatic wrapper.

Legacy clients#

The SDK serves the pre-2026-07-28 handshake era on the same endpoint, so a 2025-era client still initializes, carries an Mcp-Session-Id, and calls tools, resources, and prompts exactly as before. It cannot receive change notifications, though: resources/subscribe and resources/unsubscribe are not registered on this build, so they answer -32601 Method not found, and resources.subscribe is advertised as false to a handshake-era client. Live updates require negotiating 2026-07-28 and calling subscriptions/listen.

Implementation note#

Subscription delivery is the SDK’s — the registry, the fan-out, and the capability advertisement this project used to hand-roll are first-class in v2 — but five private-API dependencies remain: four with a retirement condition, and one that is permanent by design. Delivery runs through a project subclass of the SDK’s ListenHandler that enforces the per-stream URI bounds above; it is installed (and, when subscriptions are disabled, the stock handler withheld) by writing into the lowlevel server’s private handler registry, until a public hook for either exists. On the resource side, the MIME-aware per-entry template is installed straight into the resource manager’s _templates map, because the public decorator freezes mime_type at registration time and this resource has to choose it per read. The per-URI TTLs in the table above come from overriding the SDK’s private read-resource handler, the only layer that can tell a sealed archive from a live directory scan; if a future SDK stops routing through it the TTL tests fail rather than the hint silently reverting. sdk_compat.py wraps the SDK’s stdio transport too, whose reader drops undecodable and null-id frames instead of answering them and cancels in-flight requests the moment stdin closes; a canary fails the day the SDK answers those frames itself, which is when that wrapper goes. The fifth has no such day coming: sdk_compat.py patches the dicts under the SDK’s per-version method tables so 2026-07-28 clients can ping, and that revision drops ping deliberately — python-sdk#3273 was closed as intended spec behaviour — so the shim is a standing deviation kept for clients that ping on a keepalive timer, and its canary pins upstream’s stance rather than counting down to a fix (keep-or-drop was settled in issue #371: kept permanently, because dropping it would regress clients that ping on a timer and save nothing). All five are why the dependency pin holds mcp to 2.0.x — raising the ceiling is a deliberate bump that re-audits each seam.


API reference for tools: API reference. HTTP transport details: HTTP and Docker Deployment. Configuration knobs: Configuration.

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 ↗