Skip to content

Writing

7 min read

OpenZIM MCP Server: Offline Knowledge for AI Assistants

I built openzim-mcp so AI assistants can search Wikipedia with zero connectivity. ZIM archives pack an entire encyclopedia into one indexed file.

Most AI assistants stop being useful the moment they lose internet access. That’s a single point of failure, and it shows up in the places where reliability matters most: edge deployments, air-gapped networks, remote sites, anywhere the connection is intermittent or expensive. Offline knowledge access fixes that by letting an assistant keep working regardless of the network.

Connectivity Dependency Analysis

Assuming the network is always there creates real weak points, especially where reliability can’t be guaranteed. The cases that come up most:

  • Aircraft and ships, where connectivity is intermittent, expensive, or restricted by regulation
  • Remote research stations, field operations, and regions with little infrastructure
  • Air-gapped networks that block external connectivity for compliance or security
  • Places where data costs put information out of reach, like many developing markets
  • Systems that simply have to run without external dependencies

The upside is that a lot of high-value knowledge (encyclopedias, educational material, technical docs) packs down well for offline use, given the right compression and indexing.

OpenZIM Architecture and Compression Technology

The OpenZIM format came out of the Kiwix project, which needed to get educational content to people on slow or nonexistent connections. ZIM files pair heavy compression with a built-in index, so they stay small without giving up fast lookups.

That’s how the entire English Wikipedia (articles, metadata, cross-references) fits into a single portable archive you can ship on physical media or pull down over a thin connection.

ZIM Format Technical Advantages

  • Compression: content-aware algorithms tuned for text push ratios past 10:1
  • Random access: B-tree indexing gives O(log n) article retrieval without decompressing the whole archive
  • Metadata: full-text search indices, category hierarchies, and cross-reference graphs travel with the content
  • Cross-platform: one standardized binary format behaves the same on any OS or hardware

Model Context Protocol Integration Strategy

The Model Context Protocol gives AI systems one consistent interface for talking to outside resources, which turns out to be handy for offline knowledge. Instead of writing a bespoke integration for every data source, you expose it through MCP once and any compatible client can use it.

Here that means an assistant can reach a full knowledge base (encyclopedias, courses, technical docs) with no network at all, and keep working wherever it’s deployed.

Building the OpenZIM MCP Server

Illustrates the technical concept of efficient indexing and searching within a compressed archive without full decompression.

Performance Engineering Challenges

The hard part is searching a compressed archive of millions of documents and still answering in under a second. It’s a classic systems tradeoff: storage efficiency against query speed, inside a memory budget small enough for edge hardware.

The ZIM format solves the indexing half of this problem itself: archives ship with an embedded Xapian full-text index built at creation time, providing fast content location while preserving the storage benefits of compression. The server’s job is to expose that index safely through libzim’s Python bindings, adding validation, pagination, and caching on top. Condensed from openzim_mcp/zim/search.py, the core search path looks like this:

from libzim.reader import Archive
from libzim.search import Query, Searcher

class _SearchMixin:
    """Full-text search over a ZIM archive's embedded index."""

    def _perform_search(
        self, archive: Archive, query: str, offset: int, limit: int
    ) -> list[dict[str, Any]]:
        searcher = Searcher(archive)
        search = searcher.search(Query().set_query(query))
        total_matches = search.getEstimatedMatches()

        results = []
        for path in search.getResults(offset, min(limit, total_matches)):
            entry = archive.get_entry_by_path(path)
            results.append(
                {
                    "path": path,
                    "title": entry.title,
                    "snippet": self._get_entry_snippet(archive, entry),
                }
            )
        return results

Critical Performance Optimization Strategies

A few patterns did most of the work here:

1. Demand-Driven Resource Loading

Load nothing until someone asks for it. Entry content is never materialized until a client actually requests it: libzim only decompresses the relevant cluster when item.content is accessed, and the result is cached for next time.

def get_zim_entry_data(self, zim_file_path: str, entry_path: str) -> EntryResponse:
    cache_key = f"entry:{zim_file_path}:{entry_path}"
    cached = self.cache.get(cache_key)
    if cached is not None:
        return cached

    with zim_archive(Path(zim_file_path)) as archive:
        entry = archive.get_entry_by_path(entry_path)
        item = entry.get_item()  # cluster decompressed only on access
        content = self.content_processor.process_mime_content(
            bytes(item.content), item.mimetype or ""
        )

    response = build_entry_response(entry, content)
    self.cache.set(cache_key, response)
    return response

(Simplified. The real method also handles redirect chains, content pagination, and a search-based fallback for mismatched entry paths.)

2. Inverted Index Architecture

