API reference

OpenZIM MCP exposes three kinds of MCP surfaces:

SurfaceCount (advanced mode)Default mode
Tools (callable functions)8only zim_query exposed in Simple mode
Prompts (slash-command workflows)3Advanced mode only
Resources (URI-addressable data)1 resource + 2 templates, plus subscriptionsAdvanced 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 a TextContent block. All 8 tools are annotated -> Any, so the SDK derives no outputSchema and nothing arrives in structuredContent — parse the text block as JSON.
  • Markdown-string responseszim_query renders article bodies as a string that opens with the title as an # heading, then a Path: line and a Type: line, and then a ## Content block (when smart retrieval resolved somewhere else, a Requested Path: / Actual Path: pair replaces the single Path: line). This rendering is reachable only through zim_query; zim_get returns 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 is zim_query on its default path, and only partly: its guidance failures — no archive specified, empty query, meta-only query, chained query — return markdown with isError: false, but a zim_file_path matching no archive returns a zim_path_not_found envelope and any other exception returns a zim_query envelope, both with isError: true. There is no status key and no hint key. Only error, operation, and message are guaranteed; individual failures may merge extra self-correction keys into the same envelope (a section_not_found error, for example, also carries available_section_ids, available_section_ids_truncated, available_section_ids_total, and closest_match). The envelope arrives as JSON text in the response’s content — no tool advertises an outputSchema, so nothing lands in structuredContent — and openzim_mcp/mcp_envelope.py recognises it on the way out and sets isError: true on the CallToolResult. Path entries inside errors are redacted to ...filename.zim form 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_query returns a string on its ordinary paths, and _meta is built only to render the one-line footer appended to it. So in simple mode the footer is the sole carrier: setting OPENZIM_MCP_META__FOOTER_ENABLED=false removes the information entirely rather than merely hiding a line. Advanced-mode tools return dicts and carry the real key.

Always present:

KeyMeaning
charsCharacter count of the body
truncatedWhether 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:

KeyMeaning
tokens_estEstimated tokens in the body. Omitted when the tokenizer cannot initialise, deliberately, so a model can tell “zero tokens” from “no tokenizer”
more_at_offsetThe content_offset that continues from where this response stopped
total_charsFull length of the underlying body, of which you received chars
suggestionsUp to 5 recovery hints as {type, value} rows (e.g. {"type": "alt_spelling", "value": "Photosynthesis"}). The footer renders the first 3
reasonWhy the response is empty or partial — the taxonomy below
hintFree-text next step, on lookups whose only continuation is a larger limit rather than an offset (suggest, title search)
detected_type / detection_confidenceArchive-type detection on metadata responses — e.g. "wikipedia" / "high"
preset_appliedNamed 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.

reasonWhat happenedRecovery
0_hitsThe index was searched and matched nothingBroaden the query, or use zim_search(mode="title") for an exact title
low_relevanceHits came back, but none token-match the querySame as 0_hits — treat it as a miss, not a ranking problem
bad_queryThe query could not be parsed into anything searchableRewrite it; punctuation-only and stop-word-only queries land here
no_xapian_indexThis archive ships no full-text indexFull-text search cannot work here at all. Use zim_search(mode="title") or zim_browse
bad_namespaceThe namespace does not exist in this archiveCall zim_metadata for the real namespace list — old and new archives use different schemes
no_content_type_matchThe content_type filter excluded everythingDrop the filter, or widen it
sample_onlyThe page exhausted a sampled discovery, so done is deliberately held at false (and a next_cursor is still issued) rather than claiming completenessSwitch to zim_browse(mode="walk") for exhaustive iteration. total here is the sample size, not the namespace size
archive_unavailableIn a cross_file fan-out, every archive failedInfrastructural, not a query problem — check mounts and zim_health
search_all_budget_exceededThe aggregate cross_file timeout fired before every archive was searchedPartial results. Raise OPENZIM_MCP_SEARCH__SEARCH_ALL_TOTAL_TIMEOUT_SECONDS, or narrow the archive set
scan_truncatedA link scan stopped before reading the whole articleTotals are a floor, not a count
suggestion_total_is_lower_boundA suggestion listing did not reach the end of the indextotal 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.

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.

