API reference
OpenZIM MCP exposes three kinds of MCP surfaces:
| Surface | Count (advanced mode) | Default mode |
|---|---|---|
| Tools (callable functions) | 8 | only zim_query exposed in Simple mode |
| Prompts (slash-command workflows) | 3 | Advanced mode only |
| Resources (URI-addressable data) | 1 resource + 2 templates, plus subscriptions | Advanced mode only (change notifications additionally require the HTTP transport) |
In Simple mode (the default) only the zim_query natural-language tool is exposed. Pass --mode advanced (or set OPENZIM_MCP_TOOL_MODE=advanced) to expose all 8 specialized tools below.
v2.0.0 collapsed the prior 22-tool advanced surface into 8 consolidated tools. The full mechanical v1 → v2 mapping is reproduced in the migration table at the bottom of this page; it also lives in CHANGELOG.md.
Source of truth: openzim_mcp/tools/. If signatures here disagree with code, file an issue — code is canonical.
Output format#
Tools return one of:
- JSON payload responses (most tools) — typed payloads (
SearchResponse,ArchiveMetadataResponse,ServerHealthResponse, etc.) serialized as JSON text inside aTextContentblock. All 8 tools are annotated-> Any, so the SDK derives nooutputSchemaand nothing arrives instructuredContent— parse the text block as JSON. - Markdown-string responses —
zim_queryrenders article bodies as a string that opens with the title as an#heading, then aPath:line and aType:line, and then a## Contentblock (when smart retrieval resolved somewhere else, aRequested Path:/Actual Path:pair replaces the singlePath:line). This rendering is reachable only throughzim_query;zim_getreturns the JSON payload described under its own entry. - Tool errors — every tool catches exceptions and returns a structured
ToolErrorPayload({error: true, operation, message, context?}) rather than raising. The exception iszim_queryon its default path, and only partly: its guidance failures — no archive specified, empty query, meta-only query, chained query — return markdown withisError: false, but azim_file_pathmatching no archive returns azim_path_not_foundenvelope and any other exception returns azim_queryenvelope, both withisError: true. There is nostatuskey and nohintkey. Onlyerror,operation, andmessageare guaranteed; individual failures may merge extra self-correction keys into the same envelope (asection_not_founderror, for example, also carriesavailable_section_ids,available_section_ids_truncated,available_section_ids_total, andclosest_match). The envelope arrives as JSON text in the response’scontent— no tool advertises anoutputSchema, so nothing lands instructuredContent— andopenzim_mcp/mcp_envelope.pyrecognises it on the way out and setsisError: trueon theCallToolResult. Path entries inside errors are redacted to...filename.zimform so the canonical allowed-directory layout is never leaked.
The _meta envelope#
Every dict-returning response carries a _meta key. It is the
machine-readable answer to “why is this response empty, partial, or smaller
than I expected”.
Simple mode does not give you the key.
zim_queryreturns a string on its ordinary paths, and_metais built only to render the one-line footer appended to it. So in simple mode the footer is the sole carrier: settingOPENZIM_MCP_META__FOOTER_ENABLED=falseremoves the information entirely rather than merely hiding a line. Advanced-mode tools return dicts and carry the real key.
Always present:
| Key | Meaning |
|---|---|
chars | Character count of the body |
truncated | Whether the body was cut to fit a cap. Always present, false when nothing was cut — do not test for the key’s presence |
Present when they apply:
| Key | Meaning |
|---|---|
tokens_est | Estimated tokens in the body. Omitted when the tokenizer cannot initialise, deliberately, so a model can tell “zero tokens” from “no tokenizer” |
more_at_offset | The content_offset that continues from where this response stopped |
total_chars | Full length of the underlying body, of which you received chars |
suggestions | Up to 5 recovery hints as {type, value} rows (e.g. {"type": "alt_spelling", "value": "Photosynthesis"}). The footer renders the first 3 |
reason | Why the response is empty or partial — the taxonomy below |
hint | Free-text next step, on lookups whose only continuation is a larger limit rather than an offset (suggest, title search) |
detected_type / detection_confidence | Archive-type detection on metadata responses — e.g. "wikipedia" / "high" |
preset_applied | Named archive preset that shaped a search or summary response |
reason values and what to do about them#
A reason means the call succeeded and the answer is thin. Retrying the
identical call will return the identical thin answer — each value implies a
different next move.
reason | What happened | Recovery |
|---|---|---|
0_hits | The index was searched and matched nothing | Broaden the query, or use zim_search(mode="title") for an exact title |
low_relevance | Hits came back, but none token-match the query | Same as 0_hits — treat it as a miss, not a ranking problem |
bad_query | The query could not be parsed into anything searchable | Rewrite it; punctuation-only and stop-word-only queries land here |
no_xapian_index | This archive ships no full-text index | Full-text search cannot work here at all. Use zim_search(mode="title") or zim_browse |
bad_namespace | The namespace does not exist in this archive | Call zim_metadata for the real namespace list — old and new archives use different schemes |
no_content_type_match | The content_type filter excluded everything | Drop the filter, or widen it |
sample_only | The page exhausted a sampled discovery, so done is deliberately held at false (and a next_cursor is still issued) rather than claiming completeness | Switch to zim_browse(mode="walk") for exhaustive iteration. total here is the sample size, not the namespace size |
archive_unavailable | In a cross_file fan-out, every archive failed | Infrastructural, not a query problem — check mounts and zim_health |
search_all_budget_exceeded | The aggregate cross_file timeout fired before every archive was searched | Partial results. Raise OPENZIM_MCP_SEARCH__SEARCH_ALL_TOTAL_TIMEOUT_SECONDS, or narrow the archive set |
scan_truncated | A link scan stopped before reading the whole article | Totals are a floor, not a count |
suggestion_total_is_lower_bound | A suggestion listing did not reach the end of the index | total undercounts; raise limit |
The last three are the ones worth special-casing in client code: sample_only, scan_truncated and suggestion_total_is_lower_bound all ride responses that otherwise look complete — sample_only even keeps done: false on a page that already returned everything the sample held. The
first nine also shape the simple-mode footer; the last two only appear in
_meta.
The rendered footer#
When OPENZIM_MCP_META__FOOTER_ENABLED is true (the default), simple-mode
responses get a one-line markdown blockquote built from _meta and appended
to the body — a token-budget summary normally, or the recovery hint when a
reason is set. It also requires compact=True, which is the simple-mode
default.
Setting it to false suppresses the line. On an advanced-mode dict response
that costs you nothing, because _meta is a real key there; in simple mode it
is the only channel, so the information is gone.
Simple mode#
zim_query#
Single natural-language tool exposed by default. Routes to the underlying advanced operations via an intent parser.
Signature:
zim_query(
query: str,
zim_file_path: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
content_offset: int = 0,
cursor: Optional[str] = None,
max_content_length: Optional[int] = None,
compact: bool = True,
compact_budget: Optional[Union[str, int]] = None,
synthesize: bool = False,
) -> Any
At runtime the tool returns a string for ordinary calls (markdown for every intent except list available ZIM files, which embeds a JSON array in the string), a SynthesizeResponse dict when synthesize=True, and a ToolErrorPayload dict when it rejects an argument, hits the rate limit, cannot decode a cursor, or the synthesize pipeline fails. The -> Any annotation is deliberate — it keeps the SDK from deriving an outputSchema.
| Parameter | Type | Default | Notes |
|---|---|---|---|
query | string | (required) | Natural-language question or instruction |
zim_file_path | string | None | Auto-selects when only one ZIM is in the allowed dirs |
limit | int | None | Max results for search/browse intents |
offset | int | 0 | Pagination offset |
content_offset | int | 0 | Pagination within long article body |
cursor | string | None | Opaque cursor for resuming paginated results |
max_content_length | int | None | Max characters for retrieved articles |
compact | bool | True | Compact prose rendering (token-budget aware) |
compact_budget | str | int | None | Override the compact-mode budget. Named profiles: tiny (2000), small (4000), medium (6000), large (12000). Integers are clamped to 500–64000. An unrecognised string silently falls back to medium |
synthesize | bool | False | When True, return a SynthesizeResponse (multi-source briefing) |
Recognized phrasings#
In simple mode zim_query is the entire tool surface, so the phrasings it
recognizes are the API. There are 21 of them. The parser scores every
pattern and dispatches the most specific match, so wording matters more than
it looks — the traps are called out below the table.
| Intent | Say something like | You get back | Default limit |
|---|---|---|---|
| List archives | “list available ZIM files”, “show archives” | Every loaded archive, as JSON embedded in the string | — |
| Main page | “show main page”, “home page” | The archive’s front page | — |
| List namespaces | “list namespaces”, “what namespaces” | The archive’s namespace breakdown | — |
| Archive metadata | “metadata for wikipedia.zim”, “details about this archive” | Title, language, creator, index capabilities, counts | — |
| Fetch an article | “get article Evolution”, “show the page Photosynthesis” | The rendered article body | — |
| Topic lookup | “tell me about photosynthesis”, “everything about ants” | Auto-fetches on a strong title match, otherwise searches | 3 (a cap, not a default) |
| Full-text search | “search for biology”, “find quantum entanglement” | Ranked hits | 10 |
| Cross-archive search | “search all files for python”, “search everywhere for ants” | One result block per archive | 5 per archive |
| Filtered search | “search protein in namespace C” | Hits restricted to a namespace or content type | 10 |
| Title lookup | “find article titled Photosynthesis”, “what’s the path for Ant” | The resolved entry path | 10 |
| Autocomplete | “suggestions for photo”, “autocomplete diab” | Title-prefix completions | 10 (hard max 50) |
| Section outline | “show structure of Biology”, “outline of Protein” | Headings and section anchors | — |
| Table of contents | “table of contents for Evolution”, “toc of Ant” | The hierarchical TOC | — |
| Summary | “summary of Quantum_mechanics”, “summarize Protein” | Opening-paragraph summary | — |
| One section | “the Biochemistry section of Protein”, “section Synthesis of Protein” | That section’s body | — |
| Outbound links | “links in Ant”, “references from Protein” | Links extracted from the body | 25 per bucket (100 if compact=False) |
| Related articles | “articles related to Climate_Change”, “what links to Ant” | Link-graph neighbours | 10 |
| Browse a namespace | “browse namespace C with limit 10” | A sampled page of entries | 50 |
| Walk a namespace | “walk namespace M”, “enumerate namespace C” | Deterministic cursor-paginated iteration | 200 |
| Batch fetch | “get articles C/Ant, C/Bee” | Several entries in one call. The values must be path-shaped — bare titles reach the intent but extract nothing and return “Missing Entry Paths” | — |
| Binary content | “get image Logo.png”, “download pdf Manual.pdf”, “raw content for File.pdf” | Base64 payload with its native MIME type | — |
Phrasings that trip people up:
- Metadata needs the literal word. “metadata for X” and “details about X.zim” match; “tell me about X.zim” does not — it routes to a topic lookup and tries to fetch an article named after the file.
- A section needs one of three shapes:
section <name> of <path>,the <name> section of <path>, or<path> section <name>as a trailing suffix. Anything else scores lower thanstructureand you get the outline instead of the section. - “related” is overloaded. “links in X” gives outbound links from the body; “articles related to X” gives link-graph neighbours. “related to X” alone lands on the latter.
- The binary verb must sit next to the noun. “get image Logo.png” works; “get the image Logo.png” does not — the article breaks the pattern and the query falls through to full-text search. That adjacency rule is not special to binary: a determiner or quantifier between verb and noun drops most patterns to a literal search at confidence 0.50. “list all zim files”, “list the namespaces”, “walk the namespace A”, “browse the A namespace” and “show homepage” (one word) all search for their own wording.
- Two patterns outrank the one you meant. Browsing scores 0.85 and article fetch 0.80, so “show article Evolution” becomes a namespace browse, discards the title, and answers Missing or Invalid Namespace. Separately, the bare word “contents” is a table-of-contents trigger, so “show contents of namespace C” becomes a TOC lookup for an article called “namespace c”.
- Only two phrasings read an archive name out of the query:
metadata for <file>.zimandsearch <name> for <terms>(a unique basename prefix, three characters minimum, and the name must come beforefor). Every other form discards it silently — “walk namespace M in wikipedia.zim” parses as a walk ofMwith the filename dropped, which auto-selection hides while one archive is loaded and which fails with No ZIM File Specified once two are. Passzim_file_pathas an argument instead of naming it in the sentence. - Chain detection is driven by a verb list, not by the word “then”.
“search for X, then get article Y” returns Chained Operations Detected
guidance; “then fetch Y” is not recognised and runs as one literal
search.
andis not a connector at all: “search for climate and get article Climate crisis” runs the fetch and drops the search.
Pagination differs per intent: suggestions for, find article titled and
articles related to do not page at all — raise limit rather than passing
offset. Every response also ends with <!-- intent=<name> cert=<0.NN> -->,
which is the quickest way to see what a surprising answer actually dispatched
to; a cert of 0.50 almost always means no pattern matched and your wording
was searched literally.
Returns: intent-specific output — a markdown list for searches, a rendered article for retrievals, a SynthesizeResponse when synthesize=True. On the default (non-synthesize) path, the guidance failures — no archive specified, empty query, meta-only query, chained query — come back as markdown rather than an error envelope, so they carry isError: false. A zim_file_path that matches no loaded archive is not one of them: it returns a zim_path_not_found envelope, and any other exception the catch-all absorbs returns a zim_query envelope. Both set isError: true. The synthesize=True path instead refuses bad paths, meta-only queries, and chained queries with a ToolErrorPayload, so those do set isError: true.
synthesize=True — cross-archive briefings#
zim_query(synthesize=True, ...) is a different operation wearing the same
tool name. Instead of routing to one underlying call, it runs a retrieval
pipeline across every allowed archive and returns a cited briefing. It is
the only cross-archive answering path in the product, and the only rich
capability a simple-mode client can reach.
Read this before using it: answer_markdown is not generated prose.
It is the selected source passages concatenated verbatim, each followed by an
inline [cite: <cite_id>] marker. Nothing is paraphrased, summarised or
written by a model — the server has no model. Treat it as a pre-assembled
evidence bundle to hand your own LLM, not as an answer to display.
The pipeline: per-archive search → RRF fusion across archives → passage extraction → optional cross-encoder rerank (only with the [reranker] extra) → section attribution and heading-affinity boost → budget enforcement → answer rendering → citation building.
Response (SynthesizeResponse):
| Key | Meaning |
|---|---|
query | The query as received |
answer_markdown | Concatenated source passages with [cite: …] markers |
passages | {cite_id, text_markdown, rank, score} per passage. Dropped in compact mode, which moves rank/score onto the citations instead |
citations | {cite_id, archive, entry_path, title, section_id, section_title, rank, score} — entry_path is what you pass to zim_get |
archives_searched | Which archives actually contributed |
fallback_used | The fusion stage that ordered the hits: rrf_fusion (multiple archives) or xapian_score (single archive, or fusion unavailable). Not an error, and not a reranker signal — the cross-encoder runs after fusion and never changes this field, so the schema’s third literal reranker is never emitted |
total_chars / total_words | Size of the assembled answer |
considered_articles / considered_sections | Pivot handles for a follow-up turn without re-running the search: the other top hits minus the top passage’s own article, and that article’s other sections. Only the top passage’s article and section are excluded, so lower-ranked cited articles still appear here. Empty when there was no candidate space |
_meta | The usual envelope |
Tuning — all soft budgets under OPENZIM_MCP_SYNTHESIZE__:
| Field | Default | Effect |
|---|---|---|
top_n | 5 | Passages in the final answer |
per_archive_k | 10 | Hits taken from each archive before fusion |
output_char_budget | 4800 | Soft cap on answer_markdown (~1200 tokens). Truncates the last passage rather than dropping it |
section_affinity_threshold | 0.25 | Heading-overlap ratio before a section-attributed passage is boosted |
section_affinity_boost | 1.5 | Multiplier when it is. Deliberately conservative — it will not dominate a strong BM25 hit |
max_secondary_archive_hits | 2 | Cap per non-primary archive, bounding cross-archive flooding. 0 restricts to the primary archive |
Unlike the default path, this one does return error envelopes: bad paths,
meta-only queries and chained queries are refused with isError: true rather
than answered with markdown guidance.
Query rewriting#
Before intent matching, zim_query rewrites the text you sent. The rewrite is silent — nothing in
the response says it happened — so it is worth knowing what it changes.
Three normalisations always run, and are not covered by the kill switch below: whitespace is
collapsed, a leaked limit=10 / offset=5 fragment is stripped out of the prose, and trailing
politeness (please, thanks) is removed.
Then four rules run in order:
| Rule | What it does |
|---|---|
| Lowercase | The whole query is lowercased. This is why topics come back lowercased, and it emits no telemetry at all |
| Misspelling map | Token-wise substitution from a bundled 40-entry map, retried after stripping surrounding punctuation and a possessive 's. It is probe-gated: a token that resolves to a real title in the archive is left alone |
| Stopword strip | Removes one leading the, a, an or of. Also probe-gated — the article is kept when the full query resolves to a real title |
X of Y decomposition | <attr> of <entity> and <entity>'s <attr> are split so the entity drives the lookup |
The misspelling rule changes which article you get, with no note in the response: on a Wikipedia
archive, photosythesis retrieves the artificial-photosynthesis article and the reply contains
no trace of the original spelling. The probe gate is archive-scoped, so the same query can rewrite
differently depending on how many archives are loaded — with two loaded and none pinned, the probe
cannot run and the rule fires unconditionally.
Three configuration fields control it, under OPENZIM_MCP_QUERY_REWRITE__:
| Field | Default | Notes |
|---|---|---|
enabled | true | false turns off all four rules on the zim_query path, lowercasing included |
misspelling_map_path | unset | Replaces the bundled map rather than extending it — a one-line file disables all 40 built-in substitutions |
misspelling_exclusion_path | unset | Tokens never substituted. The bundled list is empty |
Either path pointing at a missing file fails at startup with a pydantic validation error naming the resolved path, rather than falling back silently.
One gap to know about: enabled=false does not reach zim_search(mode="title"), which calls
the misspelling and stopword rules directly without consulting the configuration. Title-mode
search always rewrites, applies only those two rules, and never probes first — though it does echo
the rewritten string back in the response’s query field, so the change is at least observable
there.
Advanced tools (8)#
zim_search#
Full-text / title / suggest search dispatch. Collapses five v1 search tools (search_zim_file, search_all, search_with_filters, find_entry_by_title, get_search_suggestions) into one.
zim_search(
query: str,
mode: Literal["fulltext", "title", "suggest"] = "fulltext",
zim_file_path: Optional[str] = None,
cross_file: bool = False,
namespace: Optional[str] = None,
content_type: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
cursor: Optional[str] = None,
) -> Any
| Parameter | Notes |
|---|---|
query | Required search term, title, or partial-query prefix (depending on mode) |
mode | "fulltext" (default; libzim full-text index), "title" (title-indexed lookup with fast C/<Title> path), or "suggest" (auto-complete prefix) |
zim_file_path | Required unless only one ZIM is in the allowed dirs. Must be omitted when cross_file=True — passing both returns an invalid_combination envelope |
cross_file | When True, queries every allowed ZIM file. The SearchAllResponse carries one row per file in results; each row’s result is that file’s SearchResponse |
namespace, content_type | Optional filters (only meaningful for mode="fulltext") |
limit | 1–1000 for plain single-archive fulltext; 1–100 when namespace/content_type filters are set; 1–50 for cross_file=True and for title/suggest |
cursor | Accepted for surface uniformity but rejected when non-empty (invalid_combination envelope) — paginate single-archive fulltext with offset instead |
Returns: mode-shaped response — SearchResponse / SearchAllResponse / SearchWithFiltersResponse / FindEntryResponse / SearchSuggestionsResponse — or ToolErrorPayload on validation failure. Every next_cursor in these responses is nulled; page with offset.
zim_get#
Single-entry / batch / binary / main-page / view-mode entry fetch. Collapses seven v1 retrieval tools into one.
zim_get(
zim_file_path: str,
entry_path: Optional[str] = None,
entry_paths: Optional[List[str]] = None,
view: Literal["full", "summary", "toc", "structure"] = "full",
binary: bool = False,
main_page: bool = False,
max_content_length: Optional[int] = None,
content_offset: int = 0,
compact: bool = False,
compact_budget: Optional[Union[str, int]] = None,
) -> Any
Exactly one of these four branch selectors must be set:
| Branch | Selector | Returns |
|---|---|---|
| Single-entry (article body) | entry_path="..." | EntryResponse dict — {path, title, content, _meta} plus content_type, requested_path, content_offset and total_chars when they apply |
| Single-entry view modes | entry_path="..." + view="summary" / "toc" / "structure" | Structured response (summary / TOC tree / headings) |
| Batch | entry_paths=[...] (up to 50) | {results, succeeded, failed} — per-entry success/error |
| Binary | entry_path="..." + binary=True | {path, title, mime_type, size, encoding, data} (base64) |
| Main page | main_page=True | Archive main page entry |
The four branches are mutually exclusive. Setting more than one (e.g. entry_path + entry_paths, or main_page + view="summary") returns a ToolErrorPayload with operation="invalid_path_combination".
| Parameter | Notes |
|---|---|
view | "full" (default; article body), "summary" (opening paragraph), "toc" (hierarchical TOC), "structure" (headings + section anchors). Combining a non-"full" view with binary=True or main_page=True is rejected with operation="invalid_path_combination", not silently ignored |
binary | When True, returns raw bytes (base64) with native MIME type. Default per-entry cap 10,000,000 bytes (10 MB decimal, not 10 MiB) |
max_content_length | Per-entry char cap, min 1 (the OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH config field has its own ≥ 100 floor) |
content_offset | Page through long articles without re-fetching the prefix |
compact | Compact-mode prose (default False — preserves legacy byte-identical behavior; a default flip was considered and explicitly deferred for lack of adoption telemetry) |
compact_budget | Inert on this tool. It is declared in the signature but never forwarded, so it does not cap the response. Only zim_query honors it |
Smart retrieval: if direct path access fails, single-entry mode falls back through a five-step ladder (see Smart retrieval); resolved paths are cached. When fallback resolves to a different path, the response carries a requested_path key alongside path.
zim_get_section#
Section-level fetch by section ID. Renamed from the v1 get_section tool; the compact=True default is the notable behavioral change.
zim_get_section(
zim_file_path: str,
entry_path: str,
section_id: str,
max_chars: Optional[int] = None,
include_subsections: bool = True,
compact: bool = True,
compact_budget: Optional[Union[str, int]] = None,
) -> Any
| Parameter | Notes |
|---|---|
section_id | Required; the heading ID or anchor from a prior zim_get(view="toc") or zim_get(view="structure") response |
max_chars | Per-section char cap |
include_subsections | Default True — nested subsections are returned with the named section. Set False for just the section’s own body |
compact | Default True (compact rendering — [Table N: …] placeholders instead of full tables). Since v2.4.0, compact=False returns the true raw section body with full tables |
compact_budget | Inert on this tool — declared but never forwarded. Only zim_query honors it |
Returns: {entry_path, title, section_id, section_title, level, parent_id, content_markdown, char_count, word_count, truncated, _meta} — the section body plus its heading metadata, with a narrow_widened_to_first_child flag added when include_subsections=False had to widen into the first child. There are no next/previous-section keys; walk the outline from zim_get(view="toc") instead.
zim_browse#
Namespace browse / walk dispatch. Collapses the v1 browse_namespace + walk_namespace tools.
zim_browse(
zim_file_path: str,
namespace: str,
mode: Literal["page", "walk"] = "page",
cursor: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
include_assets: bool = False,
) -> Any
| Mode | Behavior |
|---|---|
"page" (default) | Sampled namespace overview, paginated by limit + offset. For very large namespaces may cap entries — use mode="walk" for exhaustive iteration |
"walk" | Cursor-paginated deterministic iteration by entry ID. Pair next_cursor with a follow-up call until done: true |
| Parameter | Range | Notes |
|---|---|---|
namespace | C, M, W, X on modern archives; A, I, - on legacy ones. Which you get depends on the archive — see ZIM concepts and read the real breakdown from zim_metadata | |
limit | 1–200 (page) / 1–500 (walk) | Default 50 (page) / 200 (walk). page rejects anything above 200 |
cursor, offset | Both modes emit and accept a cursor. offset is the page-only parameter | |
include_assets | Browses of C-namespace pages on domain-scheme archives hide css/js/font assets by default in both modes; pass True to include them. Only the page_info.assets_filtered flag is mode="page"-only |
Returns: a paginated envelope for both modes — {results, next_cursor, total, done, page_info, namespace, …} — with next_cursor driving walk iteration until done: true.
zim_metadata#
Combined archive metadata + namespaces. Collapses the v1 get_zim_metadata + list_namespaces tools — the response now includes both the M-namespace metadata and the deterministic namespace breakdown.
zim_metadata(zim_file_path: str) -> Any
Returns: structured response with:
metadata— archive M-namespace fields, keyed exactly as libzim emits them, which is capitalized:Title,Language,Creator,Publisher,Date,Flavour, and so on.metadata["title"]raisesKeyError— usemetadata["Title"].namespaces— a deterministic namespace breakdown (surfaces minority namespaces —M,W,X,I— that random sampling could miss).archive_identity—{uuid, is_multipart}, the libzim archive identity (added in v2.1).index_capabilities—{has_fulltext_index, has_title_index}: whether full-text search and title suggestions will work against this archive (added in v2.1).counter_breakdown—{mimetype: count}parsed from theM/Countermetadata, so you can profile an archive’s content composition without walking it. Omitted when the archive has noM/Counterentry (added in v2.1).
zim_links#
Outbound / related link-graph dispatch. Collapses the v1 extract_article_links + get_related_articles tools.
zim_links(
zim_file_path: str,
entry_path: str,
direction: Literal["outbound", "inbound", "related"] = "outbound",
kind: Literal["internal", "external", "media"] = "internal",
cursor: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> Any
| Direction | Behavior |
|---|---|
"outbound" (default) | Links extracted from the article body — one kind per call ("internal" default; pass kind="external" or kind="media" for the other buckets; category_totals in the response counts four buckets — those three plus anchor, which no kind can fetch). Drops non-navigable schemes (javascript:, mailto:, tel:, data:, blob:, vbscript:) |
"inbound" | Pages that link TO this entry, ranked by linker importance. Requires a pre-built link-graph sidecar (see below) |
"related" | Outbound link-graph neighbors with deduplication |
cursor / offset apply to "outbound" and "inbound"; "related" returns a single ranked set and rejects a cursor.
direction="inbound" requires the link-graph sidecar to be built first: openzim-mcp build link-graph <archive>.zim walks the archive once and writes <archive>.zim.linkgraph.sqlite next to it (--force overwrites an existing sidecar; --output PATH relocates it). When the sidecar is absent or stale, zim_links returns a structured inbound_sidecar_unavailable error rather than failing. A sidecar is stale when the archive’s UUID no longer matches the one recorded at build time (it was rebuilt or replaced), or when its schema version predates the running server’s.
3.0.0 invalidates every sidecar built by 2.x. Edge targets are now stored under the path the archive can actually serve rather than the raw percent-encoded href, so the schema version was bumped and older sidecars are rejected on load. Rebuild each one with openzim-mcp build link-graph --force <archive>.zim; --force is required because the old file is still sitting next to the archive. Until then direction="inbound" returns inbound_sidecar_unavailable, and no other direction is affected.
Relative hrefs are resolved against the source entry’s directory; redirects are followed to resolved paths; the content namespace is identified correctly on domain-scheme archives; self-referential refs are rejected.
Returns: a paginated {results, next_cursor, total, done, page_info, …} envelope for every direction. direction="outbound" adds kind and category_totals alongside; "inbound" and "related" carry neither, and key the article as entry_path rather than path/title.
Outbound entries carry url/text/title (plus domain for external, alt for media). url is the raw href as written in the article — it does not necessarily round-trip into zim_get. Internal rows additionally carry path, the resolved entry path, and that is the one to pass to zim_get. Inbound entries are {path, title, inbound_degree, anchor_text}; related entries are {path, title, …} rows.
zim_health#
Two calls in one. With no argument, returns combined server health, configuration, and loaded archives (collapses the v1 get_server_health + get_server_configuration + list_zim_files tools). With a zim_file_path, validates and diagnoses that one archive instead (added in v2.1).
zim_health(zim_file_path: Optional[str] = None) -> Any
| Argument | Behavior |
|---|---|
| (omitted) | Combined server-state report: {health, configuration, loaded_archives, _meta}. |
zim_file_path | Per-archive integrity/identity check via libzim — runs Archive.check() and reports checksum, index capabilities, and identity. Lets a caller tell a valid archive from a corrupt one. |
Server-state response (no argument) — shape (abbreviated):
{
"health": {
"timestamp": "2026-05-27T15:30:00.000000+00:00",
"status": "healthy",
"server_name": "openzim-mcp",
"uptime_info": { "process_id": "[REDACTED]", "started_at": "..." },
"cache_performance": { "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": []
},
"configuration": {
"configuration": {
"server_name": "openzim-mcp",
"allowed_directories": ["...zim-files"],
"allowed_directories_count": 1,
"cache_enabled": true,
"cache_max_size": 100,
"config_hash": "<sha256>",
"server_pid": "[REDACTED]"
},
"diagnostics": { "validation_status": "ok", "warnings": [], "recommendations": [] },
"timestamp": "2026-05-27T15:30:00.000000+00:00"
},
"loaded_archives": [
{ "name": "wikipedia_en_100_2026-02.zim", "path": "...wikipedia_en_100_2026-02.zim", "size": "119.07 MB", "size_bytes": 124857600, "modified": "2026-02-15T10:30:00" }
]
}
Over the HTTP/SSE transports process_id / server_pid are "[REDACTED]" and allowed directories are shown as ...basename — diagnostic output frequently lands in bug reports. Over the local stdio transport these report the real PID and full paths, matching the unredacted loaded_archives[].path values that clients pass back to other tools.
The three blocks are built independently. If the health or configuration builder raises, that block alone is replaced by a ToolErrorPayload — {"error": true, "operation": "get server health" | "get server configuration", "message": ..., "context": ...}, the only two operation values in the codebase that are not snake_case — and the rest of the response, loaded_archives included, is still returned. The top-level dict is not an error envelope, so isError stays false; check .health and .configuration for an error key before reading their fields. A top-level envelope comes back only two ways — a rate-limit rejection (operation: "rate_limited"), or an exception escaping the tool body (operation: "zim_health"), which for the no-argument call means only the directory scan behind loaded_archives, since the other two builders swallow their own exceptions, and for the zim_file_path call below means any validation failure.
Archive-validation response (with zim_file_path, added in v2.1):
{
"is_valid": true,
"has_checksum": true,
"checksum": "<hex>",
"has_fulltext_index": true,
"has_title_index": true,
"uuid": "<archive uuid>",
"is_multipart": false,
"path": "...archive.zim",
"name": "archive.zim"
}
is_valid is the result of libzim’s Archive.check() structural-integrity probe — a quick way to tell a valid archive from a corrupt or truncated one. A non-indexed archive reports has_fulltext_index: false; full-text zim_search against it then degrades gracefully to a no_xapian_index reason instead of erroring.
MCP prompts#
Three slash-command workflows. See openzim_mcp/tools/prompts.py.
User-supplied arguments are sanitized: ASCII control characters are replaced with spaces, backticks are stripped (template delimiter), and the value is capped at 200 characters before being interpolated. Apostrophes and double quotes are preserved (real entry paths contain them, e.g. C/Schrödinger's_cat).
/research#
research(topic: str)
Workflow: zim_search(query=topic, cross_file=True) across archives, then zim_get(entry_path=..., view="summary") on the top hits, then ask the user which thread to pursue.
/summarize#
summarize(zim_file_path: str, entry_path: str)
Workflow: zim_get(view="toc") → zim_get(view="summary") → zim_links(direction="outbound"), combined into a TL;DR + section list + 5–10 most relevant outbound links.
/explore#
explore(zim_file_path: str)
Workflow: zim_metadata → zim_get(main_page=True) → zim_browse(namespace="C", mode="walk", limit=5). Produces a compact briefing.
If a prompt is invoked without required args (or args reduce to empty after sanitization), the response asks the user to supply them.
MCP resources#
One concrete resource (zim://files, listed by resources/list) plus two URI templates (listed by resources/templates/list). See openzim_mcp/tools/resource_tools.py.
zim://files#
JSON list of every ZIM file in the allowed directories. Same shape as the loaded_archives field of zim_health.
zim://{name}#
Overview of one ZIM file: metadata, namespace breakdown, and main-page preview (truncated to 2000 characters). {name} is the bare basename without .zim (e.g. wikipedia_en_climate_change_mini_2024-06).
zim://{name}/entry/{path}#
Single entry served with native MIME type:
- HTML / text entries →
text/html,text/plain,application/json, etc., body as text. - Binary entries (images, PDFs) → appropriate MIME, body as raw bytes (the SDK base64-wraps).
Encoding requirement: clients MUST URL-encode / as %2F in the {path} segment because the SDK’s URI template engine treats / as a segment separator. Example:
zim://wikipedia_en/entry/A%2FClimate_change
A literal slash will fail to route. See the Resources, prompts & subscriptions guide for full details.
Resource subscriptions#
Clients open one long-lived subscriptions/listen stream naming the notification kinds they want (resources/subscribe was removed by the 2026-07-28 revision). Under the HTTP transport (--transport http) the server then publishes:
notifications/resources/list_changedwhen a.zimfile is added to or removed from an allowed directory — request it withresourcesListChanged: truenotifications/resources/updatedforzim://{name}when that.zimfile’s mtime or size changes — request it withresourceSubscriptions: ["zim://{name}"]
subscriptions/listen is served only where the file watcher can run — the HTTP transport with subscriptions enabled. On stdio and SSE the capability is not advertised and a listen request fails with method-not-found instead of acknowledging a stream that would stay silent.
Configuration:
| Env var | Default | Notes |
|---|---|---|
OPENZIM_MCP_SUBSCRIPTIONS_ENABLED | true | master switch |
OPENZIM_MCP_WATCH_INTERVAL_SECONDS | 5 | 1–60 |
OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS | 3600 | 0–86400; read TTL for zim://{name} overviews (entry reads keep the watcher-bounded TTL), 0 disables |
See Resources, prompts & subscriptions for full client-side examples.
Rate limiting#
All tools are subject to a global token-bucket limiter (default 20 work units/s, burst 40 — most operations cost 1 unit, searches 2, binary fetches 3). Costs are charged per internal operation, not per tool call — a v2 tool that dispatches over multiple modes resolves to a specific underlying operation key:
| Tool call | Internal operation | Cost |
|---|---|---|
zim_search(mode="fulltext") or zim_search(mode="title") | search / find_entry_by_title — a fulltext call carrying namespace or content_type keys on search_with_filters instead | 2 |
zim_search(mode="suggest") | suggestions | 1 |
zim_search(cross_file=True) | search (charged once per call, not per archive scanned) | 2 |
zim_get(entry_path=...) | get_entry | 1 |
zim_get(entry_paths=[...]) | get_zim_entries (per-entry charge) | N |
zim_get(binary=True) | get_binary_entry | 3 |
zim_get(view="structure") / view="toc" / view="summary" | get_structure | 1 |
zim_browse(mode="page") or zim_browse(mode="walk") | browse_namespace | 1 |
zim_metadata | get_metadata | 1 |
zim_links(direction="related") | get_related_articles | 2 |
zim_links(direction="outbound") | extract_article_links | 2 |
zim_links(direction="inbound") | get_inbound_links | 1 |
zim_health, zim_get_section, zim_query | charged under their own tool names (zim_query once per call) | 1 |
Tune via OPENZIM_MCP_RATE_LIMIT__REQUESTS_PER_SECOND, OPENZIM_MCP_RATE_LIMIT__BURST_SIZE, and OPENZIM_MCP_RATE_LIMIT__PER_OPERATION_LIMITS. See Configuration.
When the limit is exceeded the tool returns a ToolErrorPayload with operation: "rate_limited" instead of raising — and since 2.6.0 that envelope reaches the client as a CallToolResult with isError: true (see openzim_mcp/mcp_envelope.py). There is no retry_after field. The wait is stated in prose in message ("Rate limit exceeded for operation 'search'. Please wait 1.23 seconds before retrying.") and repeated in machine-readable form in context, which is always present on this error and formatted operation=<internal op>, cost=<N>, wait_time=<S>s.
Cursors#
A next_cursor is an opaque resume handle. “Opaque” is the contract — do not parse it — but four
properties govern whether a call using one will work.
Who issues and who accepts them. Three tools emit a cursor: zim_browse (both modes),
zim_links with direction="outbound", and zim_query for its browse and walk intents. Four
accept one: those three plus zim_search, which accepts the parameter and rejects every
non-empty value with invalid_combination — it never emits one either, and nulls any the data
layer mints. Paginate search with offset.
A cursor is bound to a tool, an archive and a context. It carries the issuing tool’s name, a
hash of the archive’s validated path (not its contents, so two identical copies at different
paths have different identities), and the context fields its tool needs — namespace and
include_assets for browse, entry path and link kind for links. Change any of those and the call
is refused rather than silently answering about something else. Cursors are interchangeable in
one direction: between zim_query and the advanced tool implementing the same operation, which
mint identical cursors.
Precedence is not symmetric. An explicit limit wins over the page size the cursor encodes —
the encoded size is only a fallback when you omit limit. An explicit offset, by contrast,
loses: when a cursor is present its offset is used and yours is ignored. So pass a cursor or an
offset, never both.
There is a length cap, on one surface. zim_query rejects a cursor over 2048 characters with
a cursor_decode envelope. The advanced tools have no length check.
When a cursor is refused#
operation | Cause | Recovery |
|---|---|---|
cursor_decode | Malformed, truncated, wrong version, or over zim_query’s 2048-character cap. Also zim_query’s catch-all — it reports every cursor fault under this one code | Drop the cursor and start the sequence again |
cursor_mismatch | Issued by a different tool. Advanced surface only | Drop it; a cursor is not portable across operations |
cursor_context_mismatch | Right tool, different target — another namespace, entry, link kind, or include_assets setting | Drop it and start over for the new target; a cursor cannot be retargeted |
cursor_unsupported | zim_links(direction="related"), which returns one ranked set and does not paginate | Omit cursor entirely |
invalid_combination | Any non-empty cursor on zim_search | Use offset — single-archive mode="fulltext" only |
“Drop the cursor and pass offset” is the usual recovery, but it does not work everywhere.
zim_search accepts offset only for single-archive mode="fulltext"; title, suggest and
cross_file=True reject it and have no pagination at all. And zim_browse(mode="walk") ignores
offset on the advanced surface — you get page one back while believing you advanced — so walk
iteration must be driven by its cursor. Where the substitution does work, the cursor page and the
equivalent offset page are the same rows.
Error responses#
Every tool wraps exceptions and returns a structured ToolErrorPayload — except zim_query’s default path, which returns markdown guidance for handler-side failures — delivered as JSON text in the response’s content — never in structuredContent, since no tool advertises an outputSchema — with isError: true set on the CallToolResult. error (always true), operation, and message are always present; context is added only when the tool supplies one. Three paths merge operation-specific self-correction keys: the section_not_found envelope returned by zim_get_section also carries available_section_ids (capped at 50), available_section_ids_truncated, and available_section_ids_total, plus closest_match when a near-miss section ID is found; an unknown_argument envelope carries unknown_arguments and accepted_arguments, plus closest_matches when a stray name is close to a real one; and an invalid_argument envelope carries invalid_arguments:
{
"error": true,
"operation": "invalid_path_combination",
"message": "`entry_path` and `entry_paths` are mutually exclusive."
}
The error classes in openzim_mcp/exceptions.py are the canonical source for the underlying exception hierarchy:
OpenZimMcpError— baseOpenZimMcpConfigurationErrorOpenZimMcpValidationErrorOpenZimMcpArchiveErrorOpenZimMcpRateLimitError
Absolute filesystem paths in error messages are redacted to ...filename.zim form on all transports. PIDs and paths in zim_health diagnostics are redacted over the HTTP/SSE transports and shown in full over local stdio. Error text is safe to copy into bug reports.
v1 → v2 migration#
The full mechanical mapping. Every v1 tool name in this table is intentional — it is the canonical place to look up “what does my old call become?”. For the narrative context see CHANGELOG.md → migration table.
| v1 call | v2 equivalent | Notes |
|---|---|---|
list_zim_files() | zim_health() → .loaded_archives | health/config/files consolidated |
get_server_health() | zim_health() → .health | health/config/files consolidated |
get_server_configuration() | zim_health() → .configuration | health/config/files consolidated |
get_zim_metadata(path) | zim_metadata(path) → .metadata | now includes namespace breakdown too |
list_namespaces(path) | zim_metadata(path) → .namespaces | now includes namespace breakdown too |
get_main_page(path) | zim_get(path, main_page=True) | one of four mutually-exclusive branches |
search_zim_file(path, q) | zim_search(q, zim_file_path=path) | default mode="fulltext" |
search_all(q) | zim_search(q, cross_file=True) | one row per file in results, each with a nested result |
search_with_filters(path, q, ns=, ct=) | zim_search(q, zim_file_path=path, namespace=ns, content_type=ct) | filters only meaningful for fulltext |
find_entry_by_title(path, title) | zim_search(title, zim_file_path=path, mode="title") | fast title-indexed C/<Title> path |
get_search_suggestions(path, prefix) | zim_search(prefix, zim_file_path=path, mode="suggest") | autocomplete-style prefix |
get_zim_entry(path, entry_path) | zim_get(path, entry_path=entry_path) | smart-retrieval fallback on miss |
get_zim_entries(path, entries) | zim_get(path, entry_paths=entries) | up to 50 per call; per-entry cost |
get_binary_entry(path, entry_path) | zim_get(path, entry_path=entry_path, binary=True) | base64 wire payload, 10,000,000-byte default cap |
get_entry_summary(path, entry_path) | zim_get(path, entry_path=entry_path, view="summary") | one of four view modes |
get_table_of_contents(path, entry_path) | zim_get(path, entry_path=entry_path, view="toc") | one of four view modes |
get_article_structure(path, entry_path) | zim_get(path, entry_path=entry_path, view="structure") | one of four view modes |
get_section(path, entry_path, section_id) | zim_get_section(path, entry_path, section_id) | now defaults compact=True |
browse_namespace(path, namespace) | zim_browse(path, namespace) | default mode="page" |
walk_namespace(path, namespace) | zim_browse(path, namespace, mode="walk") | cursor-paginated deterministic iteration |
extract_article_links(path, entry_path) | zim_links(path, entry_path) | default direction="outbound" |
get_related_articles(path, entry_path) | zim_links(path, entry_path, direction="related") | replaces standalone tool |
There are no on-the-wire aliases at v2.0 — old tool names disappear cleanly per the foundational v2 decisions.
Need configuration help? See Configuration. Deploying over HTTP? See HTTP and Docker Deployment. Using resources / subscriptions? See Resources, prompts & subscriptions.
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.