Because the inverted index ships inside the archive (a Xapian index built when the ZIM file is created), the server never has to build one of its own. It validates that the archive carries a full-text index before searching, and archives without one fall back to a bounded namespace scan rather than failing outright. Title lookups get the same embedded-index treatment through libzim’s SuggestionSearcher; the pattern looks like this:

from libzim.suggestion import SuggestionSearcher

def title_suggestions(
    self, archive: Archive, query: str, limit: int
) -> list[str]:
    suggester = SuggestionSearcher(archive)
    suggestions = suggester.suggest(query)
    return list(suggestions.getResults(0, limit))

3. Memory-Mapped I/O Optimization

Let the kernel handle page caching instead of building your own. libzim memory-maps each archive and decompresses content clusters on demand, so the kernel’s page cache manages the working set for free. The only knobs the Python layer exposes tune libzim’s two internal caches:

# openzim_mcp/zim/archive.py
def configure_libzim_caches(
    cluster_cache_max_size_bytes: Optional[int] = None,
    dirent_cache_max_count: Optional[int] = None,
) -> None:
    """Tune libzim's internal caches.

    The cluster cache (process-global, default 16 MiB) holds
    decompressed content clusters; the dirent cache (per-archive,
    default 512 entries) holds directory entries.
    """

Practical Offline Workflows

Research and Development

Here’s how I use the OpenZIM MCP server in my daily workflow:

# AI assistant searching offline Wikipedia
> Search the local Wikipedia for "distributed systems consensus algorithms"

# AI assistant accessing educational content
> Find articles about "rust programming language memory safety" in the offline knowledge base

# AI assistant browsing without internet
> Look up "HTTP/3 protocol specifications" in the local technical documentation

The assistant answers from the local archive, no internet required.

Educational Scenarios

Offline access is especially useful in education:

  • Classroom environments where internet is restricted or unreliable
  • Field research where connectivity isn’t available
  • Developing regions where data costs are prohibitive
  • Security-sensitive environments where external connections aren’t allowed

Development in Low-Connectivity Environments

When you’re building on a bad connection, having docs and reference material available offline matters a lot:

# Example: AI assistant helping with offline development
def get_documentation(self, topic: str) -> str:
    response = self.zim_ops.search_zim_file_data(self.zim_path, topic, limit=5)

    sections = []
    for result in response["results"]:
        content = self.zim_ops.get_zim_entry(self.zim_path, result["path"])
        sections.append(f"## {result['title']}\n\n{content}")

    return "\n\n".join(sections)

A structural overview showing how the AI communicates through the MCP layer to access the offline storage vault.

Architecture Patterns for Offline Data Access

Resource-Centric Design

The key insight for offline MCP servers is separating data access from data processing:

from typing import Protocol
from pydantic import BaseModel

class OfflineResource(BaseModel):
    uri: str
    title: str
    description: str | None = None
    content_type: str
    size: int | None = None

class OfflineResourceProvider(Protocol):
    def search_resources(self, query: str) -> list[OfflineResource]: ...
    def get_resource_content(self, uri: str) -> bytes: ...
    def get_resource_metadata(self, uri: str) -> ResourceMetadata: ...

This lets you swap offline data sources (ZIM files, local databases, cached web content) without touching the MCP interface.

Caching Strategy

For offline systems, caching does real work. The cache combines TTL expiry with LRU eviction behind a re-entrant lock, keyed by operation (search_v2b:..., entry:...). Condensed from openzim_mcp/cache.py:

import threading
import time
from typing import Any, Optional

class CacheEntry:
    def __init__(self, value: Any, ttl_seconds: int):
        self.value = value
        self.size_bytes = _approximate_size_bytes(value)
        self.created_at = time.monotonic()
        self.ttl_seconds = ttl_seconds

    def is_expired(self) -> bool:
        return time.monotonic() - self.created_at > self.ttl_seconds

class OpenZimMcpCache:
    def __init__(self, config: CacheConfig):
        self.config = config
        self._cache: dict[str, CacheEntry] = {}
        self._lock = threading.RLock()

    def get(self, key: str) -> Optional[Any]:
        with self._lock:
            entry = self._cache.get(key)
            if entry is None:
                return None
            if entry.is_expired():
                self.delete(key)
                return None
            self._record_access(key)
            return entry.value

    def set(self, key: str, value: Any) -> None:
        with self._lock:
            if len(self._cache) >= self.config.max_size:
                self._evict_lru()
            self._cache[key] = CacheEntry(value, self.config.ttl_seconds)

The full implementation also enforces a byte budget, uses a heap for O(log n) LRU eviction, runs a background cleanup thread for expired entries, and optionally persists the cache to disk across restarts.

Best Practices for Offline MCP Servers

Error Handling for Offline Scenarios

Offline systems have unique error conditions. Each exception carries a machine-readable error code so MCP clients can handle failures programmatically:

class OpenZimMcpError(Exception):
    """Base exception for all OpenZIM MCP-related errors."""

    error_code: str = "OPENZIM_ERROR"

class OpenZimMcpFileNotFoundError(OpenZimMcpError):
    """Raised when a ZIM file is not found."""

    error_code = "OPENZIM_FILE_NOT_FOUND"

class OpenZimMcpArchiveError(OpenZimMcpError):
    """Raised when ZIM archive operations fail."""

    error_code = "OPENZIM_ARCHIVE_ERROR"

class OpenZimMcpTimeoutError(OpenZimMcpError):
    """Base class for timeout-related errors."""

    error_code = "OPENZIM_TIMEOUT"

class ArchiveOpenTimeoutError(OpenZimMcpTimeoutError):
    """Raised when opening a ZIM archive times out."""

    error_code = "OPENZIM_ARCHIVE_OPEN_TIMEOUT"

Configuration for Offline Systems

Offline systems need different configuration considerations. The server uses pydantic-settings models with validated bounds on every field:

from typing import Literal
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class CacheConfig(BaseModel):
    enabled: bool = True
    max_size: int = Field(default=CACHE.MAX_SIZE, ge=1, le=10000)
    ttl_seconds: int = Field(default=CACHE.TTL_SECONDS, ge=60, le=86400)
    persistence_enabled: bool = Field(default=CACHE.PERSISTENCE_ENABLED)

class OpenZimMcpConfig(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="OPENZIM_MCP_", env_nested_delimiter="__"
    )

    allowed_directories: list[str] = Field(default_factory=list)
    cache: CacheConfig = Field(default_factory=CacheConfig)
    tool_mode: Literal["advanced", "simple"] = "simple"
    transport: Literal["stdio", "http", "sse"] = "stdio"

Every field can be overridden through OPENZIM_MCP_-prefixed environment variables, with __ as the nesting delimiter (for example OPENZIM_MCP_CACHE__TTL_SECONDS=600).

Testing Offline Systems

Testing offline systems requires different strategies. With pytest fixtures providing a small test archive, the pattern looks like this:

def test_offline_search(zim_operations, test_zim_path):
    response = zim_operations.search_zim_file_data(
        test_zim_path, "rust programming", limit=10
    )

    assert response["results"]
    assert len(response["results"]) <= 10

def test_cache_behavior(zim_operations, test_zim_path):
    # First access - should hit the ZIM file and populate the cache
    content1 = zim_operations.get_zim_entry(test_zim_path, "A/Rust")

    # Second access - should be served from the cache
    content2 = zim_operations.get_zim_entry(test_zim_path, "A/Rust")

    assert content1 == content2

def test_cache_expiry():
    cache = OpenZimMcpCache(CacheConfig(ttl_seconds=60))
    cache.set("entry:wiki.zim:A/Rust", "<content>")

    assert cache.get("entry:wiki.zim:A/Rust") == "<content>"
    assert cache.get("entry:wiki.zim:A/Missing") is None

The Future of Offline AI

Building openzim-mcp got me thinking about how far offline AI can go. An assistant shouldn’t only be smart when it’s connected. It should stay useful when the internet is gone.

A few directions I want to take this:

  • Hybrid online/offline: switch between online and offline sources depending on what’s available
  • Incremental updates: refresh an offline knowledge base without rebuilding it from scratch
  • Specialized domains: build ZIM files for specific technical fields or industries
  • Local networks: share knowledge bases across a LAN with no internet

Getting Started with Offline Knowledge

Want to try the OpenZIM MCP server yourself? Here’s how to get started:

# Install the server
uv tool install openzim-mcp
# or: pip install openzim-mcp

# Download a ZIM file (example: Simple English Wikipedia)
wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_simple_all.zim

# Point the server at your ZIM directory
openzim-mcp /path/to/zim/files

# Configure your AI assistant to use the offline knowledge base
# (specific steps depend on your MCP client)

# Start exploring offline knowledge
# Try searching for topics you're interested in

There’s a lot out there already. You’ll find ZIM files for Wikipedia in dozens of languages, plus educational content, technical documentation, and specialized knowledge bases.

Architectural Insights and Design Principles

Offline access turned out to beat the network on more than availability. A curated, high-quality knowledge base often gives more focused, relevant answers than a general web search, and there’s no latency or flaky connection to fight.

The problems I ran into (search optimization, caching, memory management) taught me a lot about designing for data access. Tight constraints tend to produce better solutions than open-ended ones. They force you to decide what actually matters and cut the rest.


Want to dig in? The project documentation and the GitHub repository have the full implementation and more examples.

Subscribe

Was this helpful?

Discuss

Ask can help explain concepts, provide context, or point you to related content.