ParameterTypeDefaultNotes
querystring(required)Natural-language question or instruction
zim_file_pathstringNoneAuto-selects when only one ZIM is in the allowed dirs
limitintNoneMax results for search/browse intents
offsetint0Pagination offset
content_offsetint0Pagination within long article body
cursorstringNoneOpaque cursor for resuming paginated results
max_content_lengthintNoneMax characters for retrieved articles
compactboolTrueCompact prose rendering (token-budget aware)
compact_budgetstr | intNoneOverride 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
synthesizeboolFalseWhen 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.

IntentSay something likeYou get backDefault 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 searches3 (a cap, not a default)
Full-text search“search for biology”, “find quantum entanglement”Ranked hits10
Cross-archive search“search all files for python”, “search everywhere for ants”One result block per archive5 per archive
Filtered search“search protein in namespace C”Hits restricted to a namespace or content type10
Title lookup“find article titled Photosynthesis”, “what’s the path for Ant”The resolved entry path10
Autocomplete“suggestions for photo”, “autocomplete diab”Title-prefix completions10 (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 body25 per bucket (100 if compact=False)
Related articles“articles related to Climate_Change”, “what links to Ant”Link-graph neighbours10
Browse a namespace“browse namespace C with limit 10”A sampled page of entries50
Walk a namespace“walk namespace M”, “enumerate namespace C”Deterministic cursor-paginated iteration200
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 than structure and 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>.zim and search <name> for <terms> (a unique basename prefix, three characters minimum, and the name must come before for). Every other form discards it silently — “walk namespace M in wikipedia.zim” parses as a walk of M with the filename dropped, which auto-selection hides while one archive is loaded and which fails with No ZIM File Specified once two are. Pass zim_file_path as 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. and is 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):

KeyMeaning
queryThe query as received
answer_markdownConcatenated 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_searchedWhich archives actually contributed
fallback_usedThe 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_wordsSize of the assembled answer
considered_articles / considered_sectionsPivot 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
_metaThe usual envelope

Tuning — all soft budgets under OPENZIM_MCP_SYNTHESIZE__:

FieldDefaultEffect
top_n5Passages in the final answer
per_archive_k10Hits taken from each archive before fusion
output_char_budget4800Soft cap on answer_markdown (~1200 tokens). Truncates the last passage rather than dropping it
section_affinity_threshold0.25Heading-overlap ratio before a section-attributed passage is boosted
section_affinity_boost1.5Multiplier when it is. Deliberately conservative — it will not dominate a strong BM25 hit
max_secondary_archive_hits2Cap 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:

RuleWhat it does
LowercaseThe whole query is lowercased. This is why topics come back lowercased, and it emits no telemetry at all
Misspelling mapToken-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 stripRemoves 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__:

FieldDefaultNotes
enabledtruefalse turns off all four rules on the zim_query path, lowercasing included
misspelling_map_pathunsetReplaces the bundled map rather than extending it — a one-line file disables all 40 built-in substitutions
misspelling_exclusion_pathunsetTokens 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)#

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
ParameterNotes
queryRequired 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_pathRequired unless only one ZIM is in the allowed dirs. Must be omitted when cross_file=True — passing both returns an invalid_combination envelope
cross_fileWhen 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_typeOptional filters (only meaningful for mode="fulltext")
limit1–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
cursorAccepted 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:

BranchSelectorReturns
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 modesentry_path="..." + view="summary" / "toc" / "structure"Structured response (summary / TOC tree / headings)
Batchentry_paths=[...] (up to 50){results, succeeded, failed} — per-entry success/error
Binaryentry_path="..." + binary=True{path, title, mime_type, size, encoding, data} (base64)
Main pagemain_page=TrueArchive 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".

