LLM integration patterns
Best practices and patterns for integrating OpenZIM MCP with large language models.
Notation: examples on this page use Python pseudo-call syntax (
zim_query(query="..."),zim_search(query="...", ...)). The MCP wire format is JSON-RPC{"name": "...", "arguments": {...}}— your MCP client handles the framing. The argument names and types match the 8-tool advanced surface (zim_query,zim_search,zim_get,zim_get_section,zim_browse,zim_metadata,zim_links,zim_health).
Overview#
OpenZIM MCP is purpose-built for LLM integration: structured access to offline knowledge bases via a small, dispatch-friendly tool surface. This guide covers proven patterns for maximizing effectiveness against both simple mode (the default, one natural-language tool zim_query) and advanced mode (the full 8-tool surface).
If your host LLM is small or struggles with large tool catalogs, prefer simple mode — zim_query delegates to the underlying advanced operations via an intent parser. If your host LLM dispatches reliably against a richer tool surface, switch to advanced mode (--mode advanced or OPENZIM_MCP_TOOL_MODE=advanced) for fine-grained control.
Core integration principles#
1. Progressive discovery#
Start broad, then narrow down based on results.
User: "Tell me about evolution"
LLM strategy (simple mode):
1. zim_query(query="summarize Evolution")
LLM strategy (advanced mode):
1. zim_search(query="evolution", ...) → broad search
2. zim_get(entry_path=..., view="structure") → article shape
3. zim_get_section(entry_path=..., section_id=...) → specific sections
4. zim_links(direction="related", entry_path=...) → adjacent topics
2. Context-aware retrieval#
Use article structure and metadata to provide better context.
User: "What are the main mechanisms of evolution?"
LLM strategy (advanced mode):
1. zim_get(view="structure", entry_path="C/Evolution")
2. Identify "Mechanisms" section
3. zim_get_section(entry_path="C/Evolution", section_id=...)
4. zim_links(direction="related", entry_path="C/Evolution")
3. Smart fallback#
zim_get(entry_path=...) runs the smart-retrieval fallback internally — guessed paths that don’t match exactly get resolved via search-derived candidate terms. See the Smart retrieval page for the algorithm.
# The system automatically handles:
- "Natural Selection" → "Natural_selection"
- "DNA Replication" → "DNA_replication"
- "Café" → "Caf%C3%A9"
Simple mode: zim_query#
In simple mode the server exposes one tool. Phrase the request in natural language; the intent parser routes to the right underlying operation.
# Summarize an article
zim_query(query="summarize Photosynthesis")
# Search across all loaded archives
zim_query(query="search every ZIM for climate change")
# List the available archives
zim_query(query="list available ZIM files")
# Walk a namespace
zim_query(query="walk namespace M", zim_file_path=zim_path) # naming the file inside the query only works for metadata/search phrasings
The intent parser recognises summarise / search / get / browse / structure / TOC / list-files / related-articles / find-by-title phrasings (server health and cache stats require the advanced-mode zim_health tool). The response text carries a telemetry comment — <!-- intent=<operation> cert=<confidence> --> — describing which underlying operation the parser picked, useful for diagnosing surprises. Search for the comment rather than reading the last line: a <!-- reranker=... --> marker, a closing content fence and a token-count footer can follow it.
For everything that follows on this page, switch to advanced mode for fine-grained control.
Search strategies (advanced mode)#
Basic search pattern#
# 1. Start with broad search
search_results = zim_search(
query="biology",
zim_file_path=zim_path,
limit=5,
)
# 2. Get detailed content for relevant results
for result in search_results.results:
content = zim_get(
zim_file_path=zim_path,
entry_path=result.path,
)
Advanced search with filters#
# Search within a specific namespace + content type
filtered_results = zim_search(
query="evolution",
zim_file_path=zim_path,
mode="fulltext",
namespace="C", # Content articles only
content_type="text/html",
limit=10,
)
Auto-complete for better queries#
# Get title-suggester results for partial queries
suggestions = zim_search(
query="bio",
zim_file_path=zim_path,
mode="suggest",
limit=5,
)
# Use suggestions to refine search
for suggestion in suggestions.results:
refined = zim_search(query=suggestion.text, zim_file_path=zim_path)
Title-first lookup (skip search overhead)#
When the LLM already has a likely article title, zim_search(mode="title") is much cheaper than full-text search — it tries the normalized C/<Title> direct match first (fast path), then falls back to libzim’s title-indexed suggester:
hit = zim_search(
query="Photosynthesis",
zim_file_path=zim_path,
mode="title",
)
if hit.results:
body = zim_get(zim_file_path=zim_path, entry_path=hit.results[0].path)
Set cross_file=True to query every allowed archive at once.
Cross-archive search#
Use cross_file=True instead of looping over files — zim_search queries every ZIM in the allowed dirs and merges results in one call:
# One round-trip across every ZIM file in the allowed dirs
results = zim_search(query="climate change", cross_file=True, limit=5)
# results.results holds one row per file; each row's .result is that file's SearchResponse
Content retrieval patterns (advanced mode)#
Structured content access#
# 1. Get article structure first
structure = zim_get(
zim_file_path=zim_path,
entry_path="C/Evolution",
view="structure",
)
# 2. Present overview to user
overview = f"Article '{structure.title}' has {len(structure.headings)} sections"
# 3. Get a specific section by ID (cheaper than full article)
section = zim_get_section(
zim_file_path=zim_path,
entry_path="C/Evolution",
section_id=structure.headings[1].id,
)
# 4. Or fetch the full body with a cap
content = zim_get(
zim_file_path=zim_path,
entry_path="C/Evolution",
max_content_length=50000,
)
Cheap context: summary and TOC#
zim_get(view="summary") returns the lead paragraph(s) without loading the full body. zim_get(view="toc") returns a hierarchical TOC tree. Both are excellent “what’s in this article?” probes before deciding to fetch the full content:
toc = zim_get(zim_file_path=zim_path, entry_path=entry_path, view="toc")
summary = zim_get(zim_file_path=zim_path, entry_path=entry_path, view="summary")
Link-based exploration#
# Outbound links of one kind (default kind="internal";
# pass kind="external" or kind="media" for the other buckets)
links = zim_links(
zim_file_path=zim_path,
entry_path="C/Biology",
direction="outbound",
)
# Or deduplicated outbound link-graph neighbours
related = zim_links(
zim_file_path=zim_path,
entry_path="C/Biology",
direction="related",
limit=10,
)
direction="inbound" shipped in v2.3.0. What gates it is not the version but the sidecar: build it with openzim-mcp build link-graph <archive>.zim, or the call returns inbound_sidecar_unavailable. v3.0.0 invalidated every sidecar built by 2.x — rebuild those with --force.
User experience patterns#
Conversational knowledge exploration#
Pattern: Guide users through knowledge discovery.
User: "I want to learn about biology"
LLM response:
1. "I found several biology topics. Here are the main areas:"
2. Present structured overview from zim_get(view="structure")
3. "Which area interests you most?"
4. Based on response, dive deeper into specific sections via zim_get_section
Research assistant pattern#
Pattern: Help users research specific topics.
User: "I'm writing about evolutionary mechanisms"
LLM strategy:
1. zim_search(query="evolutionary mechanisms")
2. zim_get(view="structure") → identify key mechanisms
3. zim_get_section(...) → extract each mechanism
4. zim_links(direction="related") → additional context
5. Provide structured summary with sources
Question-answering pattern#
Pattern: Answer specific questions using knowledge base.
User: "What is natural selection?"
LLM strategy:
1. zim_search(mode="title", query="Natural selection") if confident, fulltext otherwise
2. zim_get(view="summary", entry_path=...) → opening paragraph
3. Provide concise answer with option to explore further
Performance optimization patterns#
Efficient content loading#
# Use appropriate content limits
small_preview = zim_get(
zim_file_path=zim_path,
entry_path=article_path,
max_content_length=5000, # For previews
)
full_content = zim_get(
zim_file_path=zim_path,
entry_path=article_path,
max_content_length=100000, # For full reading
)
Batch retrieval (HTTP-friendly)#
zim_get(entry_paths=[...]) takes up to 50 entries per call with per-entry success/failure. Per-entry failures don’t abort the batch — particularly valuable over HTTP transport where round-trip cost dominates:
# Bare-string shape for single-archive batches
batch = zim_get(
zim_file_path=zim_path,
entry_paths=["C/Biology", "C/Evolution", "C/Genetics"],
)
hits = [r for r in batch.results if r.success]
Rate limiting is charged per-entry, not per batch — but the total is clamped to the bucket’s capacity (burst_size, default 40), so a 50-entry batch costs 40 slots rather than 50. The clamp exists so one batch can never cost more than a full bucket.
Cache-friendly patterns#
# Reuse common queries to benefit from caching
popular_topics = ["Biology", "Physics", "Chemistry"]
for topic in popular_topics:
# These will be cached for faster subsequent access
zim_search(query=topic, zim_file_path=zim_path)
Specialized use cases#
Educational content delivery#
def create_lesson_plan(topic):
# 1. Get topic overview
overview = zim_search(query=topic, zim_file_path=zim_path, limit=1)
# 2. Get article structure for curriculum planning
structure = zim_get(
zim_file_path=zim_path,
entry_path=overview.results[0].path,
view="structure",
)
# 3. Prepare related topics for exploration
links = zim_links(
zim_file_path=zim_path,
entry_path=overview.results[0].path,
direction="outbound",
)
return {
"overview": overview,
"structure": structure,
"related_topics": links.results,
}
Research and analysis#
def research_topic(topic, depth="medium"):
results = []
# 1. Initial search
primary_results = zim_search(query=topic, zim_file_path=zim_path, limit=10)
# 2. Get detailed content and related links
for result in primary_results.results:
content = zim_get(zim_file_path=zim_path, entry_path=result.path)
related = zim_links(
zim_file_path=zim_path,
entry_path=result.path,
direction="related",
limit=5,
)
results.append({
"content": content,
"related": related.results,
})
return results
Content summarization#
def summarize_topic(topic):
# 1. Get main article
search_results = zim_search(query=topic, zim_file_path=zim_path, limit=1)
main_path = search_results.results[0].path
# 2. Lead-paragraph summary (cheap)
summary = zim_get(zim_file_path=zim_path, entry_path=main_path, view="summary")
# 3. TOC for key sections
toc = zim_get(zim_file_path=zim_path, entry_path=main_path, view="toc")
# 4. Extract top-level sections
key_sections = [s for s in toc.toc if s.level <= 2]
# 5. Pull each section by its ID
section_bodies = [
zim_get_section(
zim_file_path=zim_path,
entry_path=main_path,
section_id=s.section_id,
)
for s in key_sections
]
return {
"title": toc.title,
"summary": summary,
"key_sections": key_sections,
"section_bodies": section_bodies,
}
Error handling patterns#
Structured tool errors#
OpenZIM MCP v2 tools return structured ToolErrorPayload objects, not exceptions. The payload is {"error": true, "operation": "<op>", "message": "<text>"}, plus "context" when the tool supplied one, plus any operation-specific extra keys — zim_get_section, for example, adds available_section_ids, available_section_ids_truncated and available_section_ids_total on an unknown section ID, and closest_match when a near-miss heading exists. There is no status key and no hint key; branch on result["error"] is True. No tool advertises an outputSchema, so nothing arrives in structuredContent — parse the JSON text block in content. The server also flags these envelopes with isError: true on the CallToolResult. Smart retrieval already handles “wrong path” cases automatically — see Smart retrieval.
def robust_content_access(entry_path):
# zim_get already runs smart-retrieval fallback internally —
# if direct access fails, it tries search-derived candidate terms.
result = zim_get(zim_file_path=zim_path, entry_path=entry_path)
# When smart retrieval bails, the result is a ToolErrorPayload dict.
if isinstance(result, dict) and result.get("error") is True:
# Title-mode search is the cheapest "I have a name, give me the path" fallback.
hit = zim_search(
query=entry_path.split("/")[-1],
zim_file_path=zim_path,
mode="title",
)
if hit.results:
return zim_get(zim_file_path=zim_path, entry_path=hit.results[0].path)
return None
return result
For programmatic checks, the underlying exception class hierarchy lives in openzim_mcp/exceptions.py: OpenZimMcpArchiveError, OpenZimMcpValidationError, OpenZimMcpRateLimitError, OpenZimMcpConfigurationError. They’re caught at the tool boundary; you don’t see them on the wire.
Branching on isError#
As of v2.6.0 the failure is visible at the protocol level too: EnvelopeAwareMCPServer.call_tool (openzim_mcp/mcp_envelope.py) recognises a returned envelope and emits a CallToolResult with isError: true. Tools still signal failure by returning the envelope rather than raising — before v2.6.0 that meant every failure, security denials included, travelled the SDK’s ordinary success path and reached the client with isError: false.
If your MCP client surfaces the flag, branch on it first: it is one boolean and needs no JSON parsing. The body is unchanged — byte-identical to what the plain dict return produced — so parse content[0].text only when you need operation, message, or the extra keys.
Two edges worth knowing:
structuredContentis deliberately unset on error results. No tool advertises anoutputSchema, and per the MCP specstructuredContentis the counterpart to one, so the envelope always lives in thecontenttext block.- Partial failures are not call failures. A cross-archive
zim_search(cross_file=True)where one archive is unreadable, azim_get(entry_paths=[...])batch where some entries fail, and a no-argumentzim_health()whosehealthorconfigurationblock could not be built all still returnisError: false. The per-row or per-block status is nested and shaped differently per tool, and the server’s discriminator only inspects the top-level dict — so nested failures never flag the whole call. Forzim_healththat is deliberate: the two sub-report builders catch their own exceptions and return an envelope in place of their block, so a failed cache-stats read still leaves you theconfigurationandloaded_archivesblocks that did build. Total failure is still flagged — if theloaded_archivesscan raises, it reaches the tool boundary and you get an ordinary top-leveloperation: "zim_health"envelope withisError: true. The nested envelopes carryoperation: "get server health"andoperation: "get server configuration", the only two non-snake_caseoperationvalues on the wire.
Progressive content loading#
def progressive_content_load(entry_path):
# Start with structure (cheap)
structure = zim_get(zim_file_path=zim_path, entry_path=entry_path, view="structure")
# Get a preview
preview = zim_get(
zim_file_path=zim_path,
entry_path=entry_path,
max_content_length=2000,
)
# Full content only if needed
if user_wants_full_content:
full_content = zim_get(
zim_file_path=zim_path,
entry_path=entry_path,
max_content_length=100000,
)
return full_content
return preview
Monitoring and analytics#
Performance tracking#
# zim_health returns combined server health + configuration + loaded archives
health = zim_health()
cache_hit_rate = health.health.cache_performance.hit_rate
if cache_hit_rate < 0.7:
# Adjust caching strategy — see Performance Optimization Guide
pass
Note .health.cache_performance (not cache). It always carries 13 fields — enabled, size, max_size, ancillary_entries, total_entries, size_bytes, max_bytes, ttl_seconds, hits, misses, hit_rate, background_cleanup, persistence_enabled — plus persistence_path and persistence_file_exists when persistence is on. size counts only what max_size bounds; total_entries includes per-item fragments, which are bounded by max_bytes (64 MiB default) instead. process_id / server_pid are the real PID over local stdio ([REDACTED] over HTTP/SSE).
Usage analytics#
# Track popular content
popular_searches = track_search_patterns()
popular_articles = track_article_access()
# Optimize based on usage patterns
Best practices summary#
Do’s#
- Start with search before direct access.
- Use
view="structure"orview="toc"to understand content organisation cheaply. - Use
view="summary"for lead-paragraph previews before fetching full bodies. - Use
zim_get_sectionwhen you already have a section ID — much cheaper than fetching the whole article. - Leverage caching by reusing common queries.
- Handle structured tool errors by branching on the MCP
isErrorflag when your client exposes it, or onisinstance(result, dict) and result.get("error") is Truewhen you only have the parsed payload. - Monitor performance via
zim_healthand adjust limits accordingly. - Use appropriate content limits (
max_content_length) for different use cases. - Use
zim_links(direction="related")for content discovery. - Provide progressive disclosure of information.
Don’ts#
- Don’t assume exact paths — let smart retrieval do its job.
- Don’t ignore article structure — it provides valuable context.
- Don’t request excessive content — use appropriate limits.
- Don’t ignore cache performance — monitor and optimise.
- Don’t hardcode file paths — make them configurable.
- Don’t skip error handling — always have fallbacks.
- Don’t overwhelm users — provide structured, digestible information.
- Don’t fetch full articles when a section will do —
zim_get_sectionis your friend.
Advanced integration techniques#
Namespace iteration#
zim_browse(mode="page") samples (caps at limit on large namespaces). For exhaustive iteration use mode="walk", which is deterministic cursor pagination by entry ID:
cursor = None
while True:
page = zim_browse(
zim_file_path=zim_path,
namespace="M",
mode="walk",
cursor=cursor,
limit=200,
)
process(page.results)
if page.done:
break
cursor = page.next_cursor
MCP prompts#
Three pre-built workflow prompts are available in advanced mode (--mode advanced / OPENZIM_MCP_TOOL_MODE=advanced):
/research <topic>—zim_search(cross_file=True)thenzim_get(view="summary")on top hits, then ask which thread to pursue./summarize <zim_file_path> <entry_path>—zim_get(view="toc")+zim_get(view="summary")+zim_links(direction="outbound")combined./explore <zim_file_path>—zim_metadatathenzim_get(main_page=True)thenzim_browse(namespace="C", mode="walk", limit=5)— high-level briefing of one archive.
If your client supports MCP prompts, surface these as slash commands rather than reimplementing the workflows in your own orchestration.
MCP resources#
Three resource URIs, also available in advanced mode — zim://files is a concrete resource listed by resources/list, the other two are URI templates listed by resources/templates/list:
zim://files— JSON list of every ZIM file.zim://{name}— overview of one ZIM (metadata, namespace summary, main-page preview).zim://{name}/entry/{path}— single entry served with native MIME type (HTML, JSON, image, PDF).
The per-entry resource is great for clients that render content directly (browsers, image viewers). Clients MUST URL-encode / as %2F in the path segment because the URI template engine treats / as a separator. Example: zim://wikipedia_en/entry/A%2FClimate_change.
Over the HTTP transport, open a subscriptions/listen stream to be told when archives change: resourcesListChanged gets you notifications/resources/list_changed when a .zim appears or disappears, and resourceSubscriptions: ["zim://{name}"] gets you notifications/resources/updated when that archive is replaced — see Resources, prompts & subscriptions.
Simple-mode considerations#
In simple mode (the default) only zim_query is exposed — pass a natural-language request and the server’s intent parser routes to the right underlying operation. If your host LLM is small or struggles with large tool catalogues, prefer simple mode and let the parser do the routing. For LLMs that handle the full 8-tool surface, switch to advanced mode (OPENZIM_MCP_TOOL_MODE=advanced) for fine-grained control.
The 8-tool advanced surface fits comfortably below the MCP Tax pain band (~23.3KB vs the 25–50KB band) — most 7-8B class open-weights models dispatch reliably against it. The 22-tool v1 surface (~36KB) sat squarely inside the band; if you saw dispatch confusion on v1 with a small model, v2 should be substantially better.
Contextual content assembly#
# Assemble context from a single article + its outbound neighbours
def create_comprehensive_answer(zim_path, entry_path):
body = zim_get(zim_file_path=zim_path, entry_path=entry_path)
related = zim_links(
zim_file_path=zim_path,
entry_path=entry_path,
direction="related",
limit=10,
)
return body, related.results
Next steps:
- API reference — full tool signatures.
- Performance optimization — cache tuning, rate limiting, batching.
- Smart retrieval — how the search-fallback path resolution works.
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.