# v2 Phase B Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Read first:** [`docs/superpowers/specs/2026-05-08-v2-phase-b-response-contract-design.md`](../specs/2026-05-08-v2-phase-b-response-contract-design.md). The spec is the source of truth for the canonical shape, key removals, and per-tool before/after JSON. Each task below points at the relevant section.

**Goal:** Standardize the wire format of every list-returning openzim_mcp tool around a uniform `PaginatedResponse` contract, and migrate every dict-returning tool from `Dict[str, Any]` to TypedDict so payloads land at the top of `structuredContent` with real schemas. Single bundled PR; tagged `v2.0.0a2`.

**Architecture:** New `openzim_mcp/pagination.py` (versioned, tool-bound base64-JSON cursors) and new `openzim_mcp/tool_schemas.py` (all per-tool response TypedDicts). Every tool's `_data` method returns the typed shape; every `@server.mcp.tool()` registration carries a `Union[<SuccessResponse>, ToolErrorPayload]` return annotation. The Phase A `_meta` envelope is preserved unchanged as a sibling to the contract keys.

**Tech Stack:** Python 3.12+, FastMCP (via `mcp.server.fastmcp`), libzim, `tiktoken` (already in deps from Phase A), `pytest` + `pytest-asyncio`.

---

## Plan structure

The work decomposes into five phases, executed in order:

1. **Foundation** (Tasks 1–4) — branch + new modules + shared TypedDicts.
2. **Per-tool migrations** (Tasks 5–22) — one tool per task. Each task is independent once the foundation lands.
3. **Cross-cutting cleanup** (Tasks 23–26) — simple-mode footer, cache-key bumps, test_structured_tool_output extension, contract-shape regression test.
4. **Goldens + docs** (Tasks 27–29) — Phase B golden snapshots, README, CHANGELOG.
5. **Release** (Tasks 30–31) — verify everything, open the bundled PR.

Each task is self-contained: a fresh subagent should be able to execute it by reading only this plan + the spec. When in doubt, refer the spec for shape details.