ParameterNotes
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
binaryWhen 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_lengthPer-entry char cap, min 1 (the OPENZIM_MCP_CONTENT__MAX_CONTENT_LENGTH config field has its own ≥ 100 floor)
content_offsetPage through long articles without re-fetching the prefix
compactCompact-mode prose (default False — preserves legacy byte-identical behavior; a default flip was considered and explicitly deferred for lack of adoption telemetry)
compact_budgetInert 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
ParameterNotes
section_idRequired; the heading ID or anchor from a prior zim_get(view="toc") or zim_get(view="structure") response
max_charsPer-section char cap
include_subsectionsDefault True — nested subsections are returned with the named section. Set False for just the section’s own body
compactDefault 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_budgetInert 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
ModeBehavior
"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
ParameterRangeNotes
namespaceC, 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
limit1–200 (page) / 1–500 (walk)Default 50 (page) / 200 (walk). page rejects anything above 200
cursor, offsetBoth modes emit and accept a cursor. offset is the page-only parameter
include_assetsBrowses 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"] raises KeyError — use metadata["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 the M/Counter metadata, so you can profile an archive’s content composition without walking it. Omitted when the archive has no M/Counter entry (added in v2.1).

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
DirectionBehavior
"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
ArgumentBehavior
(omitted)Combined server-state report: {health, configuration, loaded_archives, _meta}.
zim_file_pathPer-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_metadatazim_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_changed when a .zim file is added to or removed from an allowed directory — request it with resourcesListChanged: true
  • notifications/resources/updated for zim://{name} when that .zim file’s mtime or size changes — request it with resourceSubscriptions: ["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 varDefaultNotes
OPENZIM_MCP_SUBSCRIPTIONS_ENABLEDtruemaster switch
OPENZIM_MCP_WATCH_INTERVAL_SECONDS51–60
OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS36000–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 callInternal operationCost
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 instead2
zim_search(mode="suggest")suggestions1
zim_search(cross_file=True)search (charged once per call, not per archive scanned)2
zim_get(entry_path=...)get_entry1
zim_get(entry_paths=[...])get_zim_entries (per-entry charge)N
zim_get(binary=True)get_binary_entry3
zim_get(view="structure") / view="toc" / view="summary"get_structure1
zim_browse(mode="page") or zim_browse(mode="walk")browse_namespace1
zim_metadataget_metadata1
zim_links(direction="related")get_related_articles2
zim_links(direction="outbound")extract_article_links2
zim_links(direction="inbound")get_inbound_links1
zim_health, zim_get_section, zim_querycharged 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#

operationCauseRecovery
cursor_decodeMalformed, 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 codeDrop the cursor and start the sequence again
cursor_mismatchIssued by a different tool. Advanced surface onlyDrop it; a cursor is not portable across operations
cursor_context_mismatchRight tool, different target — another namespace, entry, link kind, or include_assets settingDrop it and start over for the new target; a cursor cannot be retargeted
cursor_unsupportedzim_links(direction="related"), which returns one ranked set and does not paginateOmit cursor entirely
invalid_combinationAny non-empty cursor on zim_searchUse 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 — base
  • OpenZimMcpConfigurationError
  • OpenZimMcpValidationError
  • OpenZimMcpArchiveError
  • OpenZimMcpRateLimitError

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 callv2 equivalentNotes
list_zim_files()zim_health().loaded_archiveshealth/config/files consolidated
get_server_health()zim_health().healthhealth/config/files consolidated
get_server_configuration()zim_health().configurationhealth/config/files consolidated
get_zim_metadata(path)zim_metadata(path).metadatanow includes namespace breakdown too
list_namespaces(path)zim_metadata(path).namespacesnow 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.

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