> **Wire shape note (cross-cutting):** FastMCP wraps `Union[<SuccessResponse>, ToolErrorPayload]` returns in a uniform `{"result": ...}` envelope on the wire (see [spec § FastMCP wrapping](../specs/2026-05-08-v2-phase-b-response-contract-design.md#goal)). Tests should assert against `structured["result"]` (or the wrapper-tolerant equivalent `structured.get("result", structured)`) — **NOT** `assert "result" not in structured`. Apply this pattern to every `test_<tool>_returns_structured_content` test added or updated in Tasks 7–21 and Task 25. The original spec ambition of "no result wrapper" was based on a single-TypedDict return type, but Phase B uses `Union` for the in-band error envelope, so the wrapper is present and uniform across all migrated tools.

---

## Task 1: Create the v2-phase-b branch

**Files:** none (git operations only)

- [ ] **Step 1: Verify clean state on main**

```bash
git status --porcelain
git branch --show-current
```

Expected: empty output from `git status --porcelain`; `main` from `git branch --show-current`. If main is dirty or you're on another branch, stop and ask the user.

- [ ] **Step 2: Pull latest main**

```bash
git pull --ff-only origin main
```

Expected: `Already up to date.` or a successful fast-forward. If it fails, stop.

- [ ] **Step 3: Create the branch off main**

```bash
git checkout -b v2-phase-b
```

Expected: `Switched to a new branch 'v2-phase-b'`.

- [ ] **Step 4: Confirm**

```bash
git branch --show-current
```

Expected: `v2-phase-b`.

No commit on this task — the branch creation IS the work.

---

## Task 2: Add `openzim_mcp/pagination.py` with versioned, tool-bound `Cursor`

**Files:**
- Create: `openzim_mcp/pagination.py`
- Create: `tests/test_pagination_cursor.py`

**Why:** This is the foundation every paginated tool depends on. Tool-bound (`t` field) cursors prevent cross-tool reuse bugs; versioned (`v`) cursors allow forward-compat field additions without a wire-format break.

- [ ] **Step 1: Write the failing test**

Create `tests/test_pagination_cursor.py`:

```python
"""Tests for openzim_mcp.pagination.Cursor — encode/decode, tool binding, versioning."""

from __future__ import annotations

import base64
import json

import pytest

from openzim_mcp.pagination import Cursor, CursorMismatchError


class TestCursorRoundTrip:
    def test_encode_decode_roundtrip_minimal(self) -> None:
        token = Cursor.encode(tool="browse_namespace", state={"o": 50, "l": 50, "ns": "C"})
        assert isinstance(token, str)
        decoded = Cursor.decode(token, expected_tool="browse_namespace")
        assert decoded["v"] == 1
        assert decoded["t"] == "browse_namespace"
        assert decoded["s"] == {"o": 50, "l": 50, "ns": "C"}

    def test_encode_produces_urlsafe_base64(self) -> None:
        token = Cursor.encode(tool="search_zim_file", state={"o": 0, "l": 20, "q": "berlin"})
        # url-safe base64 doesn't include + or /
        assert "+" not in token
        assert "/" not in token

    def test_encode_unicode_state(self) -> None:
        token = Cursor.encode(tool="search_zim_file", state={"o": 0, "l": 20, "q": "café"})
        decoded = Cursor.decode(token, expected_tool="search_zim_file")
        assert decoded["s"]["q"] == "café"


class TestCursorVersioning:
    def test_default_version_is_one(self) -> None:
        token = Cursor.encode(tool="browse_namespace", state={"o": 0, "l": 50, "ns": "C"})
        decoded = Cursor.decode(token, expected_tool="browse_namespace")
        assert decoded["v"] == 1

    def test_explicit_version_passes_through(self) -> None:
        token = Cursor.encode(tool="browse_namespace", state={"o": 0, "l": 50, "ns": "C"}, version=1)
        decoded = Cursor.decode(token, expected_tool="browse_namespace")
        assert decoded["v"] == 1

    def test_unknown_version_rejected(self) -> None:
        # Hand-craft a v=2 token; decoder should reject it.
        raw = json.dumps({"v": 2, "t": "browse_namespace", "s": {"o": 0, "l": 50, "ns": "C"}})
        token = base64.urlsafe_b64encode(raw.encode()).decode()
        with pytest.raises(ValueError, match="Unsupported cursor version"):
            Cursor.decode(token, expected_tool="browse_namespace")


class TestCursorToolBinding:
    def test_decode_with_matching_tool_succeeds(self) -> None:
        token = Cursor.encode(tool="search_zim_file", state={"o": 0, "l": 20, "q": "x"})
        decoded = Cursor.decode(token, expected_tool="search_zim_file")
        assert decoded["t"] == "search_zim_file"

    def test_decode_with_mismatched_tool_raises(self) -> None:
        token = Cursor.encode(tool="search_zim_file", state={"o": 0, "l": 20, "q": "x"})
        with pytest.raises(CursorMismatchError) as exc_info:
            Cursor.decode(token, expected_tool="browse_namespace")
        # Error message must name both the issuing tool and the target tool
        # so the model can produce a useful tool_error response.
        assert "search_zim_file" in str(exc_info.value)
        assert "browse_namespace" in str(exc_info.value)

    def test_mismatch_error_is_value_error_subclass(self) -> None:
        # tool_error wrappers catch both; making CursorMismatchError a
        # ValueError subclass means a single except-clause covers both
        # decode failure modes.
        assert issubclass(CursorMismatchError, ValueError)


class TestCursorMalformed:
    def test_decode_garbage_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="Invalid pagination cursor"):
            Cursor.decode("not-base64!@#$", expected_tool="browse_namespace")

    def test_decode_valid_base64_invalid_json_raises(self) -> None:
        token = base64.urlsafe_b64encode(b"this is not json").decode()
        with pytest.raises(ValueError, match="Invalid pagination cursor"):
            Cursor.decode(token, expected_tool="browse_namespace")

    def test_decode_missing_required_fields_raises(self) -> None:
        # Missing "t" — tool identity is required.
        raw = json.dumps({"v": 1, "s": {"o": 0, "l": 20}})
        token = base64.urlsafe_b64encode(raw.encode()).decode()
        with pytest.raises(ValueError, match="missing"):
            Cursor.decode(token, expected_tool="browse_namespace")

    def test_decode_tolerates_missing_padding(self) -> None:
        # Some clients strip base64 padding; decoder must tolerate that.
        token = Cursor.encode(tool="browse_namespace", state={"o": 0, "l": 50, "ns": "C"})
        stripped = token.rstrip("=")
        decoded = Cursor.decode(stripped, expected_tool="browse_namespace")
        assert decoded["s"]["ns"] == "C"


class TestCursorEmptyState:
    def test_empty_state_dict_allowed(self) -> None:
        # walk_namespace at scan_at=0 still needs a cursor for paging consistency.
        token = Cursor.encode(tool="walk_namespace", state={"scan_at": 0, "l": 200})
        decoded = Cursor.decode(token, expected_tool="walk_namespace")
        assert decoded["s"]["scan_at"] == 0
```

- [ ] **Step 2: Run tests to verify they fail with import errors**

```bash
uv run pytest tests/test_pagination_cursor.py -v
```

Expected: FAIL with `ModuleNotFoundError: No module named 'openzim_mcp.pagination'`.

- [ ] **Step 3: Create `openzim_mcp/pagination.py`**

```python
"""Pagination cursor primitives — versioned, tool-bound, opaque.

v2 Phase B promotes the v1 ``PaginationCursor`` (offset/limit/query only) to
a tool-bound, versioned cursor. The wire format is a URL-safe base64 encoding
of a JSON object:

    {"v": 1, "t": "<tool_name>", "s": {<tool-specific state>}}

* ``v`` lets us add new required fields later without a wire-format break.
* ``t`` rejects cross-tool cursor reuse (a search cursor passed to browse
  raises ``CursorMismatchError`` instead of silently misbehaving).
* ``s`` carries the per-tool paging state — offset/limit/query for search,
  scan_at for walk_namespace, etc.

Decode failures and tool mismatches both subclass ``ValueError`` so a single
except-clause in tool wrappers catches both.
"""

from __future__ import annotations

import base64
import json
from typing import Any, Dict

CURRENT_VERSION = 1


class CursorMismatchError(ValueError):
    """Raised when a cursor's ``t`` field doesn't match ``expected_tool``."""


class Cursor:
    """Encode/decode opaque pagination cursors."""

    @staticmethod
    def encode(*, tool: str, state: Dict[str, Any], version: int = CURRENT_VERSION) -> str:
        """Encode a cursor payload as URL-safe base64 JSON.

        Args:
            tool: Tool name. Decoders verify this matches their expected tool.
            state: Tool-specific paging state (offset/limit/query/scan_at/etc.).
            version: Cursor format version. Defaults to ``CURRENT_VERSION``.

        Returns:
            URL-safe base64 string suitable for ``next_cursor`` in a tool response.
        """
        payload: Dict[str, Any] = {"v": version, "t": tool, "s": state}
        raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
        return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii")

    @staticmethod
    def decode(token: str, *, expected_tool: str) -> Dict[str, Any]:
        """Decode a cursor and verify it was issued by ``expected_tool``.

        Args:
            token: A previously-encoded cursor. Padding-stripped tokens are tolerated.
            expected_tool: The tool whose state ``token`` is expected to carry.

        Returns:
            The decoded payload dict with keys ``v``, ``t``, ``s``.

        Raises:
            ValueError: Token isn't valid base64 / JSON, or is missing required
                fields, or carries an unsupported version. Subclasses include:
            CursorMismatchError: Token's ``t`` field doesn't match ``expected_tool``.
        """
        try:
            padded = token + "=" * (-len(token) % 4)
            raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
            payload = json.loads(raw)
        except Exception as e:
            raise ValueError(f"Invalid pagination cursor: {e}") from e

        if not isinstance(payload, dict):
            raise ValueError("Invalid pagination cursor: payload must be an object")
        if "v" not in payload or "t" not in payload or "s" not in payload:
            raise ValueError("Invalid pagination cursor: missing required fields (v, t, s)")
        if payload["v"] != CURRENT_VERSION:
            raise ValueError(
                f"Unsupported cursor version: got v={payload['v']}, expected v={CURRENT_VERSION}"
            )
        if not isinstance(payload["t"], str):
            raise ValueError("Invalid pagination cursor: 't' must be a string")
        if not isinstance(payload["s"], dict):
            raise ValueError("Invalid pagination cursor: 's' must be an object")
        if payload["t"] != expected_tool:
            raise CursorMismatchError(
                f"Cursor was issued by '{payload['t']}', cannot be used by '{expected_tool}'"
            )
        return payload
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
uv run pytest tests/test_pagination_cursor.py -v
```

Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/pagination.py tests/test_pagination_cursor.py
git commit -m "feat(v2): add versioned, tool-bound Cursor primitive

Foundation for the Phase B response-contract migration. The new Cursor
encodes (version, tool, state) as URL-safe base64 JSON and rejects
cross-tool reuse via the t field. Subsumes the v1 PaginationCursor
(which lives on under a compat alias until per-tool migrations land)."
```

---

## Task 3: Migrate existing `PaginationCursor` callers to use the new `Cursor` API via a compat shim

**Files:**
- Modify: `openzim_mcp/zim/archive.py:79-142` (replace `PaginationCursor` body)
- (No other call-site changes — `PaginationCursor` keeps its public API.)

**Why:** Existing call sites (`search.py`, `namespace.py`) rely on `PaginationCursor.create_next_cursor()` and `PaginationCursor.decode()`. We don't want to break them mid-migration. This task swaps the implementation underneath while keeping the public API identical, so production tests stay green during the per-tool migrations that follow.

The compat shim will be REMOVED in Task 22 once every caller has migrated to `Cursor` directly.

- [ ] **Step 1: Update `openzim_mcp/zim/archive.py` `PaginationCursor` class body**

Replace lines 79–142 (`class PaginationCursor: ...`) with:

```python
class PaginationCursor:
    """Compatibility shim over ``openzim_mcp.pagination.Cursor``.

    v1.x callers used ``PaginationCursor.create_next_cursor`` and
    ``PaginationCursor.decode`` with no tool identity. v2 Phase B migrates
    each caller to ``openzim_mcp.pagination.Cursor`` (tool-bound). Until
    every caller has been migrated, this shim emits cursors with
    ``t="legacy"`` and a permissive decoder that accepts both legacy
    cursors and the new Phase B cursors.

    Removed at the end of Phase B (see plan Task 22).
    """

    @staticmethod
    def _encode(offset: int, limit: int, query: Optional[str] = None) -> str:
        """Encode pagination state into a base64 cursor token (legacy shape)."""
        from openzim_mcp.pagination import Cursor

        state: Dict[str, Any] = {"o": offset, "l": limit}
        if query:
            state["q"] = query
        return Cursor.encode(tool="legacy", state=state)

    @staticmethod
    def create_next_cursor(
        current_offset: int, limit: int, total: int, query: Optional[str] = None
    ) -> Optional[str]:
        next_offset = current_offset + limit
        if next_offset >= total:
            return None
        return PaginationCursor._encode(next_offset, limit, query)

    @staticmethod
    def decode(token: str) -> Dict[str, Any]:
        """Decode a legacy (or new) cursor into the legacy ``{o, l, q?}`` shape.

        Tolerates both v1 cursors (raw JSON without the v/t/s envelope) and
        v2 cursors (tool-bound, versioned). Raises ``ValueError`` on malformed
        tokens.
        """
        import base64
        import json

        try:
            padded = token + "=" * (-len(token) % 4)
            raw = base64.urlsafe_b64decode(padded.encode()).decode()
            data = json.loads(raw)
        except Exception as e:
            raise ValueError(f"Invalid pagination cursor: {e}") from e
        if not isinstance(data, dict):
            raise ValueError("Cursor must decode to a JSON object")
        # v2 envelope: {v, t, s} — return s as the legacy shape.
        if "s" in data and "v" in data and "t" in data:
            inner = data["s"]
            if not isinstance(inner, dict) or "o" not in inner or "l" not in inner:
                raise ValueError("Cursor missing required fields ('o', 'l')")
            if not isinstance(inner["o"], int) or not isinstance(inner["l"], int):
                raise ValueError("Cursor offset and limit must be integers")
            return inner
        # v1 raw shape.
        if "o" not in data or "l" not in data:
            raise ValueError("Cursor missing required fields ('o', 'l')")
        if not isinstance(data["o"], int) or not isinstance(data["l"], int):
            raise ValueError("Cursor offset and limit must be integers")
        return data
```

The class signature/exports are unchanged. `__all__` at line 58–68 still lists `PaginationCursor`; leave it alone.

- [ ] **Step 2: Drop the now-unused `base64` and `json` imports if they're no longer referenced**

```bash
grep -n "^import base64\|^import json" openzim_mcp/zim/archive.py
```

Both are likely still used elsewhere in archive.py (file listing, metadata). If they ARE still used, leave them. If a module-top-level import is now unused, remove it. Verify with:

```bash
uv run ruff check openzim_mcp/zim/archive.py
```

Fix any unused-import warnings.

- [ ] **Step 3: Run the full search/namespace test suites to verify zero regressions**

```bash
uv run pytest tests/test_search_tools.py tests/test_navigation_tools.py tests/test_walk_namespace.py -v
```

Expected: all tests PASS. The shim must produce identical behavior on the existing API surface.

- [ ] **Step 4: Run the new pagination cursor tests to verify they still pass**

```bash
uv run pytest tests/test_pagination_cursor.py -v
```

Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/archive.py
git commit -m "refactor(v2): PaginationCursor delegates to new Cursor primitive

Compat shim — public API unchanged, implementation now goes through
openzim_mcp.pagination.Cursor with t='legacy'. Removed at the end of
Phase B once every caller has migrated to the tool-bound Cursor API."
```

---

## Task 4: Add `openzim_mcp/tool_schemas.py` with all per-tool TypedDicts

**Files:**
- Create: `openzim_mcp/tool_schemas.py`
- Create: `tests/test_tool_schemas.py`

**Why:** Centralizing every per-tool response TypedDict in one file makes the schema surface auditable, prevents drift between tools that share patterns (e.g., search-style vs. namespace-style), and gives FastMCP a stable import target for return-type annotations. This task defines them all up-front so per-tool migration tasks can just import.

The file is large (~250 lines) but structurally simple. Subagent should follow the layout below precisely — divergence creates drift the contract regression test can't catch.

- [ ] **Step 1: Write the failing structural test**

Create `tests/test_tool_schemas.py`:

```python
"""Structural tests for openzim_mcp.tool_schemas.

These tests don't assert behavior — they assert the TypedDict surface
exists and the contract keys are present on every list-returning response.
The dynamic per-tool wire-format checks live in test_response_contract.py.
"""

from __future__ import annotations

from typing import get_type_hints

from openzim_mcp import tool_schemas as ts


# Every list-returning response TypedDict must declare these five keys
# plus _meta. The interpretation of each is documented in the spec.
CONTRACT_KEYS = {"results", "next_cursor", "total", "done", "page_info", "_meta"}


def test_page_info_has_required_fields() -> None:
    hints = get_type_hints(ts.PageInfo)
    assert "offset" in hints
    assert "limit" in hints
    assert "returned_count" in hints
    # total_is_lower_bound is NotRequired — get_type_hints still surfaces it.
    assert "total_is_lower_bound" in hints


def test_meta_envelope_has_phase_a_fields() -> None:
    hints = get_type_hints(ts.MetaEnvelope)
    for key in ("tokens_est", "chars", "truncated", "more_at_offset", "total_chars",
                "suggestions", "reason"):
        assert key in hints, f"MetaEnvelope missing {key}"


def test_paginated_responses_carry_contract_keys() -> None:
    paginated = [
        ts.SearchResponse,
        ts.SearchAllResponse,
        ts.SearchWithFiltersResponse,
        ts.FindEntryResponse,
        ts.SearchSuggestionsResponse,
        ts.BrowseNamespaceResponse,
        ts.WalkNamespaceResponse,
        ts.LinksResponse,
        ts.ListZimFilesResponse,
        ts.RelatedArticlesResponse,
        ts.BatchEntryResponse,
    ]
    for cls in paginated:
        hints = get_type_hints(cls)
        missing = CONTRACT_KEYS - set(hints.keys())
        assert not missing, f"{cls.__name__} missing contract keys: {missing}"


def test_non_paginated_responses_do_not_carry_results() -> None:
    """list_namespaces and the entry/metadata tools don't get the contract."""
    non_paginated = [
        ts.ZimMetadataResponse,
        ts.ListNamespacesResponse,
        ts.EntryResponse,
        ts.EntrySummaryResponse,
        ts.TableOfContentsResponse,
        ts.ArticleStructureResponse,
        ts.BinaryEntryResponse,
    ]
    for cls in non_paginated:
        hints = get_type_hints(cls)
        # _meta is universal; the rest of the contract keys must NOT be present.
        leaked = {"results", "next_cursor", "done", "page_info"} & set(hints.keys())
        assert not leaked, f"{cls.__name__} unexpectedly has paginated keys: {leaked}"
        assert "_meta" in hints, f"{cls.__name__} missing _meta envelope"
```

- [ ] **Step 2: Run tests to verify they fail with import errors**

```bash
uv run pytest tests/test_tool_schemas.py -v
```

Expected: FAIL with `ModuleNotFoundError: No module named 'openzim_mcp.tool_schemas'`.

- [ ] **Step 3: Create `openzim_mcp/tool_schemas.py`**

```python
"""Per-tool response TypedDicts for v2 Phase B.

Every dict-returning tool's ``_data`` method returns one of these
TypedDicts (or ``ToolErrorPayload`` from openzim_mcp.responses on
failure). FastMCP reads the function's return annotation to generate
the output schema and emits the payload at the top level of
``structuredContent`` — no ``{"result": ...}`` wrapper.

Every list-returning tool's TypedDict carries the five contract keys
(``results``, ``next_cursor``, ``total``, ``done``, ``page_info``)
plus ``_meta``. See the design spec for shape details:
docs/superpowers/specs/2026-05-08-v2-phase-b-response-contract-design.md
"""

from __future__ import annotations

from typing import Any, Dict, List, NotRequired, Optional, TypedDict


# ---------- shared sub-shapes ----------


class PageInfo(TypedDict):
    offset: int
    limit: int
    returned_count: int
    total_is_lower_bound: NotRequired[bool]


class MetaEnvelope(TypedDict, total=False):
    tokens_est: int
    chars: int
    truncated: bool
    more_at_offset: int
    total_chars: int
    suggestions: List[Dict[str, str]]
    reason: str


# ---------- per-item shapes ----------


class SearchHit(TypedDict):
    path: str
    title: str
    snippet: str
    # search_all carries archive identity per-hit; per-archive search omits it.
    zim_file: NotRequired[str]


class FindEntryHit(TypedDict):
    path: str
    title: str
    score: float
    zim_file: NotRequired[str]
    match_type: NotRequired[str]


class SuggestionItem(TypedDict):
    text: str
    path: str
    type: str


class NamespaceEntry(TypedDict):
    path: str
    title: str
    content_type: NotRequired[str]
    preview: NotRequired[str]


class WalkEntry(TypedDict):
    path: str
    title: str


class LinkItem(TypedDict):
    target: str
    label: str


class FileSummary(TypedDict):
    name: str
    path: str
    directory: str
    size: str
    size_bytes: int
    modified: str


class RelatedArticle(TypedDict):
    path: str
    title: str
    link_text: NotRequired[str]


class NamespaceSummary(TypedDict):
    total: int
    is_authoritative: bool


# ---------- list-returning tool responses ----------


class SearchResponse(TypedDict):
    # Contract keys
    results: List[SearchHit]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    # Tool-specific extras
    query: str


class _SearchAllPerFile(TypedDict):
    zim_file_path: str
    name: str
    has_hits: bool
    result: SearchResponse


class SearchAllResponse(TypedDict):
    results: List[_SearchAllPerFile]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    # Tool-specific extras
    query: str
    files_searched: int
    files_with_hits: int
    files_searched_successfully: int
    files_failed: int


class SearchWithFiltersResponse(TypedDict):
    results: List[SearchHit]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    query: str
    namespace_filter: Optional[str]
    content_type_filter: Optional[str]


class FindEntryResponse(TypedDict):
    results: List[FindEntryHit]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    query: str
    fast_path_hit: bool
    files_searched: int


class SearchSuggestionsResponse(TypedDict):
    results: List[SuggestionItem]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    partial_query: str


class BrowseNamespaceResponse(TypedDict):
    results: List[NamespaceEntry]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    namespace: str
    discovery_method: str
    sampling_based: bool
    results_may_be_incomplete: bool


class WalkNamespaceResponse(TypedDict):
    results: List[WalkEntry]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    namespace: str
    scanned_count: int
    scanned_through_id: int
    archive_entry_count: int


class LinksResponse(TypedDict):
    results: List[LinkItem]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    title: str
    path: str
    content_type: str
    kind: str
    category_totals: Dict[str, int]


class ListZimFilesResponse(TypedDict):
    results: List[FileSummary]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    name_filter: Optional[str]
    directories_count: int


class RelatedArticlesResponse(TypedDict):
    results: List[RelatedArticle]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    entry_path: str


class _BatchEntryItem(TypedDict):
    path: str
    success: bool
    content: NotRequired[str]
    error: NotRequired[str]


class BatchEntryResponse(TypedDict):
    results: List[_BatchEntryItem]
    next_cursor: Optional[str]
    total: Optional[int]
    done: bool
    page_info: PageInfo
    _meta: MetaEnvelope
    succeeded: int
    failed: int


# ---------- non-list tool responses ----------


class ZimMetadataResponse(TypedDict):
    entry_count: int
    all_entry_count: int
    article_count: int
    media_count: int
    metadata_entries: NotRequired[Dict[str, Any]]
    _meta: MetaEnvelope


class ListNamespacesResponse(TypedDict):
    total_entries: int
    sampled_entries: int
    has_new_namespace_scheme: bool
    is_total_authoritative: bool
    discovery_method: str
    namespaces: Dict[str, NamespaceSummary]
    _meta: MetaEnvelope


class EntryResponse(TypedDict):
    path: str
    title: str
    content: str
    content_type: NotRequired[str]
    _meta: MetaEnvelope


class EntrySummaryResponse(TypedDict):
    path: str
    title: str
    summary: str
    word_count: NotRequired[int]
    _meta: MetaEnvelope


class TableOfContentsResponse(TypedDict):
    title: str
    path: str
    toc: List[Dict[str, Any]]
    heading_count: int
    max_depth: int
    _meta: MetaEnvelope


class ArticleStructureResponse(TypedDict):
    title: str
    path: str
    content_type: str
    headings: List[Dict[str, Any]]
    sections: List[Dict[str, Any]]
    metadata: Dict[str, Any]
    word_count: int
    character_count: int
    _meta: MetaEnvelope


class BinaryEntryResponse(TypedDict):
    path: str
    title: str
    mime_type: str
    size: int
    size_human: str
    encoding: Optional[str]
    truncated: bool
    data: NotRequired[Optional[str]]
    _meta: MetaEnvelope
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
uv run pytest tests/test_tool_schemas.py -v
```

Expected: all 4 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/tool_schemas.py tests/test_tool_schemas.py
git commit -m "feat(v2): add per-tool response TypedDicts for Phase B contract

Defines PageInfo, MetaEnvelope, and one TypedDict per dict-returning
tool. Every list-returning shape carries the five contract keys
(results, next_cursor, total, done, page_info) plus _meta. Used as
return annotations in upcoming per-tool migrations so FastMCP emits
top-level structuredContent payloads with real schemas."
```

---

## Task 5: Migrate `search_zim_file` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/search.py:269-370` (`_perform_search`)
- Modify: `openzim_mcp/zim/search.py:183-267` (`search_zim_file_data`)
- Modify: `openzim_mcp/tools/search_tools.py:25-148` (tool registration)
- Modify: `tests/test_search_tools.py` (key-rename assertions)
- Modify: `tests/test_structured_tool_output.py:30-77` (top-level payload assertion)

**Why:** Foundational paginated tool. The migration pattern established here is repeated for every other paginated tool. See spec section [`search_zim_file`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#search_zim_file) for the before/after JSON.

**Wire-format changes:** `pagination` nested block removed; `total_results` → `total`; `pagination.has_more` → top-level `done` (polarity flipped); top-level `next_cursor`; new `page_info`; `pagination.showing_start`/`showing_end` removed (derivable from offset+returned_count). Cursor uses `Cursor.encode(tool="search_zim_file", state={"o": offset, "l": limit, "q": query})`.

- [ ] **Step 1: Update `tests/test_search_tools.py` test assertions for the new shape**

Open `tests/test_search_tools.py`. Find every test that calls `search_zim_file_data`, `search_zim_file`, or asserts on the structured response. Update the assertions:

- `result["total_results"]` → `result["total"]`
- `result["pagination"]["has_more"]` → `not result["done"]` (polarity flip)
- `result["pagination"]["next_cursor"]` → `result["next_cursor"]`
- `result["offset"]` → `result["page_info"]["offset"]`
- `result["limit"]` → `result["page_info"]["limit"]`
- Remove any assertions on `pagination.showing_start` / `pagination.showing_end` / `pagination.offset_exceeds_total` (the `offset_exceeds_total` flag is replaced by an empty `results` list and `total < offset` — assertions can check that.)
- Add assertion `result["page_info"]["returned_count"] == len(result["results"])` to one test for guard-rail coverage.

For tests that decode `next_cursor` to verify shape, replace usages of `PaginationCursor.decode` with:

```python
from openzim_mcp.pagination import Cursor
decoded = Cursor.decode(result["next_cursor"], expected_tool="search_zim_file")
assert decoded["s"]["o"] == <expected_offset>
assert decoded["s"]["l"] == <expected_limit>
assert decoded["s"]["q"] == "<expected_query>"
```

- [ ] **Step 2: Update `tests/test_structured_tool_output.py:30-77` assertion for `search_zim_file`**

Per the cross-cutting wire shape note above, FastMCP wraps the `Union[SearchResponse, ToolErrorPayload]` return in a uniform `{"result": ...}` envelope. Tests must accept the wrapper and assert the inner contract:

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
assert "results" in payload and isinstance(payload["results"], list)
assert "next_cursor" in payload
assert "total" in payload
assert "done" in payload
assert "page_info" in payload and isinstance(payload["page_info"], dict)
assert "query" in payload
```

If there's no `search_zim_file` test there yet, add one following the pattern of existing tests in that file (use `convert_result=True`, assert tuple, assert dict, then unwrap and assert inner contract keys).

- [ ] **Step 3: Run failing tests to confirm they fail with the right diagnostics**

```bash
uv run pytest tests/test_search_tools.py tests/test_structured_tool_output.py -v -k "search_zim_file or search"
```

Expected: failures pointing at missing `total` / `done` / `page_info` keys, or at `result` wrapper present.

- [ ] **Step 4: Update `_perform_search` (search.py:269) to produce the new shape**

Replace the `_perform_search` method body with:

```python
    def _perform_search(
        self, archive: Archive, query: str, limit: int, offset: int
    ) -> Tuple[Dict[str, Any], int]:
        """Perform the actual search operation.

        Returns:
            (structured_payload, total_results) — caller uses total_results
            to decide whether the response is safe to cache. The payload
            shape is documented on ``search_zim_file_data``.
        """
        from openzim_mcp.pagination import Cursor

        query_obj = _zim_ops_mod.Query().set_query(query)
        searcher = _zim_ops_mod.Searcher(archive)
        search = searcher.search(query_obj)

        total_results = search.getEstimatedMatches()

        if total_results == 0:
            return (
                {
                    "query": query,
                    "results": [],
                    "next_cursor": None,
                    "total": 0,
                    "done": True,
                    "page_info": {
                        "offset": offset,
                        "limit": limit,
                        "returned_count": 0,
                    },
                },
                0,
            )

        if offset >= total_results:
            return (
                {
                    "query": query,
                    "results": [],
                    "next_cursor": None,
                    "total": total_results,
                    "done": True,
                    "page_info": {
                        "offset": offset,
                        "limit": limit,
                        "returned_count": 0,
                    },
                },
                total_results,
            )

        result_count = min(limit, total_results - offset)
        result_entries = list(search.getResults(offset, result_count))

        results: List[Dict[str, Any]] = []
        for i, entry_id in enumerate(result_entries):
            try:
                entry = archive.get_entry_by_path(entry_id)
                title = entry.title or "Untitled"
                snippet = self._get_entry_snippet(entry, query=query)
                results.append({"path": entry_id, "title": title, "snippet": snippet})
            except Exception as e:
                logger.warning(f"Error processing search result {entry_id}: {e}")
                results.append(
                    {
                        "path": entry_id,
                        "title": f"Entry {offset + i + 1}",
                        "snippet": f"(Error getting entry details: {e})",
                    }
                )

        returned_count = len(results)
        last_index = offset + returned_count
        done = last_index >= total_results
        next_cursor: Optional[str] = None
        if not done:
            next_cursor = Cursor.encode(
                tool="search_zim_file",
                state={"o": last_index, "l": limit, "q": query},
            )

        return (
            {
                "query": query,
                "results": results,
                "next_cursor": next_cursor,
                "total": total_results,
                "done": done,
                "page_info": {
                    "offset": offset,
                    "limit": limit,
                    "returned_count": returned_count,
                },
            },
            total_results,
        )
```

- [ ] **Step 5: Bump the cache key in `search_zim_file_data` (line ~210)**

Replace:

```python
        cache_key = f"search_data:{validated_path}:{query}:{limit}:{offset}"
```

with:

```python
        # Cache key bumped to v2b (Phase B) so v1.x cached responses (old shape)
        # don't leak through after the upgrade.
        cache_key = f"search_v2b:{validated_path}:{query}:{limit}:{offset}"
```

- [ ] **Step 6: Update `_format_search_text` (search.py:372) to read the new keys**

Replace the `query`/`total_results`/`offset`/`limit`/`results`/`pagination` extraction at lines 378–383 with:

```python
        query = payload["query"]
        total_results = payload["total"] or 0
        page_info = payload["page_info"]
        offset = page_info["offset"]
        limit = page_info["limit"]
        results = payload["results"]
        done = payload["done"]
        next_cursor = payload.get("next_cursor")
```

Then update the rest of the function: anywhere it referenced `pagination["has_more"]`, use `not done`. Anywhere it referenced `pagination["showing_start"]` / `showing_end`, compute them as `offset + 1` and `offset + len(results)` inline. Anywhere it referenced `pagination["next_cursor"]`, use `next_cursor` directly.

- [ ] **Step 7: Update `search_zim_file_data` return annotation (search.py:189)**

Change:

```python
    ) -> Dict[str, Any]:
```

to:

```python
    ) -> "SearchResponse":
```

Add at the top of search.py (after the existing imports):

```python
from openzim_mcp.tool_schemas import SearchResponse
```

(Use `if TYPE_CHECKING:` guard if there's a circular-import concern; otherwise import directly.)

- [ ] **Step 8: Update tool registration in `openzim_mcp/tools/search_tools.py`**

Find the `search_zim_file` registration (around line 25). Update its return annotation:

```python
async def search_zim_file(
    zim_file_path: str,
    query: str,
    limit: Optional[int] = None,
    offset: int = 0,
    cursor: Optional[str] = None,
) -> Union["SearchResponse", "ToolErrorPayload"]:
```

Add a `cursor: Optional[str] = None` parameter. At the top of the function body, before the rate-limit check, add:

```python
    # Phase B: cursor wins on conflict per response-contract spec.
    if cursor is not None:
        from openzim_mcp.pagination import Cursor, CursorMismatchError
        try:
            decoded = Cursor.decode(cursor, expected_tool="search_zim_file")
        except CursorMismatchError as e:
            return tool_error(
                operation="search ZIM file",
                message=str(e),
                context=f"Tool: search_zim_file, cursor=<truncated>",
            )
        except ValueError as e:
            return tool_error(
                operation="search ZIM file",
                message=f"Invalid pagination cursor: {e}",
                context=f"Tool: search_zim_file",
            )
        offset = decoded["s"]["o"]
        if "l" in decoded["s"]:
            limit = decoded["s"]["l"]
        # Cursor's q overrides the query argument so the cursor round-trips
        # cleanly even if a caller forgets to pass query alongside.
        if "q" in decoded["s"]:
            query = decoded["s"]["q"]
```

Add the imports at the top of the file:

```python
from typing import Optional, Union
from openzim_mcp.tool_schemas import SearchResponse
from openzim_mcp.responses import ToolErrorPayload
```

- [ ] **Step 9: Run the affected tests**

```bash
uv run pytest tests/test_search_tools.py tests/test_structured_tool_output.py tests/test_search_all.py -v
```

Expected: all PASS. (`search_all` is included because it depends on `search_zim_file_data`'s output shape — Task 6 finishes the migration.)

If `test_search_all.py` fails because it asserts on the OLD `search_zim_file` shape inside per_file results, defer those assertion fixes to Task 6 — for now, comment out the failing search_all assertions with a `# Task 6: shape migration` marker. Run `uv run pytest tests/test_search_tools.py tests/test_structured_tool_output.py -v -k "search_zim_file"` and confirm those pass.

- [ ] **Step 10: Commit**

```bash
git add openzim_mcp/zim/search.py openzim_mcp/tools/search_tools.py openzim_mcp/tool_schemas.py tests/test_search_tools.py tests/test_structured_tool_output.py tests/test_search_all.py
git commit -m "feat(v2)!: migrate search_zim_file to PaginatedResponse + TypedDict

BREAKING: response shape changes per spec:
- top-level next_cursor / total / done / page_info (was nested 'pagination')
- total_results renamed to total
- has_more removed (use !done)
- pagination.showing_start/showing_end removed (derivable)
- cursor input added; cursor is tool-bound (t='search_zim_file')

Cache key bumped to search_v2b: to avoid old-shape leakthrough."
```

---

## Task 6: Migrate `search_all` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/search.py:1575-1673` (`search_all_data` and `_search_all_data` if separate)
- Modify: `openzim_mcp/tools/search_tools.py:150-207` (`search_all` tool registration)
- Modify: `tests/test_search_all.py`
- Modify: `tests/test_structured_tool_output.py:209-234` (`test_search_all_returns_structured_content`)

**Why:** Top-level `search_all` becomes a non-paginated `PaginatedResponse[per_file]` (always `done=true`); each `per_file[].result` is a `SearchResponse` from Task 5. Spec section [`search_all`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#search_all).

- [ ] **Step 1: Update `tests/test_search_all.py`**

Find every test that asserts on `search_all_data` output. Update:

- `result["per_file"]` → `result["results"]` (the top-level list of per-file blocks)
- Per-file blocks keep their existing keys (`zim_file_path`, `name`, `result`, `has_hits`); inside each `result` dict, apply the Task 5 key renames (`total_results` → `total`, etc.)
- Add top-level contract-shape assertions: `assert result["done"] is True`, `assert result["next_cursor"] is None`, `assert result["total"] == result["files_searched"]`
- Top-level `page_info`: `{"offset": 0, "limit": result["files_searched"], "returned_count": len(result["results"])}`

Also un-comment any `# Task 6: shape migration` markers from Task 5 and update those assertions.

- [ ] **Step 2: Update `tests/test_structured_tool_output.py:209-234`**

In `test_search_all_returns_structured_content`:

```python
payload = structured["result"] if "result" in structured else structured
assert "per_file" in payload
for entry in payload.get("per_file", []):
    if "result" in entry:
        assert isinstance(entry["result"], dict), ...
        assert "results" in entry["result"]
```

Change to (per the cross-cutting wire shape note — accept the FastMCP `{"result": ...}` wrapper, assert inner contract):

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
assert "per_file" not in payload, (
    "TypedDict migration regression: top-level still uses legacy "
    "``per_file`` key — Task 6 renamed it to ``results``."
)
assert "results" in payload
assert payload["done"] is True
assert payload["next_cursor"] is None
assert "page_info" in payload
for entry in payload["results"]:
    inner = entry["result"]
    assert isinstance(inner, dict), f"per_file[].result should be dict, got {type(inner)}"
    assert "results" in inner  # inner is itself a SearchResponse — has its own results list
    assert "done" in inner
    assert "next_cursor" in inner
```

- [ ] **Step 3: Run tests to confirm failure**

```bash
uv run pytest tests/test_search_all.py tests/test_structured_tool_output.py::TestStructuredOutput::test_search_all_returns_structured_content -v
```

Expected: failures on missing `results` (top level), `done`, etc.

- [ ] **Step 4: Update `search_all_data` in `openzim_mcp/zim/search.py:1575`**

Locate the function. Replace its return-payload construction so the shape becomes:

```python
{
    "query": query,
    "files_searched": <int>,
    "files_with_hits": <int>,
    "files_searched_successfully": <int>,
    "files_failed": <int>,
    "results": [
        {"zim_file_path": <str>, "name": <str>, "has_hits": <bool>, "result": <SearchResponse>},
        ...
    ],
    "next_cursor": None,
    "total": <int (= files_searched)>,
    "done": True,
    "page_info": {"offset": 0, "limit": <files_searched>, "returned_count": <len(results)>},
}
```

The existing `per_file` list becomes the new `results`. Apply `attach_meta(payload)` at the end if it's not already (the existing implementation likely already does this).

Update the return annotation:

```python
def search_all_data(self, query: str, limit_per_file: Optional[int] = None) -> "SearchAllResponse":
```

Add the import at the top of the file:

```python
from openzim_mcp.tool_schemas import SearchAllResponse
```

- [ ] **Step 5: Update `search_all` tool registration in `tools/search_tools.py:150`**

Update return annotation:

```python
) -> Union["SearchAllResponse", "ToolErrorPayload"]:
```

(`SearchAllResponse` import already added in Task 5; if not, add it.)

`search_all` does NOT take a top-level cursor — pagination happens per-archive. No input change.

- [ ] **Step 6: Run tests**

```bash
uv run pytest tests/test_search_all.py tests/test_structured_tool_output.py -v
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add openzim_mcp/zim/search.py openzim_mcp/tools/search_tools.py tests/test_search_all.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: migrate search_all to PaginatedResponse + TypedDict

BREAKING: top-level shape changes:
- per_file renamed to results
- five contract keys added at top level (always done=true)
- per_file[].result inherits the SearchResponse migration from Task 5"
```

---

## Task 7: Migrate `search_with_filters` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/tools/navigation_tools.py:251-323` (`search_with_filters` tool)
- Modify: corresponding `_data` method in `openzim_mcp/zim/search.py` (search for `search_with_filters_data`)
- Modify: `tests/test_navigation_tools.py` (search_with_filters tests)
- Modify: `tests/test_structured_tool_output.py` (add or update test for this tool)

**Why:** Today `search_with_filters` returns a markdown text block. Phase B migrates it to the same `SearchResponse`-shaped contract as `search_zim_file`, with three extra extras: `query`, `namespace_filter`, `content_type_filter`. Spec section [`search_with_filters`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#search_with_filters).

- [ ] **Step 1: Locate today's implementation**

```bash
grep -n "def search_with_filters\|search_with_filters_data" openzim_mcp/zim/search.py openzim_mcp/tools/navigation_tools.py
```

If `search_with_filters_data` doesn't exist (today's implementation may render markdown directly inside the tool function), create it as the dict-returning sibling — same pattern as `search_zim_file` / `search_zim_file_data`.

- [ ] **Step 2: Update `tests/test_navigation_tools.py` for `search_with_filters`**

Existing tests likely assert on rendered markdown. Replace those assertions with structured-shape assertions:

```python
import pytest
from openzim_mcp.pagination import Cursor

@pytest.mark.asyncio
async def test_search_with_filters_returns_paginated_response(server, basic_test_zim_files):
    zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
    if zim_path is None:
        pytest.skip("ZIM testing-suite small.zim not available")
    result = await server.mcp._tool_manager.call_tool(
        "search_with_filters",
        {"zim_file_path": str(zim_path), "query": "evolution", "namespace": "C"},
        convert_result=True,
    )
    assert isinstance(result, tuple)
    _, payload = result
    assert "result" not in payload  # TypedDict — top level, no wrapper
    for key in ("results", "next_cursor", "total", "done", "page_info", "_meta"):
        assert key in payload
    assert payload["query"] == "evolution"
    assert payload["namespace_filter"] == "C"
    assert payload["content_type_filter"] is None
```

- [ ] **Step 3: Run failing tests**

```bash
uv run pytest tests/test_navigation_tools.py -v -k "search_with_filters"
```

Expected: failures.

- [ ] **Step 4: Implement `search_with_filters_data`**

In `openzim_mcp/zim/search.py`, add a new method (or convert the existing markdown-returning implementation):

```python
def search_with_filters_data(
    self,
    zim_file_path: str,
    query: str,
    namespace: Optional[str] = None,
    content_type: Optional[str] = None,
    limit: Optional[int] = None,
    offset: int = 0,
) -> "SearchWithFiltersResponse":
    """Structured filtered-search response. v2 Phase B contract."""
    from openzim_mcp.pagination import Cursor

    if limit is None:
        limit = self.config.content.default_search_limit

    # Reuse search_zim_file_data and post-filter results by namespace/content_type.
    # (If the existing v1 implementation already filters at the libzim level,
    # preserve that behavior; the response shape is what's changing.)
    inner = self.search_zim_file_data(zim_file_path, query, limit=limit, offset=offset)

    # Post-filter — preserve order, drop entries that don't match filters.
    filtered_results = []
    for hit in inner["results"]:
        if namespace and not hit["path"].startswith(f"{namespace}/"):
            continue
        # content_type filter requires looking up the entry; if the v1 path
        # already does this, keep that lookup. Otherwise, skip the filter.
        filtered_results.append(hit)

    total = inner["total"]
    page_info = dict(inner["page_info"])
    page_info["returned_count"] = len(filtered_results)
    done = inner["done"]
    next_cursor: Optional[str] = None
    if not done:
        next_cursor = Cursor.encode(
            tool="search_with_filters",
            state={
                "o": page_info["offset"] + len(filtered_results),
                "l": limit,
                "q": query,
                **({"ns": namespace} if namespace else {}),
                **({"ct": content_type} if content_type else {}),
            },
        )

    payload: Dict[str, Any] = {
        "query": query,
        "namespace_filter": namespace,
        "content_type_filter": content_type,
        "results": filtered_results,
        "next_cursor": next_cursor,
        "total": total,
        "done": done,
        "page_info": page_info,
    }
    return cast("SearchWithFiltersResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import SearchWithFiltersResponse` at the top.

If the existing `search_with_filters` implementation in `tools/navigation_tools.py` builds the response as markdown, replace its body with a delegation to `search_with_filters_data`:

```python
@server.mcp.tool()
async def search_with_filters(
    zim_file_path: str,
    query: str,
    namespace: Optional[str] = None,
    content_type: Optional[str] = None,
    limit: Optional[int] = None,
    offset: int = 0,
    cursor: Optional[str] = None,
) -> Union["SearchWithFiltersResponse", "ToolErrorPayload"]:
    try:
        # Cursor decode — same pattern as search_zim_file (Task 5).
        if cursor is not None:
            from openzim_mcp.pagination import Cursor, CursorMismatchError
            try:
                decoded = Cursor.decode(cursor, expected_tool="search_with_filters")
            except CursorMismatchError as e:
                return tool_error(operation="search with filters", message=str(e), context=f"cursor=<truncated>")
            except ValueError as e:
                return tool_error(operation="search with filters", message=f"Invalid pagination cursor: {e}", context=None)
            offset = decoded["s"]["o"]
            limit = decoded["s"].get("l", limit)
            query = decoded["s"].get("q", query)
            namespace = decoded["s"].get("ns", namespace)
            content_type = decoded["s"].get("ct", content_type)

        # ... existing rate-limit check + sanitize_input pass ...

        return await server.async_zim_operations.search_with_filters_data(
            zim_file_path, query, namespace, content_type, limit, offset
        )
    except Exception as e:
        logger.error(f"Error in search_with_filters: {e}")
        return tool_error(operation="search with filters", message=str(e), context=f"query={query}")
```

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "search_with_filters"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/search.py openzim_mcp/tools/navigation_tools.py openzim_mcp/tool_schemas.py tests/test_navigation_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: migrate search_with_filters to PaginatedResponse + TypedDict

BREAKING: now returns structured dict (was markdown text). Same shape
as search_zim_file with namespace_filter and content_type_filter
extras. Cursor t='search_with_filters'."
```

---

## Task 8: Migrate `find_entry_by_title` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/search.py` (`find_entry_by_title_data`, around line 1208)
- Modify: `openzim_mcp/tools/search_tools.py:223-283` (tool registration)
- Modify: `tests/test_find_entry_by_title.py` and `tests/test_find_entry_by_title_quality.py`
- Modify: `tests/test_structured_tool_output.py:119-141`

**Why:** Non-paginated tool gets the contract for uniformity. Spec section [`find_entry_by_title`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#find_entry_by_title).

- [ ] **Step 1: Update tests**

In each test file referenced above, find assertions on `result["results"]`, `result["files_searched"]`, etc. They mostly stay — the tool is non-paginated so `results` keeps its meaning. Add new assertions:

```python
assert payload["next_cursor"] is None
assert payload["done"] is True
assert payload["total"] == len(payload["results"])
assert payload["page_info"]["offset"] == 0
assert payload["page_info"]["limit"] == 10  # default; or whatever was passed
assert payload["page_info"]["returned_count"] == len(payload["results"])
```

In `test_structured_tool_output.py`, the existing wrapper-tolerant pattern stays (per the cross-cutting wire shape note above — FastMCP wraps `Union` returns in `{"result": ...}`):

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
```

Add the contract-shape assertions on `payload` (now the inner dict).

- [ ] **Step 2: Run tests to confirm failure**

```bash
uv run pytest tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py tests/test_structured_tool_output.py -v -k "find_entry"
```

Expected: failures on missing contract keys.

- [ ] **Step 3: Update `find_entry_by_title_data` in search.py**

Locate the function (around line 1208). At the end, where the response dict is built, add the contract keys and update the return annotation.

```python
def find_entry_by_title_data(
    self, zim_file_path: str, query: str, limit: int = 10
) -> "FindEntryResponse":
    # ... existing logic that builds `results: list[FindEntryHit]`,
    #     `fast_path_hit`, `files_searched` ...

    payload: Dict[str, Any] = {
        "query": query,
        "results": results,
        "next_cursor": None,
        "total": len(results),
        "done": True,
        "page_info": {"offset": 0, "limit": limit, "returned_count": len(results)},
        "fast_path_hit": fast_path_hit,
        "files_searched": files_searched,
    }
    return cast("FindEntryResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import FindEntryResponse` at the top of search.py if not already present.

- [ ] **Step 4: Update tool registration in `tools/search_tools.py:223`**

Change the return annotation to `Union["FindEntryResponse", "ToolErrorPayload"]`. Add the import.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py tests/test_structured_tool_output.py -v -k "find_entry"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/search.py openzim_mcp/tools/search_tools.py tests/test_find_entry_by_title.py tests/test_find_entry_by_title_quality.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: migrate find_entry_by_title to PaginatedResponse + TypedDict

Non-paginated tool gets the contract for uniformity (next_cursor=None,
done=True, total=len(results)). Existing fast_path_hit and
files_searched keys preserved as tool-specific extras."
```

---

## Task 9: Migrate `get_search_suggestions` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/search.py:781-867` (`get_search_suggestions_data`)
- Modify: `openzim_mcp/tools/navigation_tools.py:328-401` (tool registration)
- Modify: `tests/test_navigation_tools.py` (suggestions tests)
- Modify: `tests/test_structured_tool_output.py:187-207`

**Why:** Renames `suggestions` → `results` (top-level). The Phase A `_meta.suggestions[]` is a different concept (recovery candidates) and stays inside `_meta`. Spec section [`get_search_suggestions`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#get_search_suggestions).

- [ ] **Step 1: Update tests**

In `test_navigation_tools.py` and `test_structured_tool_output.py:187-207`, replace `suggestions` → `results` in every assertion:

```python
assert "results" in payload
assert isinstance(payload["results"], list)
# Add contract assertions:
assert payload["next_cursor"] is None
assert payload["done"] is True
assert payload["total"] == len(payload["results"])
```

Drop assertions on `payload["count"]` (replaced by `page_info.returned_count`).

In `test_structured_tool_output.py`, the existing wrapper-tolerant pattern stays (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`):

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
```

Then assert the renamed `results` key + the contract keys on `payload`.

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "suggestions or search_suggestions"
```

Expected: failures on missing keys.

- [ ] **Step 3: Update `get_search_suggestions_data` in search.py:781**

```python
def get_search_suggestions_data(
    self, zim_file_path: str, partial_query: str, limit: int = 10
) -> "SearchSuggestionsResponse":
    # ... existing logic that builds `suggestions: list[SuggestionItem]` and `message` ...

    payload: Dict[str, Any] = {
        "partial_query": partial_query,
        "results": suggestions,  # was 'suggestions'
        "next_cursor": None,
        "total": len(suggestions),
        "done": True,
        "page_info": {"offset": 0, "limit": limit, "returned_count": len(suggestions)},
    }
    # If the v1 implementation included an optional 'message' field, drop it —
    # the structured suggestions live in _meta.suggestions per Phase A.
    return cast("SearchSuggestionsResponse", attach_meta(payload))
```

Add import: `from openzim_mcp.tool_schemas import SearchSuggestionsResponse`.

- [ ] **Step 4: Update tool registration in `tools/navigation_tools.py:328`**

Update return annotation to `Union["SearchSuggestionsResponse", "ToolErrorPayload"]`. Update the docstring's `Returns:` section to describe the new shape.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "suggestions or search_suggestions"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/search.py openzim_mcp/tools/navigation_tools.py tests/test_navigation_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: migrate get_search_suggestions to PaginatedResponse + TypedDict

BREAKING: suggestions list renamed to results to fit the contract.
The Phase A _meta.suggestions[] (recovery candidates) is unrelated
and stays inside _meta. count removed (use page_info.returned_count)."
```

---

## Task 10: Migrate `browse_namespace` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/namespace.py:470-534` (`browse_namespace_data`)
- Modify: `openzim_mcp/tools/navigation_tools.py:33-130` (tool registration)
- Modify: `tests/test_navigation_tools.py` (browse_namespace tests)
- Modify: `tests/test_structured_tool_output.py:142-162`

**Why:** Renames `entries` → `results`, `total_in_namespace` → `total`, `total_in_namespace_is_lower_bound` → `page_info.total_is_lower_bound`. Removes `has_more` (use `done`). Spec section [`browse_namespace`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#browse_namespace).

- [ ] **Step 1: Update tests**

In each test file, apply the renames:
- `result["entries"]` → `result["results"]`
- `result["total_in_namespace"]` → `result["total"]`
- `result["total_in_namespace_is_lower_bound"]` → `result["page_info"]["total_is_lower_bound"]`
- `result["has_more"]` → `not result["done"]`
- `result["offset"]` → `result["page_info"]["offset"]`
- `result["limit"]` → `result["page_info"]["limit"]`
- `result["returned_count"]` → `result["page_info"]["returned_count"]`

Drop assertions on `result["is_total_authoritative"]` at the top level (use `not page_info.get("total_is_lower_bound", False)` instead).

For tests that decode `next_cursor`, replace `PaginationCursor.decode` with:

```python
from openzim_mcp.pagination import Cursor
decoded = Cursor.decode(payload["next_cursor"], expected_tool="browse_namespace")
assert decoded["s"]["o"] == <expected>
assert decoded["s"]["ns"] == <expected_namespace>
```

In `test_structured_tool_output.py:142-162`, keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Update the inner assertions to use the renamed keys (`results`, `total`, `page_info.*`).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "browse"
```

Expected: failures on missing keys.

- [ ] **Step 3: Update `browse_namespace_data` in namespace.py:470**

The function currently builds a response with `entries`, `total_in_namespace`, `next_cursor` (top-level), `offset`, `limit`, etc. Restructure it as:

```python
from openzim_mcp.pagination import Cursor

# ... existing logic that builds `entries`, `total_in_namespace`,
#     `is_total_authoritative`, `discovery_method`, `sampling_based`,
#     `results_may_be_incomplete` ...

returned_count = len(entries)
last_index = offset + returned_count
done = (
    total_in_namespace is not None
    and last_index >= total_in_namespace
)
next_cursor: Optional[str] = None
if not done:
    next_cursor = Cursor.encode(
        tool="browse_namespace",
        state={"o": last_index, "l": limit, "ns": namespace},
    )

page_info: Dict[str, Any] = {
    "offset": offset,
    "limit": limit,
    "returned_count": returned_count,
}
if not is_total_authoritative:
    page_info["total_is_lower_bound"] = True

payload: Dict[str, Any] = {
    "namespace": namespace,
    "results": entries,  # was 'entries'
    "next_cursor": next_cursor,
    "total": total_in_namespace,
    "done": done,
    "page_info": page_info,
    "discovery_method": discovery_method,
    "sampling_based": sampling_based,
    "results_may_be_incomplete": results_may_be_incomplete,
}
return cast("BrowseNamespaceResponse", attach_meta(payload))
```

Update the return annotation:

```python
def browse_namespace_data(
    self, zim_file_path: str, namespace: str, limit: int = 50, offset: int = 0
) -> "BrowseNamespaceResponse":
```

Add `from openzim_mcp.tool_schemas import BrowseNamespaceResponse` at the top.

- [ ] **Step 4: Update tool registration in `tools/navigation_tools.py:33`**

Add a `cursor: Optional[str] = None` parameter to the tool function. At the top of the body, after the rate-limit check, add the cursor decode block (same pattern as Task 5, with `expected_tool="browse_namespace"` — the cursor's `s` carries `o`, `l`, `ns`).

Update the return annotation to `Union["BrowseNamespaceResponse", "ToolErrorPayload"]`. Update the docstring's `Returns:` description.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "browse"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/namespace.py openzim_mcp/tools/navigation_tools.py tests/test_navigation_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: migrate browse_namespace to PaginatedResponse + TypedDict

BREAKING:
- entries renamed to results
- total_in_namespace renamed to total
- total_in_namespace_is_lower_bound moved to page_info
- has_more removed (use !done)
- is_total_authoritative top-level removed (use page_info)
- cursor input added; cursor t='browse_namespace'"
```

---

## Task 11: Migrate `walk_namespace` to the contract + TypedDict (opaque cursor)

**Files:**
- Modify: `openzim_mcp/zim/namespace.py:943-1076` (`walk_namespace_data`)
- Modify: `openzim_mcp/tools/navigation_tools.py:135-246` (tool registration)
- Modify: `tests/test_navigation_tools.py`, `tests/test_walk_namespace.py`
- Modify: `tests/test_structured_tool_output.py:164-185`

**Why:** Today's `cursor: int` (entry id) becomes opaque (encoded `scan_at`). `total: null` (mid-scan, never knowable). Renames `entries` → `results`. Spec section [`walk_namespace`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#walk_namespace).

- [ ] **Step 1: Update tests**

Apply the renames + new contract assertions:

- `result["entries"]` → `result["results"]`
- `result["cursor"]` (int output) — REMOVED. Now read `result["next_cursor"]` (opaque str) and decode if needed.
- `result["done"]` — already exists; preserved.
- Add: `assert result["total"] is None` (walk doesn't know total per-namespace)
- Add: `assert "page_info" in result` and shape check
- For tests that pass `cursor` as int: change to `cursor=None` for first page, then for second-page tests pass `cursor=Cursor.encode(tool="walk_namespace", state={"scan_at": <int>, "l": limit})`.

In `test_structured_tool_output.py:164-185`, keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Update the inner assertions to use the renamed `results` key and add `assert payload["total"] is None`.

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_walk_namespace.py tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "walk"
```

Expected: failures.

- [ ] **Step 3: Update `walk_namespace_data` in namespace.py:943**

```python
from openzim_mcp.pagination import Cursor

def walk_namespace_data(
    self,
    zim_file_path: str,
    namespace: str,
    cursor_state: Optional[Dict[str, Any]] = None,  # decoded cursor.s, internal API
    limit: int = 200,
) -> "WalkNamespaceResponse":
    """Streaming-scan walk of a namespace. Cursor is opaque on the wire."""
    scan_at = (cursor_state or {}).get("scan_at", 0)

    # ... existing scan logic that builds `entries`, `scanned_count`,
    #     `scanned_through_id`, `archive_entry_count` ...
    # Note: scanned_through_id is the highest entry id touched on this page.

    returned_count = len(entries)
    done = scanned_through_id >= archive_entry_count - 1  # or however the existing
                                                          # implementation determines this
    next_cursor: Optional[str] = None
    if not done:
        next_cursor = Cursor.encode(
            tool="walk_namespace",
            state={"scan_at": scanned_through_id + 1, "l": limit},
        )

    payload: Dict[str, Any] = {
        "namespace": namespace,
        "results": entries,  # was 'entries'
        "next_cursor": next_cursor,
        "total": None,  # never knowable mid-scan
        "done": done,
        "page_info": {
            "offset": scan_at,
            "limit": limit,
            "returned_count": returned_count,
        },
        "scanned_count": scanned_count,
        "scanned_through_id": scanned_through_id,
        "archive_entry_count": archive_entry_count,
    }
    return cast("WalkNamespaceResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import WalkNamespaceResponse`.

- [ ] **Step 4: Update tool registration in `tools/navigation_tools.py:135`**

```python
async def walk_namespace(
    zim_file_path: str,
    namespace: str,
    limit: int = 200,
    cursor: Optional[str] = None,  # was cursor: int = 0
) -> Union["WalkNamespaceResponse", "ToolErrorPayload"]:
    try:
        cursor_state: Optional[Dict[str, Any]] = None
        if cursor is not None:
            from openzim_mcp.pagination import Cursor, CursorMismatchError
            try:
                decoded = Cursor.decode(cursor, expected_tool="walk_namespace")
            except CursorMismatchError as e:
                return tool_error(operation="walk namespace", message=str(e), context=f"cursor=<truncated>")
            except ValueError as e:
                return tool_error(operation="walk namespace", message=f"Invalid pagination cursor: {e}", context=None)
            cursor_state = decoded["s"]
            limit = cursor_state.get("l", limit)
        # ... existing rate-limit check + sanitize_input ...
        return await server.async_zim_operations.walk_namespace_data(
            zim_file_path, namespace, cursor_state=cursor_state, limit=limit
        )
    except Exception as e:
        logger.error(f"Error walking namespace: {e}")
        return tool_error(operation="walk namespace", message=str(e), context=f"namespace={namespace}")
```

The integer `cursor` parameter is GONE. Callers who used `cursor=42` get a clear error from FastMCP about type mismatch (`str` expected, `int` got) — that's the v2 clean break.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_walk_namespace.py tests/test_navigation_tools.py tests/test_structured_tool_output.py -v -k "walk"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/namespace.py openzim_mcp/tools/navigation_tools.py tests/test_walk_namespace.py tests/test_navigation_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: walk_namespace cursor becomes opaque, contract applied

BREAKING:
- cursor input/output type changes from int to opaque str
- entries renamed to results
- total is always null (walk doesn't know per-namespace total mid-scan)
- total_entries deprecated alias removed
- cursor t='walk_namespace' carries scan_at internally"
```

---

## Task 12: Migrate `list_namespaces` to TypedDict (no contract, special-case)

**Files:**
- Modify: `openzim_mcp/zim/namespace.py:66-115` (`list_namespaces_data`)
- Modify: `openzim_mcp/tools/metadata_tools.py:109-153` (tool registration)
- Modify: `tests/test_metadata_tools.py` and `tests/test_metadata_tools_extended.py`
- Modify: `tests/test_structured_tool_output.py:33-77` (the existing pilot test)

**Why:** `list_namespaces` returns a **dict** of namespace summaries, not a list. Doesn't get the `PaginatedResponse` contract — only gets the TypedDict migration so its payload lands at the top level (no `result` wrapper). Spec section [`list_namespaces`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#list_namespaces-is-not-a-list).

- [ ] **Step 1: Update tests**

In `test_metadata_tools.py`, `test_metadata_tools_extended.py`, and the existing pilot test in `test_structured_tool_output.py:33-77`:

- Find the inner namespace summary lookups: `result["namespaces"]["C"]["entry_count"]` → `result["namespaces"]["C"]["total"]`.
- Keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Update inner assertions to use the renamed `total` key for namespace summaries.
- Top-level keys preserved: `total_entries`, `sampled_entries`, `has_new_namespace_scheme`, `is_total_authoritative`, `discovery_method`, `namespaces`. No `results` / `next_cursor` / etc. (this tool doesn't get the contract).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py -v -k "list_namespaces"
```

Expected: failures.

- [ ] **Step 3: Update `list_namespaces_data` in namespace.py:66**

Inside the namespaces loop, rename `entry_count` → `total` per `NamespaceSummary` TypedDict:

```python
namespaces[ns_letter] = {
    "total": entry_count,         # was "entry_count": entry_count
    "is_authoritative": is_auth,  # was "is_authoritative": is_auth (already correct)
}
```

Update the function's return annotation to `"ListNamespacesResponse"`. Add `from openzim_mcp.tool_schemas import ListNamespacesResponse`.

- [ ] **Step 4: Update tool registration in `tools/metadata_tools.py:109`**

Update return annotation to `Union["ListNamespacesResponse", "ToolErrorPayload"]`.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py -v -k "list_namespaces or namespace"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/namespace.py openzim_mcp/tools/metadata_tools.py tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: list_namespaces TypedDict migration (no contract)

BREAKING: namespaces[<letter>].entry_count renamed to total. Payload
now lands at top level of structuredContent (no {'result': ...}
wrapper). list_namespaces is the one list-shaped result that doesn't
get the PaginatedResponse contract — it's a dict-of-summaries."
```

---

## Task 13: Migrate `extract_article_links` to require `kind` + apply contract

**Files:**
- Modify: `openzim_mcp/zim/structure.py:171-291` (`extract_article_links_data`)
- Modify: `openzim_mcp/tools/structure_tools.py:88-155` (tool registration)
- Modify: `tests/test_extract_article_links_pagination.py`
- Modify: `tests/test_structure_tools.py` (extract_article_links tests)
- Modify: `tests/test_structured_tool_output.py` (extract_article_links test)

**Why:** Today returns 3 parallel lists (internal/external/media) with 3 pagination flags. v2 requires `kind` (default `"internal"`); single category per call. `category_totals: {internal, external, media}` reports all three counts. Spec section [`extract_article_links`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#extract_article_links).

- [ ] **Step 1: Update tests**

This is the most surgical migration — the response shape changes substantially.

In `test_extract_article_links_pagination.py` and `test_structure_tools.py`:

- Replace assertions on `result["internal_links"]`/`result["external_links"]`/`result["media_links"]` with single-category logic: each test now passes a specific `kind=` and asserts on `result["results"]`.
- Replace `result["total_internal_links"]` etc. with `result["category_totals"]["internal"]`, `result["category_totals"]["external"]`, `result["category_totals"]["media"]`.
- Replace `result["pagination"]["has_more"]` with `not result["done"]`. Other `pagination` keys move to `page_info`.
- A test that previously asserted on all three categories at once becomes three separate test calls (one per `kind`), or one test that asserts on `category_totals`.

Add `kind="internal"` (or whichever category) to every call. Tests that previously called with no `kind` and got all three categories must be updated; the new behavior is "kind defaults to internal."

In `test_structured_tool_output.py`, find or add an `extract_article_links` test, and assert top-level shape:

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
assert "results" in payload
assert "kind" in payload and payload["kind"] == "internal"
assert "category_totals" in payload
for k in ("internal", "external", "media"):
    assert k in payload["category_totals"]
```

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_extract_article_links_pagination.py tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "links or extract_article"
```

Expected: failures.

- [ ] **Step 3: Update `extract_article_links_data` in structure.py:171**

Replace the function:

```python
from openzim_mcp.pagination import Cursor

def extract_article_links_data(
    self,
    zim_file_path: str,
    entry_path: str,
    limit: int = 100,
    offset: int = 0,
    kind: str = "internal",
) -> "LinksResponse":
    """Extract links of a single category. v2 Phase B contract."""
    if kind not in ("internal", "external", "media"):
        raise ValueError(f"kind must be one of 'internal', 'external', 'media'; got {kind!r}")

    # ... existing logic that walks the article and builds three lists:
    #     all_internal, all_external, all_media (ALL of them, no slicing yet) ...
    #     plus title, path, content_type ...

    by_kind = {"internal": all_internal, "external": all_external, "media": all_media}
    full_list = by_kind[kind]
    total_for_kind = len(full_list)
    page = full_list[offset : offset + limit]
    returned_count = len(page)
    last_index = offset + returned_count
    done = last_index >= total_for_kind
    next_cursor: Optional[str] = None
    if not done:
        next_cursor = Cursor.encode(
            tool="extract_article_links",
            state={"o": last_index, "l": limit, "ep": entry_path, "k": kind},
        )

    payload: Dict[str, Any] = {
        "title": title,
        "path": entry_path,
        "content_type": content_type,
        "kind": kind,
        "results": page,                      # was internal_links/external_links/media_links
        "next_cursor": next_cursor,
        "total": total_for_kind,
        "done": done,
        "page_info": {
            "offset": offset,
            "limit": limit,
            "returned_count": returned_count,
        },
        "category_totals": {
            "internal": len(all_internal),
            "external": len(all_external),
            "media": len(all_media),
        },
    }
    return cast("LinksResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import LinksResponse`.

- [ ] **Step 4: Update tool registration in `tools/structure_tools.py:88`**

```python
async def extract_article_links(
    zim_file_path: str,
    entry_path: str,
    limit: int = 100,
    offset: int = 0,
    kind: str = "internal",
    cursor: Optional[str] = None,
) -> Union["LinksResponse", "ToolErrorPayload"]:
    try:
        if cursor is not None:
            from openzim_mcp.pagination import Cursor, CursorMismatchError
            try:
                decoded = Cursor.decode(cursor, expected_tool="extract_article_links")
            except CursorMismatchError as e:
                return tool_error(operation="extract article links", message=str(e), context=f"cursor=<truncated>")
            except ValueError as e:
                return tool_error(operation="extract article links", message=f"Invalid pagination cursor: {e}", context=None)
            offset = decoded["s"]["o"]
            limit = decoded["s"].get("l", limit)
            entry_path = decoded["s"].get("ep", entry_path)
            kind = decoded["s"].get("k", kind)

        if kind not in ("internal", "external", "media"):
            return tool_error(
                operation="extract article links",
                message=f"kind must be one of 'internal', 'external', 'media'; got {kind!r}",
                context=f"Entry: {entry_path}",
            )
        # ... existing rate-limit + sanitize_input ...
        return await server.async_zim_operations.extract_article_links_data(
            zim_file_path, entry_path, limit=limit, offset=offset, kind=kind
        )
    except Exception as e:
        # ... existing error envelope ...
```

The previous `Optional[str]` `kind` becomes `str = "internal"` — required-with-default. Update the docstring.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_extract_article_links_pagination.py tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "links or extract_article"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/structure.py openzim_mcp/tools/structure_tools.py tests/test_extract_article_links_pagination.py tests/test_structure_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: extract_article_links requires kind, contract applied

BREAKING:
- kind required with default 'internal' (was Optional[str])
- internal_links/external_links/media_links arrays removed; single
  results array per call
- total_internal_links/total_external_links/total_media_links removed;
  category_totals: {internal, external, media} added
- pagination block flattened to top-level contract keys
- cursor t='extract_article_links'

To get all three categories, callers make three calls."
```

---

## Task 14: Migrate `get_table_of_contents` to TypedDict (non-list)

**Files:**
- Modify: `openzim_mcp/zim/structure.py:402-445` (`get_table_of_contents_data`)
- Modify: `openzim_mcp/tools/structure_tools.py:240-311` (tool registration)
- Modify: `tests/test_structure_tools.py` (toc tests)
- Modify: `tests/test_structured_tool_output.py` (toc test)

**Why:** Non-list tool. Just gets the TypedDict migration to land payload at top level. No contract keys; existing keys preserved.

- [ ] **Step 1: Update tests**

In each test file, keep the existing wrapper-tolerant pattern (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`):

```python
# FastMCP wraps Union returns in a uniform {"result": ...} envelope
# (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
payload = structured["result"] if "result" in structured else structured
```

Existing assertions on `title`, `path`, `toc`, `heading_count`, `max_depth` apply to `payload` (the inner dict).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "table_of_contents"
```

Expected: failures (until the `Union[TableOfContentsResponse, ToolErrorPayload]` annotation is wired and FastMCP emits the real anyOf schema).

- [ ] **Step 3: Update return annotations**

In `openzim_mcp/zim/structure.py:402`, change return annotation:

```python
def get_table_of_contents_data(
    self, zim_file_path: str, entry_path: str
) -> "TableOfContentsResponse":
```

Add `from openzim_mcp.tool_schemas import TableOfContentsResponse`.

In `openzim_mcp/tools/structure_tools.py:240`, change tool registration return annotation to `Union["TableOfContentsResponse", "ToolErrorPayload"]`.

The `_data` method's payload construction stays the same — TypedDict merely declares the schema; no field changes.

- [ ] **Step 4: Run tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "table_of_contents"
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/structure.py openzim_mcp/tools/structure_tools.py tests/test_structure_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: get_table_of_contents TypedDict migration

BREAKING (wire-format only): payload now at top level of
structuredContent (no {'result': ...} wrapper). Existing keys
preserved (title, path, toc, heading_count, max_depth)."
```

---

## Task 15: Migrate `get_article_structure` to TypedDict (non-list)

**Files:**
- Modify: `openzim_mcp/zim/structure.py:50-113` (`get_article_structure_data`)
- Modify: `openzim_mcp/tools/structure_tools.py:34-84` (tool registration)
- Modify: `tests/test_structure_tools.py`
- Modify: `tests/test_structured_tool_output.py` (article_structure test)

**Why:** Same pattern as Task 14. Non-list, TypedDict-only migration.

- [ ] **Step 1: Update tests**

Keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Existing keys preserved (`title`, `path`, `content_type`, `headings`, `sections`, `metadata`, `word_count`, `character_count`).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "article_structure"
```

Expected: failures.

- [ ] **Step 3: Update return annotations**

In `structure.py:50`:

```python
def get_article_structure_data(
    self, zim_file_path: str, entry_path: str
) -> "ArticleStructureResponse":
```

In `tools/structure_tools.py:34`: return annotation → `Union["ArticleStructureResponse", "ToolErrorPayload"]`.

Add the import.

- [ ] **Step 4: Run tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "article_structure"
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/structure.py openzim_mcp/tools/structure_tools.py tests/test_structure_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: get_article_structure TypedDict migration"
```

---

## Task 16: Migrate `get_binary_entry` to TypedDict (non-list)

**Files:**
- Modify: `openzim_mcp/zim/content.py:597-725` (`get_binary_entry_data`)
- Modify: `openzim_mcp/tools/structure_tools.py:316-403` (tool registration)
- Modify: `tests/test_structure_tools.py`
- Modify: `tests/test_structured_tool_output.py`

**Why:** Same pattern. Non-list, TypedDict-only.

- [ ] **Step 1: Update tests**

Keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Existing keys preserved (`path`, `title`, `mime_type`, `size`, `size_human`, `encoding`, `data`, `truncated`).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "binary"
```

Expected: failures.

- [ ] **Step 3: Update return annotations**

In `openzim_mcp/zim/content.py:597`:

```python
def get_binary_entry_data(
    self,
    zim_file_path: str,
    entry_path: str,
    max_size_bytes: Optional[int] = None,
    include_data: bool = True,
) -> "BinaryEntryResponse":
```

In `tools/structure_tools.py:316`: `Union["BinaryEntryResponse", "ToolErrorPayload"]`.

Add `from openzim_mcp.tool_schemas import BinaryEntryResponse`.

- [ ] **Step 4: Run tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "binary"
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/content.py openzim_mcp/tools/structure_tools.py tests/test_structure_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: get_binary_entry TypedDict migration"
```

---

## Task 17: Migrate `get_related_articles` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/tools/structure_tools.py:408-468` (and the `_data` method it calls)
- Modify: `tests/test_structure_tools.py`
- Modify: `tests/test_structured_tool_output.py`

**Why:** Renames `outbound_results` → `results`. Anticipates Phase E inbound-link feature where `direction` becomes a parameter; the `results` field then covers either side. Spec section [`get_related_articles`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#get_related_articles).

- [ ] **Step 1: Locate `_data` method**

```bash
grep -n "get_related_articles\|outbound_results" openzim_mcp/zim/*.py
```

Find the function that builds the response.

- [ ] **Step 2: Update tests**

Apply renames + contract:

- `result["outbound_results"]` → `result["results"]`
- Add: `assert result["next_cursor"] is None`, `assert result["done"] is True`, `assert result["total"] == len(result["results"])`, `assert result["page_info"]["limit"] == <limit>`.
- Replace `payload = structured["result"] if "result" in structured else structured` with the top-level form.

- [ ] **Step 3: Run failing tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "related"
```

Expected: failures.

- [ ] **Step 4: Update `_data` method**

Wherever the response is built, restructure to:

```python
payload: Dict[str, Any] = {
    "entry_path": entry_path,
    "results": outbound_hits,                 # was 'outbound_results'
    "next_cursor": None,
    "total": len(outbound_hits),
    "done": True,
    "page_info": {"offset": 0, "limit": limit, "returned_count": len(outbound_hits)},
}
return cast("RelatedArticlesResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import RelatedArticlesResponse`. Update the function's return annotation.

- [ ] **Step 5: Update tool registration**

`tools/structure_tools.py:408`: return annotation → `Union["RelatedArticlesResponse", "ToolErrorPayload"]`.

- [ ] **Step 6: Run tests**

```bash
uv run pytest tests/test_structure_tools.py tests/test_structured_tool_output.py -v -k "related"
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add openzim_mcp/zim/ openzim_mcp/tools/structure_tools.py tests/test_structure_tools.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: get_related_articles to PaginatedResponse + TypedDict

BREAKING: outbound_results renamed to results. Anticipates Phase E
inbound-link feature where direction becomes a parameter."
```

---

## Task 18: Migrate `get_zim_metadata` to TypedDict (non-list)

**Files:**
- Modify: `openzim_mcp/zim/archive.py:376-398` (`get_zim_metadata_data`)
- Modify: `openzim_mcp/tools/metadata_tools.py:25-72` (tool registration)
- Modify: `tests/test_metadata_tools.py` and `tests/test_metadata_tools_extended.py`
- Modify: `tests/test_structured_tool_output.py:78-98` (existing pilot test)

**Why:** TypedDict-only migration. Existing keys preserved.

- [ ] **Step 1: Update tests**

Keep the existing wrapper-tolerant pattern `payload = structured["result"] if "result" in structured else structured` (per the cross-cutting wire shape note — FastMCP wraps `Union` returns in `{"result": ...}`). Existing keys preserved (`entry_count`, `all_entry_count`, `article_count`, `media_count`, `metadata_entries`).

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py -v -k "zim_metadata or get_zim_metadata"
```

Expected: failures.

- [ ] **Step 3: Update return annotations**

In `archive.py:376`:

```python
def get_zim_metadata_data(self, zim_file_path: str) -> "ZimMetadataResponse":
```

In `tools/metadata_tools.py:25`: return annotation → `Union["ZimMetadataResponse", "ToolErrorPayload"]`.

Add `from openzim_mcp.tool_schemas import ZimMetadataResponse` in both files.

- [ ] **Step 4: Run tests**

```bash
uv run pytest tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py -v -k "zim_metadata"
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add openzim_mcp/zim/archive.py openzim_mcp/tools/metadata_tools.py tests/test_metadata_tools.py tests/test_metadata_tools_extended.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: get_zim_metadata TypedDict migration"
```

---

## Task 19: Migrate `get_zim_entry`, `get_main_page`, `get_entry_summary` to TypedDict

**Files:**
- Modify: `openzim_mcp/zim/content.py` (`get_zim_entry`, `get_main_page`, `get_entry_summary` `_data` methods)
- Modify: `openzim_mcp/tools/content_tools.py` (and structure_tools.py for entry_summary), `openzim_mcp/tools/metadata_tools.py:74` (`get_main_page`)
- Modify: `tests/test_content_tools.py` and similar

**Why:** Three related entry-fetch tools share the `EntryResponse` shape. Same TypedDict-only migration.

- [ ] **Step 1: Locate `_data` methods**

```bash
grep -n "get_zim_entry\|get_main_page\|get_entry_summary" openzim_mcp/zim/content.py openzim_mcp/tools/*.py | head -40
```

- [ ] **Step 2: Update tests for all three**

In any test that asserts on these tools' returns, swap the wrapper-tolerant payload extraction for top-level. Existing keys preserved (`path`, `title`, `content`).

- [ ] **Step 3: Run failing tests**

```bash
uv run pytest tests/ -v -k "get_zim_entry or get_main_page or get_entry_summary"
```

Expected: failures (until the `Union[<Response>, ToolErrorPayload]` annotations are wired and FastMCP emits the real anyOf schema).

- [ ] **Step 4: Update return annotations**

For each `_data` method (`get_zim_entry_data` → `EntryResponse`, `get_main_page_data` → `EntryResponse`, `get_entry_summary_data` → `EntrySummaryResponse`).

For each tool registration: return annotation `Union[<Response>, ToolErrorPayload]`.

Add the imports.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/ -v -k "get_zim_entry or get_main_page or get_entry_summary"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/content.py openzim_mcp/tools/content_tools.py openzim_mcp/tools/structure_tools.py openzim_mcp/tools/metadata_tools.py tests/
git commit -m "feat(v2)!: get_zim_entry/get_main_page/get_entry_summary TypedDict migration"
```

---

## Task 20: Migrate `get_zim_entries` (batch fetch) to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/tools/content_tools.py:99-...` (`get_zim_entries` tool)
- Modify: `openzim_mcp/zim/content.py` (`get_zim_entries_data` if separate)
- Modify: `tests/test_content_tools.py` (or similar)
- Modify: `tests/test_structured_tool_output.py:236-...` (existing test)

**Why:** Batch fetch is list-shaped — gets the full contract with `done=true, next_cursor=null`. Spec section ["Tools that don't paginate"](../specs/2026-05-08-v2-phase-b-response-contract-design.md#tools-that-dont-paginate).

- [ ] **Step 1: Update tests**

Apply renames + contract:

- Existing keys: depends on v1 shape. Find and inspect:
  ```bash
  grep -n "succeeded\|failed\|results" openzim_mcp/zim/content.py | head -20
  ```
- The result list (whatever it's called today) → `results`.
- Contract: `next_cursor: None`, `total: len(results)`, `done: True`, `page_info: {offset: 0, limit: <as_requested>, returned_count: len(results)}`.

In `test_structured_tool_output.py`, change the wrapper-tolerant extraction to top-level.

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/ -v -k "get_zim_entries or batch"
```

Expected: failures.

- [ ] **Step 3: Update `get_zim_entries_data`**

Restructure response with the contract keys + per-entry list as `results`. Tool-specific extras (`succeeded`, `failed`) preserved.

- [ ] **Step 4: Update tool registration**

`Union["BatchEntryResponse", "ToolErrorPayload"]`. Add the import.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/ -v -k "get_zim_entries or batch"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/content.py openzim_mcp/tools/content_tools.py tests/
git commit -m "feat(v2)!: get_zim_entries batch fetch to PaginatedResponse + TypedDict"
```

---

## Task 21: Migrate `list_zim_files` to the contract + TypedDict

**Files:**
- Modify: `openzim_mcp/zim/archive.py:323-346` (`list_zim_files_summary_data` or similar)
- Modify: `openzim_mcp/tools/file_tools.py:26-66` (tool registration)
- Modify: `tests/test_file_tools.py` and `tests/test_file_tools_extended.py`
- Modify: `tests/test_structured_tool_output.py:101-117`

**Why:** Renames `files` → `results`, `count` → `total`. Spec section [`list_zim_files`](../specs/2026-05-08-v2-phase-b-response-contract-design.md#list_zim_files).

- [ ] **Step 1: Update tests**

Apply renames + contract assertions. `result["files"]` → `result["results"]`, `result["count"]` → `result["total"]`. Add `next_cursor: None`, `done: True`, `page_info`.

In `test_structured_tool_output.py:101-117`, switch to top-level payload.

- [ ] **Step 2: Run failing tests**

```bash
uv run pytest tests/test_file_tools.py tests/test_file_tools_extended.py tests/test_structured_tool_output.py -v -k "list_zim_files"
```

Expected: failures.

- [ ] **Step 3: Update `list_zim_files_summary_data` in archive.py:323**

```python
def list_zim_files_summary_data(
    self, name_filter: Optional[str] = None
) -> "ListZimFilesResponse":
    # ... existing logic that builds files_list, directories_count ...

    payload: Dict[str, Any] = {
        "name_filter": name_filter,
        "directories_count": directories_count,
        "results": files_list,                     # was 'files'
        "next_cursor": None,
        "total": len(files_list),                  # was 'count'
        "done": True,
        "page_info": {
            "offset": 0,
            "limit": len(files_list),
            "returned_count": len(files_list),
        },
    }
    return cast("ListZimFilesResponse", attach_meta(payload))
```

Add `from openzim_mcp.tool_schemas import ListZimFilesResponse`.

- [ ] **Step 4: Update tool registration in `file_tools.py:26`**

`Union["ListZimFilesResponse", "ToolErrorPayload"]`. Add the import.

- [ ] **Step 5: Run tests**

```bash
uv run pytest tests/test_file_tools.py tests/test_file_tools_extended.py tests/test_structured_tool_output.py -v -k "list_zim_files"
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add openzim_mcp/zim/archive.py openzim_mcp/tools/file_tools.py tests/test_file_tools.py tests/test_file_tools_extended.py tests/test_structured_tool_output.py
git commit -m "feat(v2)!: list_zim_files to PaginatedResponse + TypedDict

BREAKING: files renamed to results, count renamed to total."
```

---

## Task 22: Remove the `PaginationCursor` compat shim

**Files:**
- Modify: `openzim_mcp/zim/archive.py:79-...` (delete `PaginationCursor` class)
- Modify: `openzim_mcp/zim/archive.py:58-68` (remove from `__all__`)
- Modify: any remaining callers (should be zero by now)

**Why:** Per Task 3's commit message, the shim was added to keep v1 callers working during the migration. With Tasks 5–21 complete, every caller should now be using `Cursor` directly.

- [ ] **Step 1: Find any remaining `PaginationCursor` callers**

```bash
grep -rn "PaginationCursor" openzim_mcp/ tests/
```

If any production-code references remain (other than `openzim_mcp/zim/archive.py` itself), MIGRATE THEM to `from openzim_mcp.pagination import Cursor` first. Test references are okay if they decode legacy cursors — but those should also have been updated in earlier tasks.

If grep returns ONLY the definition site in `archive.py` and possibly `__all__`, proceed.

- [ ] **Step 2: Remove the class and the `__all__` entry**

Delete the `PaginationCursor` class body in `archive.py` (the lines you wrote in Task 3). Remove `"PaginationCursor",` from the `__all__` list near the top of the file.

- [ ] **Step 3: Run the full test suite**

```bash
uv run pytest tests/ -v
```

Expected: PASS. If any test imports `PaginationCursor` and breaks, update the import to `from openzim_mcp.pagination import Cursor` and adjust the call site to use the tool-bound API.

- [ ] **Step 4: Commit**

```bash
git add openzim_mcp/zim/archive.py
git commit -m "refactor(v2): remove PaginationCursor compat shim

All callers now use openzim_mcp.pagination.Cursor directly with
explicit tool binding. The shim from Task 3 is no longer needed."
```

---

## Task 23: Update `simple_tools.py` footer / pagination read paths

**Files:**
- Modify: `openzim_mcp/simple_tools.py`
- Modify: `tests/test_simple_tools.py`

**Why:** The compact-mode footer in simple-mode (`zim_query`) reads `_meta` and previously may have read `pagination.next_cursor`. With the contract, those reads become top-level.

- [ ] **Step 1: Find affected reads**

```bash
grep -n 'pagination\["\|pagination.get\|"pagination"' openzim_mcp/simple_tools.py
grep -n '\["next_cursor"\]\|\["has_more"\]\|"pagination"' openzim_mcp/simple_tools.py
```

For each match, replace `payload["pagination"]["next_cursor"]` with `payload.get("next_cursor")`, `payload["pagination"]["has_more"]` with `not payload.get("done", True)`, etc.

If the simple-mode wrapper repacks search results into a different shape, ensure it sources the new keys: `total` (was `total_results`), `page_info.offset`, `page_info.limit`, etc.

- [ ] **Step 2: Update tests**

In `tests/test_simple_tools.py`, find any assertions that exercised the old shape via the simple-mode response. Update to new shape. Footer-content assertions should still work (the footer is rendered from `_meta`, which is unchanged).

- [ ] **Step 3: Run tests**

```bash
uv run pytest tests/test_simple_tools.py -v
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add openzim_mcp/simple_tools.py tests/test_simple_tools.py
git commit -m "feat(v2): simple_tools reads new top-level pagination keys

No external behavior change for simple-mode callers — the compact
footer is rendered from _meta (unchanged). This task just fixes the
internal reads after the structured-response migration in Tasks 5-21."
```

---

## Task 24: Add `tests/test_response_contract.py` — golden contract regression

**Files:**
- Create: `tests/test_response_contract.py`

**Why:** A single test file that asserts every list-returning tool conforms to the five-key contract. Catches future drift (e.g., someone adds a new tool that forgets `done`).

- [ ] **Step 1: Create the test file**

```python
"""Contract-shape regression: every list-returning tool emits the five contract keys.

Phase B (#3) standardizes pagination across all list-returning tools. This
file is the single source of truth for "did we keep the contract?" Each
test calls a real tool against the bundled fixtures and asserts the shape.
"""

from __future__ import annotations

import pytest

from openzim_mcp.config import OpenZimMcpConfig
from openzim_mcp.server import OpenZimMcpServer


CONTRACT_KEYS = ("results", "next_cursor", "total", "done", "page_info", "_meta")
PAGE_INFO_KEYS = ("offset", "limit", "returned_count")


def _assert_contract(payload: dict) -> None:
    """Assert payload carries the five contract keys + _meta + page_info subkeys."""
    for key in CONTRACT_KEYS:
        assert key in payload, f"contract key missing: {key}"
    assert isinstance(payload["results"], list), f"results must be list, got {type(payload['results'])}"
    assert isinstance(payload["done"], bool)
    assert payload["next_cursor"] is None or isinstance(payload["next_cursor"], str)
    assert payload["total"] is None or isinstance(payload["total"], int)
    pi = payload["page_info"]
    for key in PAGE_INFO_KEYS:
        assert key in pi, f"page_info missing key: {key}"
    # done <-> next_cursor co-vary (redundant by design)
    if payload["done"]:
        assert payload["next_cursor"] is None, "done=True must imply next_cursor=None"
    else:
        assert payload["next_cursor"] is not None, "done=False must imply next_cursor!=None"


@pytest.fixture
def server(test_config_with_zim_data: OpenZimMcpConfig) -> OpenZimMcpServer:
    return OpenZimMcpServer(test_config_with_zim_data)


class TestContractShape:
    @pytest.mark.asyncio
    async def test_search_zim_file_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "search_zim_file",
            {"zim_file_path": str(zim_path), "query": "evolution", "limit": 5},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)

    @pytest.mark.asyncio
    async def test_search_all_contract_top_level(self, server):
        result = await server.mcp._tool_manager.call_tool(
            "search_all", {"query": "evolution"}, convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        # search_all top-level is always done=True
        assert payload["done"] is True
        # And each per-archive result inside also conforms.
        for entry in payload["results"]:
            inner = entry["result"]
            _assert_contract(inner)

    @pytest.mark.asyncio
    async def test_find_entry_by_title_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "find_entry_by_title",
            {"zim_file_path": str(zim_path), "title": "anything"},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["done"] is True  # non-paginated tool

    @pytest.mark.asyncio
    async def test_get_search_suggestions_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "get_search_suggestions",
            {"zim_file_path": str(zim_path), "partial_query": "ev"},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["done"] is True

    @pytest.mark.asyncio
    async def test_browse_namespace_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "browse_namespace",
            {"zim_file_path": str(zim_path), "namespace": "C", "limit": 5},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)

    @pytest.mark.asyncio
    async def test_walk_namespace_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "walk_namespace",
            {"zim_file_path": str(zim_path), "namespace": "C", "limit": 50},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["total"] is None  # walk doesn't know total

    @pytest.mark.asyncio
    async def test_extract_article_links_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        # Find any article and extract links from it.
        find = await server.mcp._tool_manager.call_tool(
            "find_entry_by_title", {"zim_file_path": str(zim_path), "title": "Berlin"},
            convert_result=True,
        )
        _, find_payload = find
        if not find_payload["results"]:
            pytest.skip("No fixture article to test against")
        entry_path = find_payload["results"][0]["path"]
        result = await server.mcp._tool_manager.call_tool(
            "extract_article_links",
            {"zim_file_path": str(zim_path), "entry_path": entry_path, "kind": "internal"},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert "category_totals" in payload
        for k in ("internal", "external", "media"):
            assert k in payload["category_totals"]

    @pytest.mark.asyncio
    async def test_list_zim_files_contract(self, server):
        result = await server.mcp._tool_manager.call_tool(
            "list_zim_files", {}, convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["done"] is True

    @pytest.mark.asyncio
    async def test_get_related_articles_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        find = await server.mcp._tool_manager.call_tool(
            "find_entry_by_title", {"zim_file_path": str(zim_path), "title": "Berlin"},
            convert_result=True,
        )
        _, find_payload = find
        if not find_payload["results"]:
            pytest.skip("No fixture article to test against")
        entry_path = find_payload["results"][0]["path"]
        result = await server.mcp._tool_manager.call_tool(
            "get_related_articles",
            {"zim_file_path": str(zim_path), "entry_path": entry_path},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["done"] is True

    @pytest.mark.asyncio
    async def test_get_zim_entries_contract(self, server, basic_test_zim_files):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "get_zim_entries",
            {"zim_file_path": str(zim_path), "entry_paths": ["C/Berlin", "C/Paris"]},
            convert_result=True,
        )
        _, payload = result
        _assert_contract(payload)
        assert payload["done"] is True
```

- [ ] **Step 2: Run the new test file**

```bash
uv run pytest tests/test_response_contract.py -v
```

Expected: every test PASS. If any fail, the failure indicates the corresponding tool migration (Tasks 5-21) is incomplete or drifted from the contract — go fix the tool, not the test.

- [ ] **Step 3: Commit**

```bash
git add tests/test_response_contract.py
git commit -m "test(v2): add response-contract regression test

Single source of truth for 'every list-returning tool conforms to the
five-key contract'. Catches future drift if a new tool ships without
the contract keys."
```

---

## Task 25: Extend `tests/test_structured_tool_output.py` to cover every migrated tool with a wrapper-tolerant payload assertion

**Files:**
- Modify: `tests/test_structured_tool_output.py`

**Why:** During Tasks 5-21 each migration touched this file with one-off changes. This task ensures every dict-returning tool has a single, consistent assertion shape: unwrap the FastMCP `{"result": ...}` envelope and assert the inner TypedDict contract (per the cross-cutting wire shape note above — FastMCP wraps `Union` returns uniformly). Backstops the per-tool migration tasks.

- [ ] **Step 1: List every dict-returning tool**

Tools: `search_zim_file`, `search_all`, `search_with_filters`, `find_entry_by_title`, `get_search_suggestions`, `browse_namespace`, `walk_namespace`, `list_namespaces`, `extract_article_links`, `get_table_of_contents`, `get_article_structure`, `get_binary_entry`, `get_related_articles`, `list_zim_files`, `get_zim_metadata`, `get_zim_entry`, `get_zim_entries`, `get_main_page`, `get_entry_summary`.

- [ ] **Step 2: Audit the file**

```bash
grep -n "def test_.*_returns_structured_content" tests/test_structured_tool_output.py
```

For each tool in the list above that doesn't have a test in this file, ADD one following the existing pattern:

```python
@pytest.mark.asyncio
async def test_<tool>_returns_structured_content(
    self, server: OpenZimMcpServer, basic_test_zim_files
) -> None:
    """<tool> emits a real dict at structuredContent.result (FastMCP Union wrap)."""
    zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
    if zim_path is None:
        pytest.skip("ZIM testing-suite small.zim not available")
    result = await server.mcp._tool_manager.call_tool(
        "<tool>",
        {"zim_file_path": str(zim_path), <other_required_args>},
        convert_result=True,
    )
    assert isinstance(result, tuple)
    _, structured = result
    assert isinstance(structured, dict)
    # FastMCP wraps Union returns in a uniform {"result": ...} envelope
    # (see spec § FastMCP wrapping). Accept the wrapper; assert inner shape.
    payload = structured["result"] if "result" in structured else structured
    # Tool-specific keys (one or two characteristic ones):
    assert "<key1>" in payload
    assert "<key2>" in payload
```

Every existing test should already use this wrapper-tolerant pattern. If you find a test using the strict pattern (`assert "result" not in structured`), loosen it now to the tolerant form above — the strict form is incompatible with the chosen `Union[<SuccessResponse>, ToolErrorPayload]` annotation per the cross-cutting wire shape note.

- [ ] **Step 3: Run the file**

```bash
uv run pytest tests/test_structured_tool_output.py -v
```

Expected: every test PASS. The wrapper-tolerant pattern handles both pre-migration tools (no wrapper at all if anything ever returns single-TypedDict) and the standard `Union`-wrapped case.

- [ ] **Step 4: Commit**

```bash
git add tests/test_structured_tool_output.py
git commit -m "test(v2): extend structured-output regression to every migrated tool

Each test now unwraps the FastMCP {result} envelope and asserts the
inner TypedDict shape (per spec § FastMCP wrapping), backstopping the
per-tool TypedDict migrations from Tasks 5-21."
```

---

## Task 26: Bump cache keys in cache-using `_data` methods that weren't covered by individual tasks

**Files:**
- Audit: every `_data` method in `openzim_mcp/zim/`

**Why:** Phase A bumped some cache keys when shapes changed. Phase B's shape changes mean any cached v1.x response shape would be returned to a v2 caller — wrong. We need to bump every cache key that wraps a list-returning response.

- [ ] **Step 1: Find every cache key in zim/ modules**

```bash
grep -n "cache_key\|self\.cache\.get\|self\.cache\.set" openzim_mcp/zim/*.py
```

- [ ] **Step 2: For each cache key, check if it was updated in earlier tasks**

Task 5 already bumped `search_data:` → `search_v2b:`. Other cache keys (e.g., for `browse_namespace`, `extract_article_links`, `list_zim_files`) may not have been bumped per-task — those need updating now.

For every cache_key string used in a `_data` method that produces a Phase-B-changed shape, append `:v2b` (or replace the prefix with `<name>_v2b:`). Examples:

- `f"browse_ns:{path}:{ns}:{limit}:{offset}"` → `f"browse_ns_v2b:{path}:{ns}:{limit}:{offset}"`
- `f"links:{path}:{entry}:{limit}:{offset}:{kind}"` → `f"links_v2b:{path}:{entry}:{limit}:{offset}:{kind}"`

If a cache wraps a non-changed response (e.g., entry-content cache where only the TypedDict annotation changed but the dict shape didn't), no bump needed.

- [ ] **Step 3: Run the full test suite**

```bash
uv run pytest tests/ -v
```

Expected: PASS. (Cache-key bumps don't break tests; they just avoid returning stale-shape responses in production.)

- [ ] **Step 4: Commit**

```bash
git add openzim_mcp/zim/
git commit -m "chore(v2): bump cache keys for Phase B response shapes

Every cache key wrapping a list-returning response gets a v2b suffix
so v1.x cached responses (old shape) don't leak through after upgrade."
```

---

## Task 27: Capture Phase B golden file regression

**Files:**
- Create: `tests/test_golden_v2_phase_b.py` (or extend the existing Phase A pattern)
- Generate: golden snapshot data files (likely under `tests/data/` or as inline text)

**Why:** The Phase A goldens were captured at v1.2.0 shape and will fail after Phase B. This task captures the new shape as the v2 anchor. Future regressions on the contract will diff against these snapshots.

- [ ] **Step 1: Inspect the Phase A golden test**

```bash
ls tests/test_golden* tests/data/*.golden* 2>/dev/null
cat tests/test_golden_v2_phase_a.py | head -80 2>/dev/null || true
```

If the Phase A golden test exists, follow its pattern. If not, create a minimal one.

- [ ] **Step 2: Create `tests/test_golden_v2_phase_b.py`**

Pattern: for ~5 fixture archives × ~5 representative tool calls (search, browse, walk, links, find_entry), serialize the response to JSON and snapshot. On first run, the test writes goldens to disk and skips. On subsequent runs, it diffs.

Implementation depends on whether the project uses `pytest-snapshot`, `syrupy`, or hand-rolled. Inspect Phase A's pattern; mirror it.

If hand-rolled:

```python
"""Phase B golden snapshots: response shape after the response-contract migration.

The Phase A goldens captured the v1.2.0 shape; Phase B's wire-format break means
those goldens stop matching after this phase by design. These v2 goldens are
the new anchor. Future contract drift is caught by diffing against these.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from openzim_mcp.config import OpenZimMcpConfig
from openzim_mcp.server import OpenZimMcpServer

GOLDENS_DIR = Path(__file__).parent / "data" / "goldens" / "v2_phase_b"


def _capture_or_compare(name: str, payload: dict, *, capture_mode: bool) -> None:
    """Write or diff a golden snapshot for ``name``."""
    GOLDENS_DIR.mkdir(parents=True, exist_ok=True)
    path = GOLDENS_DIR / f"{name}.json"
    if capture_mode or not path.exists():
        path.write_text(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
        return
    expected = json.loads(path.read_text())
    # Stripping volatile fields like _meta.tokens_est (which depend on snippet text)
    # keeps the diff focused on the contract shape.
    def _strip_volatile(d):
        if isinstance(d, dict):
            return {k: _strip_volatile(v) for k, v in d.items() if k not in ("tokens_est", "chars")}
        if isinstance(d, list):
            return [_strip_volatile(x) for x in d]
        return d
    assert _strip_volatile(payload) == _strip_volatile(expected), (
        f"Golden mismatch for {name}. Set OPENZIM_MCP_CAPTURE_GOLDENS=1 to refresh."
    )


@pytest.fixture
def server(test_config_with_zim_data: OpenZimMcpConfig) -> OpenZimMcpServer:
    return OpenZimMcpServer(test_config_with_zim_data)


@pytest.fixture
def capture_mode(monkeypatch) -> bool:
    import os
    return os.environ.get("OPENZIM_MCP_CAPTURE_GOLDENS") == "1"


class TestGoldensV2PhaseB:
    @pytest.mark.asyncio
    async def test_search_zim_file_golden(self, server, basic_test_zim_files, capture_mode):
        zim_path = basic_test_zim_files.get("nons") or basic_test_zim_files.get("withns")
        if zim_path is None:
            pytest.skip("ZIM testing-suite small.zim not available")
        result = await server.mcp._tool_manager.call_tool(
            "search_zim_file",
            {"zim_file_path": str(zim_path), "query": "evolution", "limit": 5},
            convert_result=True,
        )
        _, payload = result
        _capture_or_compare("search_zim_file_evolution_5", payload, capture_mode=capture_mode)

    # Repeat for browse_namespace, walk_namespace, extract_article_links,
    # find_entry_by_title with consistent fixture inputs.
```

Add similar test methods for the other tools. Aim for 5–8 snapshots total.

- [ ] **Step 3: Generate the goldens**

```bash
OPENZIM_MCP_CAPTURE_GOLDENS=1 uv run pytest tests/test_golden_v2_phase_b.py -v
```

Expected: every test PASS (the capture path is a no-op assertion). Verify the JSON files were written:

```bash
ls -la tests/data/goldens/v2_phase_b/
```

- [ ] **Step 4: Run again without capture mode to verify diffs**

```bash
uv run pytest tests/test_golden_v2_phase_b.py -v
```

Expected: PASS. The snapshots match themselves.

- [ ] **Step 5: Commit**

```bash
git add tests/test_golden_v2_phase_b.py tests/data/goldens/v2_phase_b/
git commit -m "test(v2): capture Phase B response-contract goldens

5-8 snapshots covering the new wire format across the major tools.
Anchors the v2 contract against future drift. Set
OPENZIM_MCP_CAPTURE_GOLDENS=1 to refresh after intentional changes."
```

---

## Task 28: Update `README.md` with Phase B contract documentation

**Files:**
- Modify: `README.md`

**Why:** The README is the user-facing contract. After Phase B, every list-returning tool's response shape needs documenting once (not per-tool — the common contract is the point).

- [ ] **Step 1: Find the existing tool-response section**

```bash
grep -n "^##\|^###" README.md | head -40
```

Identify where tool responses, pagination, or `_meta` are currently documented.

- [ ] **Step 2: Add a "Response contract" section**

Insert a new section (under or near the Phase A `_meta` section). Content:

```markdown
## Response contract (v2)

Every list-returning tool returns the same five contract keys:

| Key | Type | Meaning |
|---|---|---|
| `results` | `list[T]` | The page of items. Empty list on zero hits. |
| `next_cursor` | `str | null` | Opaque base64-JSON cursor. Pass back as `cursor=` to fetch the next page. `null` on the last page. |
| `total` | `int | null` | Total count across all pages. `null` when not knowable mid-scan (e.g., `walk_namespace`). |
| `done` | `bool` | `true` when no more pages exist. Always co-varies with `next_cursor`: `done=true` ⟺ `next_cursor=null`. |
| `page_info` | `{offset, limit, returned_count, total_is_lower_bound?}` | Pagination state for this page. |

Plus the Phase A `_meta` envelope as a sibling.

### Pagination input

Every paged tool accepts `cursor` (preferred) or `offset` (convenience). If
both are supplied, `cursor` wins.

### Cursor format

Cursors are URL-safe base64-encoded JSON of `{v: 1, t: <tool_name>, s: <state>}`.
The cursor is **tool-bound** — passing one tool's cursor to another raises a
clear error. Cursors are **opaque** — clients should not interpret them.

### Tools without natural pagination

Tools like `find_entry_by_title`, `get_search_suggestions`, and `list_zim_files`
return all matches in one call. They still emit the contract: `done=true`,
`next_cursor=null`, `total=len(results)`.

### `extract_article_links` requires `kind`

`extract_article_links` returns one category per call (`kind=internal` by
default). Use `kind=external` or `kind=media` for the other categories.
Per-category counts surface in `category_totals: {internal, external, media}`.
```

- [ ] **Step 3: Note the wire-format break in the existing v2 section**

If the README has a "v2 changes" or "Migration from v1.x" section, add a bullet for the response-contract break. Otherwise, add one sentence near the section above:

> v2 is a wire-format break from v1.x — see CHANGELOG for the per-tool key renames.

- [ ] **Step 4: Run a quick lint pass**

```bash
test -f README.md && wc -l README.md
```

Expected: file exists, line count reasonable.

- [ ] **Step 5: Commit**

```bash
git add README.md
git commit -m "docs(v2): document Phase B response contract in README

Single section covering the five contract keys, cursor format, and
the extract_article_links kind requirement. The per-tool docstrings
in the source already cover their tool-specific extras; this is the
shared contract reference."
```

---

## Task 29: Add `## [2.0.0a2]` entry to `CHANGELOG.md`

**Files:**
- Modify: `CHANGELOG.md`

**Why:** Standard release-please workflow uses CHANGELOG entries to gate the next tag. This entry documents Phase B's breaking changes for users who upgrade.

- [ ] **Step 1: Inspect the existing changelog format**

```bash
head -60 CHANGELOG.md
```

Match the existing style (release-please format usually).

- [ ] **Step 2: Add the entry**

Insert near the top, under the `## [Unreleased]` section if one exists, or above the prior version:

```markdown
## [2.0.0a2] — 2026-05-08 (alpha pre-release)

v2 Phase B: response-contract migration. **Wire-format break** for every
list-returning tool. v1.x users upgrading must update response-shape parsing.
See [docs/superpowers/specs/2026-05-08-v2-phase-b-response-contract-design.md](docs/superpowers/specs/2026-05-08-v2-phase-b-response-contract-design.md)
for the full design.

### Breaking — pagination contract

Every list-returning tool now returns the same five contract keys:
`results`, `next_cursor`, `total`, `done`, `page_info`.

| Tool | What changed |
|---|---|
| `search_zim_file` | `total_results` → `total`; `pagination` block flattened; `next_cursor` at top level; `cursor` accepted as input |
| `search_all` | `per_file` → `results`; per-archive blocks each carry the new contract via `result` field; cursor lives only at the per-archive level |
| `search_with_filters` | now returns structured dict (was markdown); same shape as `search_zim_file` plus `namespace_filter`/`content_type_filter` |
| `find_entry_by_title` | gets the contract; `done=true`, `total=len(results)` always |
| `get_search_suggestions` | `suggestions` → `results`; `count` removed |
| `browse_namespace` | `entries` → `results`; `total_in_namespace` → `total`; `total_in_namespace_is_lower_bound` → `page_info.total_is_lower_bound`; `has_more` removed |
| `walk_namespace` | `cursor` input/output type changes from `int` to opaque `str`; `entries` → `results`; `total` is always `null`; `total_entries` deprecated alias removed |
| `list_namespaces` | `namespaces[<letter>].entry_count` → `total`; payload at top level (no `result` wrapper) |
| `extract_article_links` | **`kind` is now required (default `"internal"`)**; single category per call; `internal_links`/`external_links`/`media_links` parallel arrays removed; `category_totals: {internal, external, media}` added |
| `list_zim_files` | `files` → `results`; `count` → `total` |
| `get_related_articles` | `outbound_results` → `results`; anticipates Phase E inbound-link feature |
| `get_zim_entries` (batch) | gets the contract; `done=true`, `total=len(results)` |

### Breaking — TypedDict everywhere

Every dict-returning tool migrated from `Dict[str, Any]` to a per-tool
TypedDict. FastMCP now emits payloads at the top level of `structuredContent`
with real schemas. The previous `{"result": ...}` outer wrapper is gone.

Migrated tools (TypedDict-only, no contract): `get_zim_metadata`,
`get_zim_entry`, `get_main_page`, `get_entry_summary`,
`get_table_of_contents`, `get_article_structure`, `get_binary_entry`.

### Cursor format

Cursors are URL-safe base64 JSON: `{v: 1, t: <tool_name>, s: <state>}`.
**Tool-bound** — a search cursor passed to browse raises a clear error.
**Versioned** — adding new fields later doesn't break the wire format.

### Compat shim removed

`openzim_mcp.zim.archive.PaginationCursor` (the v1 cursor class) is removed.
Use `openzim_mcp.pagination.Cursor` instead.

### Other

- New module: `openzim_mcp/pagination.py` — `Cursor.encode/decode`, `CursorMismatchError`.
- New module: `openzim_mcp/tool_schemas.py` — every per-tool response TypedDict.
- New tests: `tests/test_pagination_cursor.py`, `tests/test_response_contract.py`, `tests/test_golden_v2_phase_b.py`.
- The Phase A `_meta` envelope is unchanged in shape and still populates on every response.
```

- [ ] **Step 3: Commit**

```bash
git add CHANGELOG.md
git commit -m "chore(v2): CHANGELOG entry for v2.0.0a2 (Phase B)"
```

---

## Task 30: Final verification — full test suite, lint, type check

**Files:** none (verification only)

- [ ] **Step 1: Full test suite**

```bash
uv run pytest tests/ -v
```

Expected: every test PASS. If anything fails, fix before proceeding.

- [ ] **Step 2: Lint**

```bash
uv run ruff check .
```

Expected: no errors. Fix any reported issues.

- [ ] **Step 3: Type check**

```bash
uv run mypy openzim_mcp/
```

Expected: no errors. Common Phase B-introduced issues:
- TypedDict variance: if a `_data` method's return type is annotated as `TypedDict X` but the constructed dict is `Dict[str, Any]`, `cast()` it.
- Union return annotations may need `cast()` at error-paths.

- [ ] **Step 4: Smoke-run the server (optional but recommended)**

```bash
uv run python -m openzim_mcp.server --help 2>&1 | head -10
```

Expected: help text. The CLI starts without error.

- [ ] **Step 5: Verify branch state**

```bash
git status --porcelain
git log --oneline main..v2-phase-b
```

Expected: empty `git status` output; `git log` shows ~25-30 commits since main branched.

No commit on this task — verification only. If everything is green, proceed to Task 31.

---

## Task 31: Open the v2-phase-b → main PR

**Files:** none (git/PR operations)

- [ ] **Step 1: Push the branch**

```bash
git push -u origin v2-phase-b
```

Expected: branch pushed to origin.

- [ ] **Step 2: Open the PR**

Use the GitHub CLI:

```bash
gh pr create --base main --head v2-phase-b \
  --title "feat(v2)!: Phase B — response contract & TypedDict migration" \
  --label v2 --label v2-phase-b \
  --body "$(cat <<'EOF'
## Phase B of the v2 effort — response contract migration

**Tag target:** v2.0.0a2 (alpha pre-release)
**Spec:** docs/superpowers/specs/2026-05-08-v2-phase-b-response-contract-design.md

### What's in scope (#3 + #13)

- **#3 Standard pagination contract.** Every list-returning tool returns the same five contract keys (`results`, `next_cursor`, `total`, `done`, `page_info`) plus `_meta`.
- **#13 TypedDict everywhere.** Every dict-returning tool migrated from `Dict[str, Any]` to a per-tool TypedDict. FastMCP emits payloads at the top level of `structuredContent` with real schemas (no more `{"result": ...}` wrapper).

### Wire-format breaks

This is a clean break from v1.x — no deprecation period (v2 policy). See CHANGELOG for the per-tool key renames table.

Most surgical changes:
- `extract_article_links` requires `kind` (default `"internal"`); single category per call.
- `walk_namespace` cursor type changes from `int` to opaque `str`.
- `search_with_filters` now returns structured dict (was markdown).

### Test surface

- New: `tests/test_pagination_cursor.py`, `tests/test_response_contract.py`, `tests/test_golden_v2_phase_b.py`, `tests/test_tool_schemas.py`.
- Extended: `tests/test_structured_tool_output.py` covers every migrated tool with top-level-payload assertions.
- Updated: every per-tool test file reflects the new shape.

### Release plan

- Merge this PR into `v2-phase-b` (branch already at HEAD).
- Tag `v2.0.0a2` after merge.
- v2 final tag waits for Phases C–F.
EOF
)"
```

- [ ] **Step 3: Confirm the PR opened**

```bash
gh pr list --head v2-phase-b
```

Expected: one open PR.

The PR review and merge are the human in the loop — this plan stops here.

- [ ] **Step 4: Stop**

When the PR opens cleanly, the implementation plan is complete. The user will review, merge, and tag `v2.0.0a2`.

---

## Self-Review

Spec coverage check:

- ✅ Phase B item #3 (pagination contract) — Tasks 5-13, 17, 20, 21 cover every paginated tool.
- ✅ Phase B item #13 (TypedDict migration) — Tasks 5-21 each migrate one tool's return type.
- ✅ Foundation (`pagination.py`, `tool_schemas.py`) — Tasks 2 and 4.
- ✅ Cursor design (versioned, tool-bound) — Task 2 implements; Task 5 first uses; Tasks 7, 10-13, 17 follow same pattern.
- ✅ `done` + `next_cursor` redundant by design — Task 24's contract regression asserts the co-variance.
- ✅ Offset-as-input retained — every paged tool's registration in Tasks 5-21 keeps `offset` and adds `cursor`.
- ✅ Error-as-data union return — every tool's annotation is `Union[<Success>, ToolErrorPayload]`.
- ✅ `extract_article_links` requires `kind` — Task 13.
- ✅ `walk_namespace` opaque cursor — Task 11.
- ✅ `search_all` two-level contract — Task 6.
- ✅ `list_namespaces` non-list special case — Task 12.
- ✅ Removed wire-format keys per spec table — covered across per-tool tasks.
- ✅ Phase A `_meta` envelope preserved unchanged — every per-tool task keeps `attach_meta()` calls intact.
- ✅ Cache key bumps — Task 5 (search) and Task 26 (audit pass).
- ✅ Phase B goldens — Task 27.
- ✅ README update — Task 28.
- ✅ CHANGELOG entry — Task 29.
- ✅ Single bundled PR off `v2-phase-b` — Task 1 creates branch; Task 31 opens PR; tag `v2.0.0a2` waits for review/merge.

Type consistency check: `Cursor.encode/decode` signatures match between Task 2 (definition) and every consumer (Tasks 5, 7, 10-13). `CursorMismatchError` is consistently a `ValueError` subclass. TypedDict names match between `tool_schemas.py` (Task 4) and the per-tool migrations (Tasks 5-21). The `_meta` envelope shape (`MetaEnvelope`) is consistent with Phase A's `attach_meta()` output.

Placeholder scan: no "TBD", no "TODO", no "implement later". One forward reference in Task 26 ("if a cache wraps a non-changed response... no bump needed") — that's a judgment instruction, not a placeholder.

No issues found. Plan is ready for execution.
