Skip to content

feat!: port to MCP Python SDK v2 (2026-07-28 protocol revision) - #362

Merged
cameronrye merged 42 commits into
mainfrom
feat/mcp-sdk-v2-port
Aug 20, 2026
Merged

cameronrye merged 42 commits into
mainfrom
feat/mcp-sdk-v2-port

Conversation

@cameronrye

@cameronrye cameronrye commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

Phases 1–5 of #360. Held in draft behind python-sdk#3273 until 2026-08-19; the shim described in "The release blocker, closed at our layer" ends the hold. Merging cuts 3.0.0.

What this does

Ports the server from MCP Python SDK 1.29 to 2.0.0, which implements the 2026-07-28 revision. The 1.x line is maintenance-only now.

Most of the change is deletion. The features this project hand-rolled against 1.x internals are first-class in v2, so subscriptions.py drops ~350 lines: the URI-keyed registry of live ServerSession objects, the WeakSet bookkeeping that inferred disconnects from garbage collection, the concurrent fan-out with per-subscriber send timeouts and dead-session eviction, and the monkey-patch of create_initialization_options that advertised a capability the SDK hardcoded to False. Clients now opt in via subscriptions/listen and the SDK's SubscriptionBus owns delivery. What survives is the part the SDK cannot know: MtimeWatcher.

The rest is mechanical: FastMCP → MCPServer, version/instructions as constructor kwargs (instructions now ride server/discover), transport config moved to the app builders, health routes onto the public custom_route hook, wire models to snake_case, and ResourceTemplate built via from_function so v2's parsed RFC 6570 template and ResourceSecurity policy are populated.

Behavior changes a user can see

Notification kinds are now distinct. A .zim file appearing or disappearing publishes resources/list_changed; a file replaced in place publishes resources/updated for its own URI. 1.x sent both as an updated for zim://files, because resources/subscribe gave clients no way to ask about list membership. zim://files is therefore no longer a valid subscription target — list_changed carries no URI.

resources/subscribe is no longer served. A 2025-era client gets -32601 and cannot receive change notifications; the rest of the handshake era (tools, resources, prompts) still works. This is a choice rather than a constraint — see the spike in the comments: the SDK keeps the method, and registering a handler through the public add_request_handler restores both the capability advertisement and real notification delivery to a legacy client. What it costs is the per-session subscriber registry this PR deletes. Subscriptions were HTTP-only before this change too, so no stdio client ever had the capability.

Results carry cache hints. Registration-table endpoints get a one-hour TTL. Reads split by URI: zim://{name} and zim://{name}/entry/{path} are fixed by a sealed archive file and carry OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS (new, default one hour), while zim://files is a live directory scan and stays bounded by the watcher's poll interval, so a cached listing is never staler than the server's own detection latency. The long TTL and subscriptions work together: a client on an open subscriptions/listen stream is told about a replacement immediately and re-reads. A client that is not subscribed — anything on stdio — can serve stale content until the TTL expires; set the knob to 0 to turn the override off. Scope is private throughout: payloads embed server-local paths.

One deviation from the plan in #360

The sse transport stays. The spec deprecates HTTP+SSE and the SDK still serves it. Removing a working transport is a user-facing decision that deserves its own change and changelog entry, not a side effect of an SDK bump. Now tracked in #366.

(The plan also called for dropping the legacy CORS headers. They stay, because the endpoint serves both eras — but note Mcp-Method/Mcp-Name are required and enforced today, so those entries are load-bearing rather than forward-compatibility. See the correction below.)

Two bugs this nearly shipped

SubscriptionBus.publish is async. The first version of publish_change called it without await — which delivers nothing, emits only a never-awaited warning, and would have passed every test as a silently dead subscriptions feature. mypy caught it.

The live subscription test drove subscribe_resource. It could only have failed against this build, and invisibly, since addopts excludes live tests from every normal run and from CI. It now opens a real subscriptions/listen stream.

A correction to an earlier claim on this PR

An earlier revision of this description said mcp 2.0.0 "does not implement Mcp-Method / Mcp-Name". That was wrong. It came from a rg search that silently skipped the virtualenv because it is gitignored. They are implemented (MCP_METHOD_HEADER in mcp/shared/inbound.py) and enforced: a modern POST whose Mcp-Method disagrees with the body's method is rejected with -32020. The stale code comment is fixed in this branch.

Verification

  • 3733 passed, 213 skipped, 0 failed, against the real ZIM fixture corpus (3675/217 at first review, before the base refresh brought the corpus job in). Skip count matches main exactly.
  • Live suite: 34 tests, 3 failures — the identical 3 that fail on main. No live regressions.
  • End-to-end over real HTTP: a real server process, stateless server/discover, an open subscriptions/listen stream, and both notification kinds arriving with correct URIs after real file changes on disk.
  • Protocol conformance asserted over an in-memory client session in both eras: legacy initialize and modern server/discover.
  • Cache hints verified on the wire, which is how their era-gating surfaced: the SDK stamps them only for modern clients, and legacy sessions correctly see none.
  • black / isort / flake8 / mypy / bandit clean; docs rewritten and test_docs_consistency passing.

Base refresh (2026-08-13)

origin/main moved to 2.7.0 (#363, #364) after this branch's base, so main is merged in as of ebbca89. #364's identity kwargs landed in the 1.x fastmcp_kwargs dict this PR deletes; they are re-homed onto the v2 constructor call, which takes website_url and icons directly — plus title and description, the two v2-only fields on the same identity surface. Its completion/complete module carried over verbatim, and its test file re-wired onto the v2 stream pair (the 1.x memory-session helper no longer exists) with snake_case wire-model attributes. mypy caught a third near-miss in the same family as the two below: Completion(hasMore=) — runtime-valid via alias, wrong for a codebase that standardized on snake_case. Merged-tree verification: 3695 passed / 0 failed / 213 skipped against the real corpus, all linters clean. With #363, this PR's CI now also runs the corpus-backed job — the ~54 corpus-dependent tests that skipped silently in earlier runs cover exactly the resource-read and search paths this PR rewrites.

Third review pass (2026-08-15)

A review aimed at the paths tests don't reach — the branch was fully green at the time — found six defects and three pieces of drift. All fixed in bb5e559.

Validation errors on resources/read reached clients as "Internal server error." A zim://{name}/entry/{path} whose path sanitize_input rejects (empty after sanitisation, or over INPUT_LIMIT_ENTRY_PATH) raised OpenZimMcpValidationError, which is neither MCPError nor a ResourceError. Nothing downstream converted it, because ResourceManager.get_resource invokes create_resource outside the try that wraps resource.read() — so modern_error_data replaced it wholesale with a generic -32603. The caller lost the only actionable part of the message and read a malformed request as a retryable server fault. Now INVALID_PARAMS with the text intact, converted at the only layer that can. This is the same failure the sibling ResourceNotFoundError conversion three lines above was added to prevent; the second call site was missed.

Replacement notifications never reached clients that subscribed with the spelling they had successfully read from. UriTemplate.match percent-decodes before matching, so zim://my archive is a working resources/read — which means a client that built its URI from the zim://files listing rather than by RFC 6570 expansion gets a valid read and then subscribes with that same string. The watcher published only the encoded form, and SDK delivery is exact-string (event_matches does event.uri in uris), so that client sat on a URI nothing ever fires on: silence on every replacement, and with the new hour-long resource TTL, a stale overview for the life of the stream. Both spellings are now published when they differ; unreserved stems still publish exactly once. Note this is the flip side of the fix in f547858 — that one made the encoded spelling deliver; this one covers the raw spelling that also reads.

publish_change mapped every unrecognised change kind to resources/updated. Only CHANGE_LIST_CHANGED was compared; CHANGE_REPLACED was defined but never matched, so a third watcher kind added later (a removal carrying zim://files, say) would have gone out as an updated for that URI — reintroducing exactly the list-membership/content conflation the 2026-07-28 split exists to end, at the one seam nothing asserted on. Both kinds are matched explicitly now; anything else is dropped with a warning.

The per-stream subscription URI bound went missing, and the docstring claimed otherwise. Deleting the 1.x registry took MAX_URIS_TOTAL / MAX_URIS_PER_SESSION with it, and this module's docstring said the SDK's ListenHandler was "the equivalent admission control." It is not: it caps concurrent streams (max_subscriptions) and per-stream backlog (max_buffered_events) and never inspects the requested URI set, which it then holds in a frozenset for the stream's lifetime. The real ceiling was 1024 streams × the transport's 4 MiB body cap — roughly three orders of magnitude looser than what it replaced, on a method the project's rate limiter does not cover (it wraps tool calls only). Restored as install_bounded_listen_handler, which swaps in a ListenHandler subclass that rejects an oversized set with INVALID_PARAMS before a slot is taken or an ack is sent — through the same private-registry seam already used to withhold the handler when subscriptions are off. The docstring now states what is and isn't bounded.

Title-mode promotion leaked the zero-result recovery hints onto the hit it produced. The empty-page promotion branch popped the stale reason="0_hits" but kept _meta.suggestions, which _assemble_find_response fills only for the no-results and fuzzy cases — its stated rule being that a confident hit carries none "so confident matches aren't muddled by alt-spelling noise." A promoted canonical is exactly such a hit, so the model was handed "did you mean X?" alongside the answer, inviting a second lookup for a query already resolved.

An empty M/<key> metadata value rendered as a bare ## Content heading. The metadata presenter was a copy of _render_entry_payload_text that had dropped its or "(No content)" fallback (and diverged on the content-type default's casing), so an archive storing a key with an empty value produced output that read as a truncated response — the same silently-empty rendering the offset accounting was added to fix, in the surface that fix was written for. Both renderers now share one _render_offset_and_body builder, so the copy cannot drift again.

Three pieces of drift alongside them: serve_streamable_http re-registered /healthz and /readyz on every call (custom_route appends unconditionally, so a retry after a failed bind stacked unreachable duplicates holding live closures for the process lifetime — the pre-port _custom_starlette_routes.extend had the same shape); the per-URI TTL decision fully re-parsed every overview body — metadata, namespace summary, and up to a 2000-character main-page preview — on the event loop to answer one boolean, now short-circuited by a substring probe that still falls through to the exact check; and OPENZIM_MCP_SUBSCRIPTIONS_ENABLED's description still documented the removed resources/subscribe handlers, three website pages still named EnvelopeAwareFastMCP, and TIMEOUTS.SUBSCRIPTION_SEND_SECONDS still documented an asyncio.gather fan-out over a session registry that no longer exists.

Verification: 16 new tests, each mutation-checked — every fix was reverted individually and its test confirmed to fail. 3733 passed / 0 failed / 213 skipped against the real corpus (from 3717 before this pass; skip count unchanged). flake8 / isort / black / mypy / bandit clean, and the full pre-commit hook set passes.

Left unfixed, deliberately: completion/complete recursively globs every allowed directory on every request and is not covered by the rate limiter — list_zim_files_data walks the tree to build its cache-invalidation signature before consulting its own cache, so even a warm listing rescans, and a picker sees one full walk per keystroke. A short TTL memo fixes the cost but fails test_completion_reflects_archives_added_after_startup, which deliberately pins live reads so an operator dropping in an archive sees it immediately. Bounding the cost means accepting a staleness window, which is a product decision rather than a bug fix, so it is left for its own change. (Also left: scanned_count is patched onto the payload by the W-namespace caller rather than being a parameter of the shared _new_scheme_browse_payload builder — a recurrence risk, but today's behavior is correct.)

Ninth pass (2026-08-19): the sweep before the cut

A four-dimension review — the new ping shim, docs drift, release mechanics, and a fresh-eyes tail over the full diff — with every finding adversarially verified, ran once the shim landed. The shim and the release mechanics came back clean. Four things did not:

Pagination stalled forever when a page landed entirely inside a whitespace run. paged_slice_length — the single source both the footer hint and _meta.more_at_offset derive from — returned 0 for an all-whitespace mid-article slice (lead is 0 past the top of the article and rstrip empties the rest), so both told the caller to re-request the offset it was already at: an infinite loop for any client following either, on any preformatted body with a whitespace run one page wide. Deferral now has a floor: such a page consumes its full slice and ships the run verbatim — deferring it to the next page defers it to the same page forever, and dropping it would fuse the words on either side, the exact defect the reassembly suite exists to prevent. That suite's own nxt > offset assert now covers the case.

The suggestions cache kept its key while this release changed the payload. The canonical-promotion fix (third pass) changed what _generate_search_suggestions produces, but suggestions_data:v2b survived — so an operator upgrading with persistence_enabled restored the 2.7.0 snapshot and kept serving the wrongly-promoted suggestions until TTL expiry, the embedded stat token blind to a change the archive didn't make. Bumped to v2c under the release's own rule (stated at the entry:v3 and browse_ns_data:v2e bumps: a change to what a payload contains needs a new prefix), with a sweep-9 guard test pinning the retired spelling out of the source.

The dependency range promised what the code cannot keep. mcp>=2.0,<3.0 admits any 2.x, but sdk_compat.py and the handler-registry installs are surgery on 2.0.0 internals; CI resolves the lockfile and stays green either way, while fresh installs (pip/uvx, Smithery, Docker) resolve the newest wheel the range admits — the lockfile-masks-range-defects failure mode. Held to <2.1; raising the ceiling is a deliberate bump PR that re-audits each seam, and the ping-shim canary says when the biggest one can go.

Docs said things the branch made false. The subscriptions page claimed delivery "no longer touches SDK internals" (the bounded listen handler — and now sdk_compat.py — are exactly that), told clients a raw non-ASCII stem "would never match" (the four-spelling publish exists precisely so it does), and presented the SDK's two stream caps as the only limits while the server's own pre-ack bound — 256 subscription URIs per stream, 2048 chars per URI, INVALID_PARAMS — went undocumented, including in the security page's defense-in-depth table, where the DoS-relevant numbers belong. And FastMCP — a name that does not exist in SDK v2 — survived in fourteen website references plus roughly twice that in code comments once a full grep ran; every one now names what ships, each behavior claim behind it re-verified against the installed 2.0.0 before renaming, with sdk_compat.py's "FastMCP 4" (the third-party client framework) the one deliberate survivor.

Verification for this pass: each behavioral fix mutation-checked (test red with the fix reverted, green with it). Full suite 4058 passed / 0 failed / 213 skipped against the real corpus — skip count unchanged, the growth from the third pass's 3733 being the tests sweeps four through eight added. Live suite over real HTTP: 27 passed / 0 failed (16 corpus-conditional skips), now including the modern-keepalive ping; the 3 failures earlier passes recorded did not reproduce. flake8 / isort / black / mypy / bandit and the full pre-commit hook set clean.

The release blocker, closed at our layer (2026-08-19)

python-sdk#3273 held this PR in draft: SDK 2.0.0's per-version method tables lack ("ping", "2026-07-28"), so every keepalive from a modern-negotiated client — the clients this port exists to serve — died as -32601 before the SDK's own ping handler was consulted. The gap is also wider than the issue states: SERVER_RESULTS lacks the row too, so patching only the request gate would have traded the clean error for an unhandled KeyError in the outbound sieve, mid-response. Both rows or neither.

Upstream still has no fix (2.0.0 remains the only release; no commit touches the tables), so this ships the escape hatch the hold was defined with: sdk_compat.install_ping_keepalive_shim(), run at server construction, copies the 2025-11-25 ping rows into both tables — ping's wire shape did not change in 2026; the gap is a missing entry, not a missing schema, and reusing the previous revision's models keeps the shim off the mcp_types._v* internals. The tables are MappingProxyType views that the SDK binds as call-time defaults, so neither rebinding the module attribute nor passing surface= from our layer can reach the runner's calls; the one seam every consumer shares is the dict under the proxy, and that surgery is confined to sdk_compat.py and its tests. The wire result is spec-correct without further help: the sieve passes the empty result through and the runner's own era backfill stamps resultType: "complete" and the serverInfo _meta.

Retirement is enforced, not remembered. A canary test captures the tables' pristine state at import — necessarily before any install can run — and fails the day the locked SDK defines the rows itself; deleting the shim is then a red build away, not a TODO. Wire tests assert the behavior in both eras in-memory, and the live suite now sends a keepalive over real HTTP against a spawned server process, where an in-memory test could not see an install that silently didn't happen. Mutation-checked both ways: with the install commented out, the in-memory test and the live HTTP test each fail with the exact upstream symptom (-32601 from a real server).

(Secondary upstream issue #3300 — a transport leaked per politely-DELETEd legacy session — remains open, slow, dual-era-path-only, and not release-blocking.)

What merging means

  1. Dropping resources/subscribe for 2025-era clients ships as designed. The decision the hold deferred is made; the BREAKING CHANGE: footer below is its changelog framing.
  2. Merging cuts 3.0.0. feat! on 2.7.0 is a major bump — with the keepalive gap closed, the timing of this merge is deliberately the timing of the major release.

Phase 5 status (verified 2026-08-11)

  • server.json $schema needs no bump. 2025-12-11 is still the only published registry revision — every 2026-dated URL 404s — and the registry schema does not encode MCP protocol revisions at all (zero occurrences of any protocol version string; it describes packaging and transport). The only unreleased draft change is a transport-URL template-variable relaxation, irrelevant to our stdio package entry. This checklist item rested on a false premise: server.json tracks the registry's schema evolution, not the protocol's.
  • .mcpb re-cut: clean, and the listing does not change. Built on the v2 runtime, the bundle captures all 8 tools and its manifest.json is byte-identical to the one main produces. This also exercises the legacy initialize handshake through a real subprocess — build_mcpb.py speaks protocolVersion: 2025-06-18 — so it is independent end-to-end evidence of dual-era support. Worth noting because the only test covering that path, test_capture_tools_live_matches_advanced_surface, is @pytest.mark.live and runs in neither a default run nor CI. (The bundle's sha256 differs from main's purely because v2 emits tool-object keys in a different order; same keys, same values, same byte length.)
  • CHANGELOG: the repo squash-merges with PR_BODY as the commit message, so this description is the changelog source. The framing is the BREAKING CHANGE: footer below.

BREAKING CHANGE: requires MCP Python SDK v2 (mcp>=2.0.0,<2.1 — held to 2.0.x while sdk_compat.py and the handler-registry seams depend on SDK internals; raising the ceiling is a deliberate bump that re-audits them) and adopts the 2026-07-28 protocol revision. The server answers both protocol eras from one endpoint, so 2025-era clients keep working for tools, resources, and prompts with no client change. Two things do break for them. this build no longer serves resources/subscribe, so a legacy client gets -32601 and cannot receive change notifications from it. That is this server's choice, not an SDK limitation: the lowlevel Server still accepts a handler for the method and advertises the capability when one is registered. Restoring it would mean restoring the per-session subscriber registry that this change deletes. Subscriptions have always been HTTP-only here, so no stdio client is affected. And zim://files is no longer a valid subscription target, because a .zim file appearing or disappearing is now published as resources/list_changed, which carries no URI. Clients on the 2026-07-28 revision subscribe through subscriptions/listen: ask for resourcesListChanged to hear about archives appearing or disappearing, and list zim://{name} under resourceSubscriptions to hear about in-place replacements.

The 1.x SDK is maintenance-only now that the 2026-07-28 revision has shipped,
and the features this server hand-rolled against 1.x internals are first-class
in v2. Sessions, the initialize handshake, resources/subscribe, and SSE
resumability are all gone from the protocol; the SDK serves both eras from one
endpoint, so 2025-era clients keep working through the deprecation window.

Most of the delta is deletion. subscriptions.py loses ~350 lines: the
URI-keyed registry of live ServerSession objects, the WeakSet bookkeeping that
inferred disconnects from garbage collection, the concurrent fan-out with
per-subscriber send timeouts and dead-session eviction, and the monkey-patch of
create_initialization_options that advertised a capability the SDK hardcoded to
False. Clients now opt in through subscriptions/listen and the SDK's
SubscriptionBus owns delivery. What remains is the part the SDK cannot know:
MtimeWatcher, and a publish_change that maps its two change kinds onto the
right notifications — a file appearing or disappearing is a list_changed, a
file replaced in place is an updated for that URI. 1.x had no way to say the
former and sent both as an updated for zim://files.

The registry's DoS caps and inbound-URI validation went with it. They bounded a
server-global, session-lifetime structure keyed by client input, which no
longer exists; the SDK's ListenHandler caps concurrent streams and per-stream
backlog instead.

Elsewhere the port is mechanical: FastMCP -> MCPServer, version and
instructions as constructor kwargs (instructions now ride server/discover),
transport config moved to the app builders, health routes onto the public
custom_route hook, wire models to snake_case, and ResourceTemplate built via
from_function so v2's parsed RFC 6570 template and ResourceSecurity policy are
populated.

Deliberate deviations from the upgrade plan, both because v2 turned out to
support more than expected:
- CORS keeps Mcp-Session-Id, Last-Event-ID and DELETE rather than dropping
  them. The endpoint serves legacy clients too, and stripping them would break
  browser-based ones. Mcp-Method / Mcp-Name are allow-listed ahead of use;
  mcp 2.0.0 does not send or enforce them yet.
- The sse transport stays. The spec deprecates HTTP+SSE and the SDK still
  serves it, so removing it is a user-facing decision that belongs in its own
  change, not a side effect of an SDK bump.

httpx is now an explicit dev dependency: it used to arrive transitively via
mcp 1.x, and v2 ships httpx2 instead.

Refs #360 (Phases 1-3).
The 2026-07-28 revision lets a result tell the client how long it may be
reused. Without hints every field defaults to zero, so a client re-asks for
the tool list on every turn and re-reads a resource it already has — the
freshness the fields exist to buy is left on the table.

The list endpoints and server/discover describe registration tables built once
at startup, which nothing short of a restart can invalidate, so they get an
hour. That is also what makes them worth caching at all: a stable tool block
is what earns a client's prompt-cache hits.

resources/read is bounded by the watcher's poll interval instead. Most of what
it serves is immutable — a ZIM archive is a sealed file, so an entry body for a
given (archive, path) can never change — and would justify hours. But the hint
is per method, not per URI, and the same method also serves zim://files, a live
directory scan; the floor belongs to the mutable member. Deriving it from the
poll interval means a cached read is never staler than the server's own
detection latency. Per-URI hints would need the handler to construct the
result, which the resource layer does not currently do.

Scope is private throughout: these payloads embed server-local absolute paths
and configuration, so a shared intermediary must not serve one tenant's
response to another.

Verified on the wire rather than at the constructor, which is how the era
gating surfaced: the SDK stamps these fields only for clients that opened the
2026 way, and a legacy session correctly sees none of them. Tests pin both,
since a hint that silently fails to apply looks identical to one that works —
the server keeps answering correctly, it just never gets to skip a request.

Refs #360 (Phase 4).
Comment thread openzim_mcp/subscriptions.py Fixed
The published docs still described `resources/subscribe`, its wire example,
the 5-second per-subscriber send timeout, and registry eviction — none of which
exist any more. A reader following them would have configured a flow the server
answers with method-not-found.

The rewrite covers the two notification kinds separately, since that is the
part clients must act on: `zim://files` is no longer a subscription target,
because a membership change arrives as `resources/list_changed` and carries no
URI. Delivery limits are now the SDK's (`max_subscriptions`,
`max_buffered_events`) rather than ours.

Two claims were checked against a running server rather than the source, and
both corrected what we assumed:

- `resources/subscribe` is not served at all on this build. The SDK never
  registers the handler, so a 2025-era client gets -32601 and cannot receive
  change notifications, even though the rest of the handshake era works. The
  earlier "legacy clients keep working" framing was too broad.
- `Mcp-Method` / `Mcp-Name` ARE enforced today: a modern POST whose
  `Mcp-Method` disagrees with the body is rejected with -32020. An earlier
  comment in http_app.py claimed the SDK neither sends nor enforces them; that
  came from a grep that silently skipped the virtualenv, and is now fixed. The
  CORS entries are load-bearing, not forward-compatibility.

The live subscription test drove `subscribe_resource`, so it could only have
failed against this build — invisibly, since `addopts` excludes live tests. It
now opens a real `subscriptions/listen` stream and asserts both kinds arrive,
which makes it the only coverage of delivery over real HTTP. Verified against a
live server: 34 live tests, same 3 pre-existing failures as main, none new.

Refs #360 (Phase 5).
The port introduced CHANGE_LIST_CHANGED/CHANGE_REPLACED next to
publish_change, but _tick kept emitting the bare string literals it
carried over from the 1.x module. That left CHANGE_REPLACED referenced
nowhere (CodeQL py/unused-global-variable) and, worse, left the emitter
and the dispatcher free to drift apart on the values they agree on.
The cache hints shipped so far are keyed by method, so one TTL had to cover
every resources/read. That method serves two very different things: a ZIM
archive, which is a sealed file whose content cannot change without the file
being replaced, and zim://files, a live scan of the allowed directories. The
honest method-wide value is the short one, so archive reads were capped at the
watcher's poll interval — five seconds by default — and the caching that
mattered most never happened. An overview read costs three blocking archive
opens; a client re-paid that every five seconds.

The SDK fills only fields a handler left unset, so a handler that stamps ttl_ms
itself wins over the server-wide hint for exactly the URIs it chooses.
_handle_read_resource does that for archive-backed URIs and leaves zim://files
alone, which lands it on the method-wide floor as before.

How long is a policy question, not a technical one, so it is configurable via
OPENZIM_MCP_RESOURCE_CACHE_TTL_SECONDS (default one hour). The trade-off is
staleness after an archive is replaced in place. A client on an open
subscriptions/listen stream is told immediately and re-reads, so the TTL costs
it nothing — the long TTL and the watcher are designed to work together. A
client that is not subscribed, which is anything on stdio, can serve the old
content until the TTL expires. Operators who hot-swap archives and cannot rely
on subscribers lower it, or set 0 to turn the override off entirely.

Zero disables rather than forbidding caching. The watcher's own detection
latency is already the tightest freshness the server can honestly promise, so
falling back to it is what "off" should mean; being stricter than the floor
would promise something the server cannot keep.

Era gating is pinned on this path specifically. The method-wide hints are
applied by the SDK, which decides whether the client's protocol revision has
any meaning for ttlMs; a handler-set value bypasses that decision point, so a
legacy session reading an archive URI is now asserted to still see nothing.

Also documents the cache hints for the first time — the per-method work landed
without any user-facing description of what clients may cache or for how long.

Refs #360 (Phase 4).
The TTL policy is stated as an exception — everything except zim://files is
backed by a sealed archive and may be cached for an hour. That is true of the
three resources registered today and silently wrong for any future one that is
computed rather than read out of a ZIM: it would inherit the long TTL without
anyone deciding it should, and nothing would fail. Pinning the registration set
turns that into a build failure, which is the prompt to classify the new
resource. Verified by registering a fourth resource and watching the assertion
trip.

Also records why the override sits on a private SDK method when the port spent
its effort moving off private reach-ins: a per-result hint is set by returning
a result with an explicit ttl_ms, and on this layer the only code that returns
a ReadResourceResult is that handler, which MCPServer binds as on_read_resource
at construction. The per-URI tests are the canary if a future SDK stops routing
through it.

Refs #360 (Phase 4).
@cameronrye

Copy link
Copy Markdown
Owner Author

The green checks on this PR have never seen a ZIM file

test-comprehensive is the only job that downloads the testing corpus, and it is gated:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

So it reports skipping on every PR — including this one. Every other job runs against a checkout with no test_data/, where the corpus-dependent tests skip themselves silently. That is the difference between the 217 skipped in this PR's description (a local run, with the corpus) and the 271 skipped a CI run or a fresh checkout produces. Roughly 54 tests, including the ones that read real archives, have been validated only on a laptop.

For an ordinary change that is a reasonable trade — the job is heavy and main catches it after merge. For this change it inverts: a protocol port whose resource-read and per-entry paths are exactly the corpus-dependent ones, merged on the strength of checks that never opened an archive, with the comprehensive job running only after the merge lands on main.

Closed for the current head by running it properly, locally, with the corpus downloaded:

  • Full suite with ZIM_TEST_DATA_DIR set: 3682 passed, 217 skipped, 0 failed. The 217 matches this PR's stated baseline, and 3682 is that baseline plus the tests added since.
  • Live suite (-m live, docker deselected): 24 passed, 1 failed, 10 skipped. The one failure is test_live_cache_persistence.py::test_cache_persistence_survives_restart, and it reproduces on main at 3d36fe7 — pre-existing, not a regression from this branch.
  • .mcpb rebuilt after the latest commits: byte-identical bundle, same sha256 as before them.

Worth deciding separately from this PR: make download-test-data-all took seconds here, so gating the corpus behind main-only pushes may be costing more confidence than it saves on runner time.

The upgrade plan called for deciding whether to pass custom headers through
CORS, and the port never did — the allow-list simply has no entry, which reads
as an oversight rather than a choice.

Make it a choice. The 2026-07-28 revision lets a tool annotate an input
property with x-mcp-header, and a modern client then sends that argument as an
Mcp-Param-<token> request header instead of in the JSON body. No tool here
annotates one, so no such header is ever sent and listing any would only widen
what a browser may put on the wire.

What makes this worth more than a comment is the failure mode. Adding the
annotation to a tool schema is a change to the schema, nowhere near the CORS
configuration, and the resulting break happens in the browser: preflight
rejects the request, so the server never receives it and has nothing to log.
The guard turns that into a build failure naming the header to add.

The allow-list moves to a module constant so the policy is inspectable rather
than buried in a middleware call.

Refs #360 (Phase 2).
@cameronrye

Copy link
Copy Markdown
Owner Author

Spike: a 2025-era client can still subscribe and be notified — for a price

Blocker #2 said dropping resources/subscribe is a real capability loss because "the SDK never registers the handler." That is wrong, and this time it was checked by running it rather than reading it.

The lowlevel Server keeps resources/subscribe and resources/unsubscribe in its handler table and exposes add_request_handler as public API. Registering a handler and driving a real legacy session over in-memory streams:

registered:               True
advertised_subscribe:     True          <- capability, with no monkey-patch
protocol_version:         2025-11-25
subscribe_ok:             True          <- not -32601
captured_sessions:        1
send_ok:                  True
notifications_received:   ['notifications/resources/updated']

So the legacy client subscribes, the capability is advertised, and the notification arrives.

Three things follow.

1. One deletion is permanent and unambiguously good. patch_capabilities_to_advertise_subscribe — the ~31-line monkey-patch of create_initialization_options that existed only because 1.x hardcoded the capability to False — is dead either way. v2 advertises it off the handler registration, which is how it should always have worked.

2. Restoring the rest is not free. Delivery is per-session (ctx.session.send_resource_updated), so it needs the subscriber registry back. Sizing it honestly: subscriptions.py went 559 → 212 lines, and the legacy-specific machinery is roughly 225 of those 347 — URI validation, the MAX_URIS_TOTAL/MAX_URIS_PER_SESSION caps, SubscriberRegistry with its WeakSet dead-session inference, and the handler wiring. Bringing it back re-imports the most bug-prone part of the old design to serve a deprecation window.

3. The blast radius is smaller than the framing suggests. Subscriptions were already gated to transport == "http" on main (server.py:264), exactly as they are here. No stdio client — which is what the .mcpb bundle and the uvx entry produce — ever had this capability. The affected population is: an HTTP deployment, with a 2025-era client, that actually calls resources/subscribe.

Recommendation: keep the loss, drop it as a blocker. Narrow blast radius, and the alternative is ~225 lines of exactly the machinery this PR was right to delete. What changes is the framing — it is a deliberate, reversible product decision with a known escape hatch, not an unavoidable consequence of adopting the revision. The PR description and the BREAKING CHANGE: footer are corrected accordingly.

That leaves no 2.0.x patch release as the only remaining reason to hold this branch.

cameronrye added a commit that referenced this pull request Aug 12, 2026
## The problem

`Comprehensive Testing` is the only job that downloads the ZIM testing
suite, and it was gated to pushes on `main`:

```yaml
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
```

So it reported `skipping` on every pull request — and the tests that
need a real archive then skipped themselves *silently* inside the jobs
that did run. Nothing failed, nothing warned; the suite just quietly
covered less.

The gap is measurable. A checkout without the corpus skips **271**
tests; one with it skips **217**. That is roughly **54 tests** — the
resource reads, the per-entry resource, the search paths — first
exercised only after merge, on `main`.

This surfaced while verifying #362, where it matters most: a protocol
port whose resource-read paths are exactly the corpus-dependent ones,
going green on checks that had never opened a ZIM file.

## Why the gate does not hold up

It was justified in a comment as the heaviest job. Measured:

| | |
|---|---|
| Corpus size | 14 MB |
| Job duration on `main` (download + full suite + slow suite) | ~2m21s |
| Windows matrix entries it runs beside | ~5m |

It was not costing runner time. It was costing confidence on every PR.

## Safety

Nothing in the job needs a push context. `permissions` is `contents:
read`, and the Codecov upload is already conditional with
`fail_ci_if_error: false`, so it degrades quietly on a fork PR with no
token.

## Follow-up worth considering separately

This makes the job *run and be visible* on PRs, but it is not in the
branch-protection required set, so a failure would not block a merge.
Adding it there is a repo-settings change and deliberately not bundled
here.
cameronrye added a commit that referenced this pull request Aug 12, 2026
Two client-facing surfaces the server had left empty. Both work on the
**1.29 SDK we ship today** — neither needs #362 — so they reach users
now rather than waiting on an SDK patch release.

## `serverInfo` identity

It carried a name and a version. A registry listing or client UI had
nothing else to show, so the server appeared as a bare string. It now
advertises the documentation site and the logo.

Both are https URLs on the published site (verified live, `200
image/svg+xml`). An icon fetched over plain http would be blocked as
mixed content in any browser-based client, which the test asserts rather
than leaves to review.

## Argument completion

`completion/complete` was never registered, so every argument naming an
archive was a free-text field the user filled from memory, with a failed
call as the only feedback.

| Argument | Completes to |
|---|---|
| `zim_file_path` on `/summarize`, `/explore` | full archive paths |
| `{name}` in `zim://{name}` | bare basenames, no `.zim` |

The two are deliberately different strings — a path in a `zim://{name}`
URI would not resolve, and the test pins that distinction. Matching is
prefix-based on the filename as well as the whole path, so `wikipedia`
finds `/srv/zim/wikipedia_en.zim`.

Three decisions worth review:

- **Empty, never an error.** A `/research` topic is free text and an
entry path can run to millions of rows. Returning `[]` rather than
raising matters because a raising handler surfaces in the client as a
*failed request* instead of "no suggestions" — a picker must not break
the session. The archive listing call is guarded for the same reason.
- **Read live, not snapshotted.** An operator dropping in a new archive
is precisely when someone reaches for the picker to find it. A
registration-time snapshot would be stale exactly then, so there is a
test that adds a file mid-session and expects it offered.
- **Capped at the protocol's 100-value page**, with the true count in
`total` and `hasMore` set, so a full Kiwix mirror degrades honestly
instead of returning a silently truncated list.

## Verification

- 3711 passed, 217 skipped, 0 failed, with the real ZIM corpus
(`ZIM_TEST_DATA_DIR` set).
- 8 new tests, written first and watched fail; all assert through a real
client session, since both features only exist as what a client
receives.
- black / isort / flake8 / mypy clean.

## Note for #362

`title` and `description` are v2-only constructor arguments and are
deliberately **not** included here; they belong with that port.
Everything in this PR is era-neutral and will carry across the merge.
main gained the serverInfo identity fields and completion/complete on the
1.x SDK (#364) plus corpus CI on pull requests (#363). The identity kwargs
move onto the v2 constructor call — the fastmcp_kwargs dict they landed in
no longer exists — and title/description, the v2-only fields on the same
identity surface, ride along. The new test file re-wires its session
helper onto the v2 stream pair (the 1.x memory helper is gone) and the
wire models go snake_case, matching the rest of the port.
@cameronrye cameronrye mentioned this pull request Aug 13, 2026
5 tasks
A review sweep of the SDK v2 port found eight places where what the
code meant didn't survive the SDK boundary:

- Withholding the subscription bus didn't withhold the capability. The
  SDK substitutes its own bus for None and registers
  subscriptions/listen regardless, and the 2026-07-28 capability
  derivation reports subscribe/listChanged purely from that handler's
  presence — so stdio (the default) and subscriptions_enabled=False
  deployments advertised notifications that could never fire, and an
  acked listen stream stayed silent forever. Drop the handler with the
  bus so the advertisement stays honest and listen fails fast.

- Replacement events published the raw archive stem, but SDK delivery
  is an exact string match against the client's template-expanded URIs,
  so stems outside RFC 6570's unreserved set (spaces, non-ASCII) never
  matched any subscription. Publish the template expansion instead.

- The 1.x-era unquote() in the entry template double-decoded paths the
  v2 SDK already decodes, corrupting entry paths that contain a
  literal '%' (common in zimit/web-archive ZIMs).

- An unknown ZIM name raised a bare ValueError, which the v2 SDK
  surfaces as a generic -32603 "Internal server error"; raise
  ResourceNotFoundError so the client gets -32602 with the message.

- The redirect-cycle/depth and binary-cap diagnostics were swallowed
  into "Error reading resource <uri>" — MCPError is the only type the
  SDK forwards verbatim, so convert at the read boundary.

- The hour-long per-URI TTL was stamped on reads it could never
  invalidate: per-entry URIs (no resources/updated is ever published
  for them) and overview error bodies (a cached "ZIM file not found"
  outlives the operator dropping the archive in). Both stay on the
  watcher-bounded hint, and the config description now says so.

- completion/complete ran the recursive archive scan inline on the
  event loop — the one sync scan left in the async server; a single
  request against a hung mount would have wedged every session.
The previous pages documented around two defects the port review just
closed: they told readers that stdio/SSE (and subscriptions_enabled=
false) servers acknowledge `subscriptions/listen` and advertise
subscribe/listChanged anyway, and that per-entry reads carry the
hour-long TTL. Both are now the honest versions — the capability and
handler are withheld together, and only the `zim://{name}` overview
(whose replacement actually publishes `resources/updated`) takes the
long TTL.
A follow-up pass over the two previous commits found the fixes sound
but their edges thin:

- The SSE transport also withholds the subscription capability (the
  gate is http-only, not merely non-stdio), and the docs promise it —
  but no test pinned it, so a gate rewritten as `transport != "stdio"`
  would have resurrected the false advertisement on SSE with the whole
  suite green. Pinned, and mutation-checked against exactly that edit.

- The `*_error` partial-failure overview shape was excluded from the
  long TTL but only the total-failure `"error"` shape was tested;
  deleting the clause left 3702 tests green. Pinned with a bogus-file
  overview that produces the shape naturally, mutation-checked.

- Four pages and one docstring still described the pre-fix behavior:
  troubleshooting and architecture-overview said stdio/SSE/disabled
  servers ack listen and stay silent (now method-not-found — an ack now
  *proves* the gate is open), the subscriptions page told clients to
  subscribe with the raw stem (now the template expansion, which is
  what delivery matches), the api-reference TTL row still covered
  "archive URIs" generally, and _cache_hints still claimed every
  archive-backed URI takes the long stamp.
The test still drove the pre-consolidation tool names — every call
answered "Unknown tool: get_server_health", so the health parse blew up
before any assertion ran, and the cache-populating calls were silently
populating nothing. It now speaks zim_health/zim_browse/zim_search/
zim_metadata and reads the legacy health payload from the combined
response's `health` key, and passes end-to-end: run #1 populates, the
graceful shutdown persists, run #2 reloads.
A second verified sweep over ground the port review didn't touch (the
Phase F wrappers, zim/ core, search pipeline, cache/security/limiter,
HTTP entry) confirmed ten distinct bugs:

- Title-mode promotion was discarded exactly when it mattered most: a
  filler-prose query whose full-phrase lookup returns 0 hits (the class
  promotion exists for) got `results: []` back while the resolved
  canonical was thrown away — yet one irrelevant raw hit DID get the
  canonical hoisted. An empty raw page now serves the promoted row.
- The promotion probe train (dozens of blocking libzim opens) ran
  inline on the event loop; now offloaded like every sibling call.
- Inbound-links cursors never verified archive identity, so a cursor
  from archive A silently paginated archive B at A's offset. The same
  guard every other paginated surface applies is now applied.
- zim_search validated `limit` against 1000 while cross_file caps at 50
  and filtered fulltext at 100 in the data layer, turning the promised
  invalid_limit envelope into a generic error naming an internal
  parameter. The wrapper now mirrors the per-dispatch caps.
- An oversize zim_get batch paid the full clamped batch price (the
  whole bucket) before the data layer rejected it having read nothing;
  now rejected up front at the flat price with a structured envelope.
- W-namespace browse resumed by surviving rows: a row dropped during
  materialisation re-served the survivor (duplicates) or re-issued the
  same cursor forever. The payload now reports candidate rows scanned.
- The M/<key> text presenter dropped all content_offset accounting: a
  tail slice read as the complete value and a past-end offset as an
  empty one. It now mirrors the regular-entry renderer.
- Telemetry's first-ever increment of a key raced across to_thread
  workers (Counter.__missing__ is a preemption point); now locked.
- A dotted persistence_path was rewritten by with_suffix, colliding
  e.g. openzim.prod and openzim.dev onto one snapshot file that
  last-writer-wins and peer-exit unlinks; the extension is appended.
- A non-ASCII auth token could never match for UTF-8-encoding clients
  (Starlette decodes headers as latin-1); rejected at config load like
  the blank-token footgun.

Also ports the live cache-persistence test off the pre-Phase-F tool
names it was still speaking. The zim_search description trim keeps the
advanced surface at 25,498 of the 25,500-byte schema budget.
…on surfaces

A third review pass over the port, aimed at the paths tests don't reach,
found six defects and three pieces of drift:

- A per-entry resource path that `sanitize_input` rejects reached 2026-era
  clients as an opaque "Internal server error". `ResourceManager.get_resource`
  calls `create_resource` outside the `try` that wraps `resource.read()`, so
  nothing converted the `OpenZimMcpValidationError` and the modern HTTP entry
  replaced it wholesale — discarding the only actionable part of the message
  and making a malformed URI look like a retryable server fault. Now
  INVALID_PARAMS with the message intact, converted at the only layer that
  can.

- Replacement notifications never reached clients that subscribed with the
  spelling they had successfully read from. `UriTemplate.match` percent-decodes
  before matching, so `zim://my archive` is a working `resources/read`, but the
  watcher published only the encoded form and SDK delivery is exact-string.
  Such a client sat on a URI nothing ever fires on — a stale overview for the
  life of the stream, extended by the hour-long resource TTL. Both spellings
  are published when they differ; unreserved stems still publish once.

- `publish_change` treated "not list_changed" as "replaced", so a future third
  change kind would go out as a `resources/updated` for whatever URI came with
  it — the list-membership/content conflation the 2026-07-28 split exists to
  end, reintroduced silently. Both kinds are now matched explicitly.

- The per-stream URI-set bound went missing with the 1.x registry, and the
  module docstring claimed the SDK's `ListenHandler` was the equivalent
  admission control. It is not: it caps stream count and event backlog and
  never inspects the URI set, which it holds for the stream's lifetime — a
  ceiling of 1024 streams x 4 MiB on a method the rate limiter does not cover.
  Restored as `install_bounded_listen_handler`, and the docstring now says what
  is and isn't bounded.

- Title-mode promotion carried the zero-result alt-spelling suggestions onto
  the confident hit it produced, against `_assemble_find_response`'s stated
  rule that a non-fuzzy hit carries none.

- An empty `M/<key>` metadata value rendered as a bare `## Content` heading:
  the presenter was a copy of `_render_entry_payload_text` that had dropped its
  `(No content)` fallback (and its content-type casing). Both now share one
  builder, so the copy cannot drift again.

- `serve_streamable_http` re-registered /healthz and /readyz on every call,
  stacking unreachable duplicate routes; the TTL decision re-parsed every
  overview body to answer one boolean; `subscriptions_enabled` still documented
  the removed subscribe/unsubscribe handlers; three website pages still named
  `EnvelopeAwareFastMCP`; and `TIMEOUTS.SUBSCRIPTION_SEND_SECONDS` documented a
  fan-out that no longer exists.

Every fix is pinned by a test that was mutation-checked against reverting it.
3733 pass.

Not fixed, deliberately: `completion/complete` recursively globs every allowed
directory per keystroke and is outside the rate limiter. Memoizing it trades
away the freshness `test_completion_reflects_archives_added_after_startup`
deliberately pins, which is a product call rather than a bug fix.
…yers

A fourth verified sweep, aimed at the subsystems the first three passes
never opened — the search pipeline core, query understanding, synthesis,
content rendering, and the archive/state layer — confirmed twenty-two
defects. One reported finding was refuted and is not addressed here.

Query understanding, the layer every `zim_query` call goes through:

- A 4 KB query cost 2.1 s of CPU and then returned a regex-timeout error
  instead of results. `_TRAILING_POLITENESS_RE` led with `\s+`, and a
  greedy repeat whose tail is a failing alternation backtracks O(n^2)
  over a whitespace run — a cost `safe_regex_sub` cannot bound, since
  CPython's `re` holds the GIL for the whole match. The anchor is now the
  fixed-width `\s` that `_PARAM_LEAK_RE` already uses; behaviour is
  unchanged because the match simply starts at the last space.

- The same politeness pass ate the entire topic when the topic *was* the
  polite word: `tell me about cheers` (the TV series), `tack`, `ta`, `tx`
  all returned "Topic Required" without touching the archive. The strip
  now refuses a pass that would consume the query's last content token.

- `get entries A/Mercury_(planet)` shipped `A/Mercury_` to the data layer.
  The batch extractor's character class dropped `(`, `)` and `@`, so it
  truncated exactly the disambiguated titles the tool itself emits. It now
  carries the single-entry sibling's tolerance, with paren-aware trimming
  so a parenthesised aside is still peeled.

- `search across all files for X` and `search all archives for X`
  classified as `search_all` but failed the extractor's narrower prefix,
  so the cross-archive search ran on the whole command sentence. The
  extractor now accepts the shape its own intent pattern admits.

- `query_rewrite.misspelling_map_path` / `misspelling_exclusion_path` were
  validated and stored and then read by nothing: an operator's curated map
  was silently ignored. Bound at handler construction, and the paths are
  now checked for readability at startup rather than failing mid-request.

Search and ranking:

- Zero-hit alt-spelling suggestions were derived from the path basename,
  which is only title-shaped on mwoffliner archives. On zimit/warc2zim
  archives the recovery hint was the empty string (paths ending in `/`)
  or an opaque `a682878.html` — and the empty value poisoned the dedup
  set, collapsing the whole suggestion pool to one blank entry. The
  entry's own title is authoritative and the archive is still open.

- The canonical-splice namespace gate parsed `iep.utm.edu/plato/` as
  namespace `iep.utm.edu`, so on every new-scheme archive the splice was
  unreachable. The gate now asks the archive for its scheme, matching the
  verdict `_matches_cheap_namespace` gives the same path. Unblocking it
  exposed a second defect it had been hiding: on archives that suffix
  every title with the site name, the one entry whose title matches a
  query *exactly* is whichever page carries a stripped title, so the
  splice promoted `iep.utm.edu/meno-2/` (titled "Plato") above the Plato
  article. The prepend now yields when a hit already carries the queried
  title, and the relevance order breaks the tie.

- The canonical-prefix suggestion probe excluded already-present rows
  from its scan instead of from its result, so it returned the *second*
  shortest title — promoting a non-canonical entry above the canonical
  and evicting a genuine suggestion from the page.

- Tail-hijack promotion guards tokenized the raw path, so on old-scheme
  archives the namespace letter counted as a token and every
  `len(cand_tokens) == 1` rule was unreachable: `antarctica coal aerosol`
  promoted `A/Aerosol`, the exact wrong-answer class those guards exist
  to block. Both schemes now produce the same promotion.

- `_is_list_article` matched anchored rules against the namespace-prefixed
  path, so catalog demotion silently no-opped on old-scheme archives. Its
  suffix table also treated a bare `_awards`/`_honors` tail as catalog
  shape, demoting `Academy_Awards` out of the answer lead; award catalogs
  are matched in their multi-word form now, and an explicit title-index
  promotion is exempt from the heuristic.

Rendering and bundling:

- A heading containing a run of backslashes hung the bundle build: the
  relaxed matcher emitted a per-character optional escape, letting the
  engine split n backslashes 2**n ways (16 s at n=26) before conceding.

- The compact search-snippet cap truncated inside `**bold**`, and the
  unpaired marker turned the rest of the response bold. It now runs the
  same repairs `create_snippet` applies at its own cut sites.

- `_heading_visible_text` folded HTML comment text into the heading, so a
  heading with a `<!-- -->` never matched the rendered markdown and the
  whole section vanished from the bundle.

- The `include_subsections=False` empty-body test added the heading line's
  length to its threshold, so a long-titled section swallowed a genuine
  lead paragraph. The measured slice starts at the body offset.

Envelopes, state, and the tool surface:

- Any response or query containing `<|endoftext|>` hard-failed the tool:
  tiktoken rejects special-token literals by default. A best-effort budget
  estimate must never fail the call.

- Combined `zim_health` / `zim_metadata` responses shipped `_meta: {}`,
  so a client budgeting context from `tokens_est` got nothing.

- `zim_health` leaked the absolute cache-persistence path over HTTP/SSE
  while redacting every sibling field — the docs already promised it was
  redacted.

- "File does not exist" reached the entry-level "Resource Not Found"
  template, whose recovery steps hunt inside an archive that was never
  opened; the caller was never told to list the real archives.

- Replaying a `next_cursor` without repeating `limit` silently changed
  page size: the cursor records `l` but no consumer read it, so zim_links
  jumped 2 -> 100 and zim_browse 5 -> 50 and 3 -> 200.

- A decodable cursor carrying no resume offset was served as a fresh
  page 1 — the exact failure simple mode returns a structured error for.

- `openzim-mcp build --help` exited 2, because argparse's SystemExit(0)
  was coerced by `or 2`.
…nd paging layers

Six defects in paths no test reaches, all found by aiming at the seams the
previous four passes did not cover. Every fix is mutation-checked: each was
reverted individually and its test confirmed to fail.

**Two ways a real heading became unlocatable, both silently losing a whole
section.** ``_compute_section_offsets`` logs one WARNING and ``continue``s
when no matcher finds a heading, so the section vanishes from the bundle and
``zim_get_section`` answers ``section_not_found`` for an id no listing ever
offered. First, ``_strip_md_inline_decorations`` dropped ``*`` and backticks
but never ``_`` — exactly what html2text emits for ``<i>``/``<em>``. Wikipedia
italicises the work title in the H1 of every article about a film, book,
album, or newspaper, so ``<h1><i>2040</i> (film)</h1>`` rendered as
``#  _2040_ (film)`` and the last-resort matcher compared that against
``2040 (film)``. On the shipped corpus ``A/2040_(film)`` reported
``heading_count: 0`` with an empty TOC. The strip is now emphasis-aware and
applied to *both* sides of the comparison, so a heading whose text really
carries an underscore still matches; an interior ``snake_case`` underscore is
preserved. Second, a ``<br>`` inside a heading becomes a real newline in the
rendered markdown and pushes the tail onto the following line, while
``_heading_visible_text`` concatenates the whole subtree — and every matcher
is line-anchored, so none could bridge it. ``_build_headings`` now records the
pre-break spelling under an opt-in ``line_text`` (opt-in because that dict is
also the wire payload of ``zim_get(view="structure")``, which must not
change), and the locator retries with it. A wikibooks article went from 1
heading to 10.

**A subscription on the ``.zim`` spelling never fired.** ``_resolve_zim_name``
deliberately accepts both the bare stem and the full filename, and the
``zim://files`` listing advertises ``name`` *with* the extension — so a client
that builds its URI from that listing reads ``zim://wikipedia.zim``
successfully and is handed the hour-long ``archive_read_ttl_ms``, then
subscribes with the same string. The watcher only published the stem, and SDK
delivery is exact-string, so that stream sat on a URI nothing ever fired on:
a stale overview for its whole life. This is the percent-encoding gap fixed in
bb5e559, along the extension axis instead; both axes are now published and
deduplicated, so an unreserved name still emits each URI exactly once.

**One open listen stream made the process unkillable.** ``uvicorn.Config``
left ``timeout_graceful_shutdown`` at ``None`` — wait forever — and a
``subscriptions/listen`` SSE loop only ends when the *client* disconnects. On
SIGTERM uvicorn blocked in "Waiting for connections to close" and never
reached the ASGI lifespan, which is where the MtimeWatcher is stopped.
Verified against a real server: still running 30s after SIGTERM before, exits
in 5.2s after. ``docker stop`` therefore burned its full grace period and
SIGKILLed, and SIGKILL skips ``atexit`` — discarding the cache persistence
save on every restart.

**The canonical-title splice made one search hit unreachable.** It prepended
a synthetic row then trimmed back to ``limit`` (a cosmetic fix for the
"showing 1-4 with limit=3" header), dropping the last BM25 hit — but told
neither resume mechanism, so the renderer still advertised ``offset + limit``
and ``next_cursor`` still encoded the pre-splice count. The displaced row fell
between the end of page 1 and the start of page 2. On the corpus, ``search for
biomass fuel`` with ``limit=3`` lost ``A/Aviation_biofuel`` entirely. The
splice no longer trims — which is what the filtered sibling
``search_with_filters_with_canonical_splice`` already does for the same query
— and records ``source_consumed`` so the cursor-less render path advances by
ranked rows rather than by rendered ones.

**Promotion reported a budget for a payload it had already replaced.**
``_merge_promotion_into_title_results`` rewrote ``results`` and carried the
old ``_meta`` forward verbatim, so ``chars``/``tokens_est`` described bytes
that never shipped — 47% of actual on the empty-page branch this PR added,
where a zero-row page with suggestions becomes a one-row hit. The envelope is
re-measured after the merge, carrying the source annotations through and
keeping bb5e559's rule that a promoted canonical sheds its zero-result hints.

Verification: 3943 passed / 0 failed / 213 skipped against the real corpus (22
new tests, from 3921). Live suite 25 passed / 0 failed, unchanged. black /
isort / flake8 / mypy / bandit clean, and the pre-commit hook set passes on
every changed file.
…pass exposed

Three defects, one of them introduced by the previous commit. Every fix is
mutation-checked: each was reverted individually and its test confirmed to fail.

**The reranker silently undid the pass-5 splice fix — and inverted it.** 1e1bd8e
made `_splice_title_match_into_search` prepend the canonical row as an EXTRA
rather than a replacement, so a spliced page carries `limit + 1` rows. Sixteen
lines later `_handle_search` hands that page to `_maybe_rerank_compact`, which
set `effective_top_k = limit` and cut it straight back down — re-applying the
very trim the fix removed, and violating the invariant its own comment states
("the caller's page size wins; rerank only REORDERS it"). That invariant held
only while `len(candidates) == limit`, which the splice broke.

The row that died was near-deterministically the canonical one. `BGEReranker`
scores `snippet or path` as the passage, and the synthetic row's snippet is the
sentinel `(canonical title match)` — a placeholder, not content. Measured on the
shipped corpus with the real BAAI/bge-reranker-base, it scored -5.5 against +4.6
for a genuine hit. So with the [reranker] extra installed, `search for effects of
climate change on humans` at limit=3 returned three rows and no canonical at all,
while the control run with the reranker disabled put `A/Effects_of_climate_change`
at rank 1. The splice's entire purpose was reversed by a placeholder string.

Fixed on both axes: the canonical row is held out of the comparison and pinned at
the head where the splice put it, and the cut is sized from the list actually
handed over rather than from the caller's page size — so the next thing that
grows a page cannot reintroduce the same defect. The sentinel is now a shared
constant rather than a literal repeated across three modules, since that
duplication is precisely what let the reranker diverge from the renderers that
already special-case it.

**Simple mode ignored the page size its own cursor advertised.** Every cursor
encodes `s.l`, and the advanced arm honours it through `effective_limit` — but
`decode_offset_cursor` never projected it, so `handle_zim_query` fell back to
each intent handler's hardcoded default. Replaying the `next_cursor` the footer
literally instructs the caller to pass resized a 3-row `walk namespace` page to
200 rows. Same precedence as the advanced arm now: explicit limit > cursor's `l`
> handler default, with the same positive-int validation so a hand-built cursor
degrades instead of erroring. This is the sibling-divergence shape again — one
arm got the fix, the other was missed.

**Percent-encoded link targets were never decoded.** ZIM stores entry paths as
raw UTF-8 (`A/El_Niño`) while the `<a href>` in archived HTML is RFC 3986-encoded
(`El_Ni%C3%B1o`), and `_resolve_link_to_entry_path` normalises without decoding.
So `zim_links(direction="related")` returned `{"path": "A/El_Ni%C3%B1o", "title":
"A/El_Ni%C3%B1o"}` — the title lookup failed too, leaving the raw path as its own
placeholder — and feeding that path to `zim_get`, the obvious next call, answered
"Entry not found". The inbound link graph indexed the same unfetchable keys.
Both consumers already open the archive, so they now pick whichever spelling it
can serve, raw first: some archives genuinely store a literal `%` in a path
(warc2zim assets like `I/Al_Gore%2C_2007.webp`), and decoding unconditionally
would break the paths that already work.

Verification: 3961 passed / 0 failed / 213 skipped against the real corpus (18
new tests, from 3943). Live suite 25 passed / 0 failed, unchanged. The reranker
fix is confirmed against the real cross-encoder end to end, not only the stub.
black / isort / flake8 / mypy / bandit clean.
Comment on lines +72 to +73
except Exception: # noqa: BLE001 — libzim raises bare KeyError/RuntimeError
pass
Comment on lines +78 to +79
except Exception: # noqa: BLE001 — same
pass
Comment thread openzim_mcp/constants.py
# reranker, which must NOT feed it to the cross-encoder as a passage — it
# scores ``snippet or path``, and a placeholder scores far below every real
# hit, sorting the row the splice exists to surface first into last place.
CANONICAL_TITLE_MATCH_SNIPPET = "(canonical title match)"
Two persisted artifacts survive an upgrade and are keyed only on inputs, never
on the code that produced them. Both re-serve pre-fix values after upgrading to
3.0.0, silently making this release's own fixes look like they did not work.

**The entry bundle.** `EntryBundle.sections` is what `view="toc"` and
`zim_get_section` read, and pass 5 changed what goes into it: an H1 whose title
html2text italicises, and a heading split across two lines by a `<br>`, now
yield sections that the previous locator dropped. The cache key is
`bundle:<prefix>:<path>:<mtime>:<size>:<entry>:<mode>` — nothing there moves
when the extractor changes. Verified by storing a pre-fix bundle under the
current key: `A/2040_(film)` goes straight back to `heading_count: 0`, and
`get_or_build_bundle` returns it with no revalidation. So an operator running
with `persistence_enabled` restores a 2.7.0 snapshot and keeps the old, broken
TOC for the full cache TTL on exactly the articles the fix was written for.

The codebase already set the precedent and the reasoning: the comment above
`_BUNDLE_KEY_PREFIX` records that v2c -> v2d existed because a fix would
otherwise "silently keep serving the child heading until TTL expiry without the
key bump". Pass 5 met that bar and did not bump it. Now v2e, with the rule
stated: a change to what a bundle CONTAINS needs a new prefix, not just a
change to its shape.

**The link-graph sidecar.** Pass 6 changed the spelling
`_parse_internal_link_edges` stores for an edge target — the path the archive
can actually serve (`A/El_Niño–Southern_Oscillation`) rather than the raw
percent-encoded href. The sidecar's table layout is unchanged, so a 2.7.0 file
loads and looks healthy, and `LinkGraphReader.open_for` fingerprints only
`schema_version` and `archive_uuid`. But `query_inbound` matches `nodes.path`
as an exact string and every caller now supplies the decoded form, so a stale
sidecar answers every non-ASCII lookup with an empty page. On the shipped
corpus that is 36 inbound links becoming zero for one article alone, reported
as "no inbound links" rather than as a stale sidecar.

`SCHEMA_VERSION` 2 -> 3 turns that silent wrong answer into the actionable
error the module already emits — "Run `openzim-mcp build link-graph <path>`
(rebuild it if the archive changed)" — which is the strict staleness decision
the reader's own docstring describes. The two tests that pinned the old
constant are updated with the rationale rather than deleted.

Verification: 3966 passed / 0 failed / 213 skipped against the real corpus (5
new tests, from 3961). Both defects reproduced before the fix and confirmed
closed after. black / isort / flake8 / mypy / bandit clean.
… all

Two defects in the watcher, both of which end with a subscriber reading
stale content for the full hour `resource_cache_ttl_seconds` allows,
without a notification or a log line to explain it.

`quote()` refuses to encode a lone surrogate, and `Path.glob` hands back
exactly that for any filename the filesystem stores as non-UTF-8 bytes. The
raise happened inside `_tick`, and `_loop` let it propagate out of the
polling task. Nothing ever retrieved that exception: the only place that
awaits the task is `stop()`, which suppresses it. So one badly-named file
in a watched directory permanently and silently ended `resources/updated`
and `resources/list_changed` for every archive on the server. Encoding with
`errors="surrogateescape"` percent-encodes the original bytes, which is
what the URI spec asks for anyway, and `_loop` now logs a failed pass and
takes the next one instead of dying — a fault that persists reappears every
interval rather than going quiet forever.

Separately, a replacement that straddles the poll window was never
published as a replacement. Swapping a large archive means `rm` followed by
a copy that runs for minutes, against a five-second poll, so one pass sees
the file removed and a later one sees it added and it never lands in
`changed`. The client holding a `resourceSubscriptions` entry for that
exact URI — the one that asked to be told about replacements — was the only
one that heard nothing. The watcher now remembers removed paths and treats
a reappearance as a replacement.

A path appearing for the first time stays listing-only, which is the split
this release documents and `test_watcher_changes_reach_the_bus_end_to_end`
pins. Removals stay listing-only too: `resources/updated` invites a re-read
and there is nothing left to read. The removed-path memory is bounded, so
churn cannot grow it for the process lifetime.

The four `zim://` spellings that must all notify move into `_uri_spellings`
now that two call sites need them.

5 new tests, each confirmed to fail against the unfixed code. Suite 3971
passed, 0 failed; mypy, flake8, black, isort clean.
…lly pay

The advanced surface measured 25,499 against a 25,500 cap — one byte of
headroom, so the next word added to any tool description would have turned
CI red on a release that adds descriptions.

The surface was not the problem. `_measure_tools` serialized with
`json.dumps`, which defaults to `", "` and `": "` separators, while the wire
is compact: pydantic emits no padding. Counting a space after every comma
and colon across eight tools inflated the figure by ~620 bytes. Measured
against a running server, the whole `tools/list` frame — JSON-RPC envelope
and all — is 24,881 bytes, comfortably inside the 25KiB MCP Tax pain band
the cap exists to defend.

This is the same failure the function's own docstring already warns about
from the other direction. Omitting `outputSchema` once let it measure a
number smaller than clients pay; counting padding made it measure a number
larger. Either way the cap is not measuring the thing it names.

Separators are pinned, `TOTAL_CAP` becomes the pain band itself (25KiB), and
a test asserts the padded serializer is not in use, so this cannot drift
back silently. Per-tool allocations are untouched: every ceiling simply
gained the padding back as real headroom, leaving 618 bytes on the total.

Suite 3972 passed, 0 failed.
…r X"

The `search_all` cue-strip treated `zim` as a head noun only, so `zims?`
claimed the qualifier in `search all zim files for photosynthesis` and the
alternation stopped there. What survived as the cross-archive search terms
was `files for photosynthesis` — the head noun and the preposition both
polluting BM25, on what is probably the most natural way a user has of
phrasing this. `zim archives`, `zim file` and `.zim files` failed the same
way; `search all files for X` and `search all zims for X` were fine, which
is why the variant list never caught it.

`zim` is now matched as a qualifier ahead of the head noun as well as a head
noun in its own right, in the extractor and in the `INTENT_PATTERN` that
gates it. Both, because the extractor's prefix has to stay at least as wide
as the gate — a prefix that fails to match returns the whole command
sentence as the search terms — and because the gate rejected `.zim` outright,
which would have left that half of the extractor unreachable.

Five phrasings added to `_SEARCH_ALL_VARIANTS`, each confirmed to fail
first, against the class that already pins exactly this property. Suite
3982 passed, 0 failed.
…ages over

`truncate_content` emitted `content[:max_length].strip()` but advertised
`content_offset = current_offset + max_length` — the *unstripped* page size.
Any whitespace sitting on a page boundary was therefore stripped out of the
page and then skipped over by the next offset, so it left the document
entirely. A caller doing exactly what the footer tells it to do — "Pass
`content_offset=N` to read the next page" — and concatenating the result got
the words on either side of every boundary fused: `word00001word00002`, one
character lost per page, silently. Paragraph breaks went the same way, so
paged article text lost its structure as well as its spacing.

The page still ends stripped; the difference is that the stripped whitespace
is now handed to the next page instead of deleted, because `next_offset`
advances by what was actually emitted. Leading whitespace is still trimmed
at offset 0, where there is no preceding page for it to belong to — on any
later page it is precisely the boundary whitespace its predecessor deferred.

Verified by paging a body end to end at several page sizes and asserting the
concatenation equals the original, including one case whose boundaries land
on paragraph breaks. All four assertions fail against the unfixed code.
Suite 3987 passed, 0 failed.
…e it

html2text puts a space after emphasis it closes even when the source had
none, so `<h2><b>Foo</b>bar</h2>` renders as `## **Foo** bar` while
`_heading_visible_text` reads `Foobar` off the soup. Stripping decorations
left `'Foo bar'` against `'Foobar'`, the last-resort matcher missed, and
`_compute_section_offsets` logged one warning and dropped the section — so
`view="toc"` never listed it and `zim_get_section` answered
`section_not_found` for a heading the article plainly has. Worse, the
preceding section's slice then ran on through the missing heading and
swallowed its body.

This is the third instance of the family the fifth sweep documented, and it
survived that pass because the natural fixture does not reproduce it: code
spans gain no space, so `<code>` abutting text matches either way. Only
`<b>`/`<i>`/`<em>`/`<strong>` do.

The cascade gains one final comparison with the spaces removed. It runs only
where the section would otherwise be lost, and both sides must still agree
on every non-space character, so it cannot promote a match the exact pass
rejected on content — asserted by a test that a genuinely different heading
still fails to match.

6 new tests, 4 confirmed to fail against the unfixed code. Suite 3993
passed, 0 failed.
… failure modes

Two problems the reappearance handling introduced, both found by reviewing
that change rather than the code it fixed.

`_scan` swallows an `OSError` per directory and returns a snapshot missing
every archive underneath it, which reads exactly like a mass deletion.
Recording those as removals meant the next healthy pass saw all of them
"reappear" and published a replacement for each — one transient stat failure
on a network mount turning into a notification storm and a re-read of every
archive on the server. `_scan` now reports whether it walked everything, and
the replacement inference is withheld when it did not. The listing
notification keeps its long-standing behaviour; only the new inference is
gated.

The removed-path memory was also consumed before the publishes rather than
after. `_snapshot` is deliberately assigned last so that a pass dying
mid-publish is retried wholesale on the next interval — but the memory did
not keep that discipline, so the retry no longer recognised the reappearance
and the `resources/updated` the client was waiting for was lost for good.
Both are now updated together, after every publish has succeeded.

2 new tests, both confirmed to fail against the previous commit: the first
saw six fabricated replacements from a single failed scan, the second saw
the notification vanish. Suite 3995 passed, 0 failed.
`SHUTDOWN_GRACE_SECONDS` was added so termination would be "clean rather
than killed", the stated benefit being that the cache-persistence save
registered in `OpenZimMcpCache.__init__` survives. It does not. Python's
default SIGTERM disposition terminates without unwinding, so `atexit` never
runs, and measuring it shows the gap plainly: closing stdin persists 5.3KB
and exits 0, while SIGTERM writes nothing and exits -15. Every `docker
stop`, pod eviction and systemd restart threw the cache away and forced the
next start to be cold, on both transports.

The HTTP transport looked safe and is not. uvicorn does run its own graceful
ASGI shutdown, but then restores the previous signal handler and re-raises
the signal to report the right exit status — and with the default
disposition restored, that re-raise killed the process just the same.

`main` now installs a SIGTERM handler that raises `SystemExit`, which
unwinds the server for both transports, uvicorn included: its re-raise lands
on this handler instead, after its own shutdown has completed.

Unwinding alone is not enough to reach `atexit`, though, and relying on it
would have replaced a fast wrong stop with a hang. The stdio transport parks
a worker thread on a blocking read of stdin, and nothing closes stdin when
the stop came from a signal, so interpreter finalization waits on that
thread until the supervisor's SIGKILL — measured at over 8 seconds before
being killed. So the flush is explicit via `cache.shutdown()` (which also
deregisters the now-redundant atexit handlers) and the process leaves
through `os._exit`, by which point `server.run()` has returned and the ASGI
lifespan that stops the watcher has completed with it.

Both transports now exit 143 promptly, and stdio persists 5.3KB where it
previously persisted nothing. 5 unit tests plus a live test that drives a
real subprocess through populate → SIGTERM → assert file. Suite 4000 passed,
0 failed; bandit -ll, mypy, flake8, black, isort clean.
`get entries 'A/Foo' and 'A/Bar'` reached `get_entries` as `A/Foo'` and
`A/Bar'`, which resolve to nothing. The namespace lookbehind excludes the
opening quote from the match while the suffix class accepts the closing one,
so quoting the paths — an obvious thing to type, and what the tool's own
examples look like once a title contains a space — broke every path in the
batch.

`_trim_entry_token` already had the right idiom for this from the paren
case: peel a closer only when it has no opener inside the token. A trailing
apostrophe now follows the same balance rule, so `A/Rock_'n'_Roll` keeps the
pair the title owns, while the quoted spelling of that same title is odd
again and loses only the quote.

12 tests, including the pre-existing paren and sentence-punctuation
behaviour as regression cover. Suite 4012 passed, 0 failed.
Reading `zim://{name}/entry/{path}` for an entry the archive doesn't have
answered `-32603` with "Error reading resource <uri>" — an internal-error
code, which tells a client the server broke and the request is worth
retrying, for a request that can never succeed, and without naming the entry.

libzim signals a missing entry with a bare `KeyError('Cannot find entry')`.
That is neither `MCPError` nor `OpenZimMcpArchiveError`, so it escaped both
converters at this boundary and the SDK replaced it wholesale. It now
converts to `-32602` naming the entry and the archive, matching the sibling
unknown-archive case one level up, which has answered `-32602` with a
descriptive message since the third pass.

Verified on the wire against a real archive: a missing entry now reads
`Entry not found: 'A/...' is not in '<archive>'.` where it previously read
the generic internal error, and a valid entry still returns its 12,637-char
body. The test is confirmed to fail against the unfixed module. Suite 4013
passed, 0 failed.
3.0.0 bumps the link-graph `SCHEMA_VERSION` from 2 to 3, so every sidecar
built by 2.x is rejected on load and every operator using
`zim_links(direction="inbound")` meets this error path on upgrade. It told
them to run `openzim-mcp build link-graph <archive>`, which exits 1 with
"sidecar already exists; pass --force to overwrite" whenever a sidecar file
is present next to the archive — which, in the situation that produces this
message, it always is. The one instruction shipped for the release's only
breaking on-disk change could not be followed.

The message now names `--force` and says why it is needed. The invalidation
is also documented in the API reference, where the sidecar was described as
going stale only when the archive's UUID changes — a schema bump was not
listed as a staleness cause at all, so nothing told a reader upgrading to
3.0.0 that their sidecars were about to stop working.

Also corrects the deployment guide, which claimed
`OPENZIM_MCP_SUBSCRIPTIONS_ENABLED=false` leaves `subscriptions/listen`
succeeding but silent. It does the opposite: the handler is dropped, so the
call fails with method-not-found and the capability reads `false`. Verified
against a constructed server; three other pages, including a later section
of that same page, already described it correctly.

Suite 4014 passed, 0 failed.
`test_removed_path_memory_is_bounded` asserted that the newest removal
survives a full memory, but a single tick diffs a *set* of removed paths
whose iteration order is arbitrary — so which entry survives eviction is not
a property of one pass at all. It passed locally and failed on CI's
different hash seed. The bound assertion stays; the recency guarantee moves
to its own test that spans two ticks, which is where it actually holds.
Verified across four `PYTHONHASHSEED` values.

`_uri_spellings` now publishes only spellings that can reach the wire.
Percent-encoding the surrogates fixed the raise inside the watcher, but the
loop also published the unencoded spelling, and a URI carrying a lone
surrogate cannot be JSON-encoded (`json.dumps(..., ensure_ascii=False)`
raises on it). That would have moved the same crash off the poller and onto
the subscriber's notification stream — a narrower blast radius than the
original bug, but the same bug.

Three CodeQL alerts raised by this pass's test code, one of them an error:
`returncode` was only assigned on the success path of the SIGTERM live test,
and the two discovery helpers in the phase-C module mixed explicit returns
with a terminal `pytest.skip` that CodeQL reads as falling through.

Suite 4015 passed, 0 failed; live suite 38 passed, 0 failed.
514f83e corrected the human-readable `content_offset=N` hint in the
truncation note but not `_meta.more_at_offset`, which is the field a
programmatic client actually pages by. That is computed from
`_content_chars` in two payload builders in `zim/content.py`, each
independently repeating `max_content_length if was_truncated else
len(content)` — so the structured path went on skipping the whitespace at
every page boundary and gluing the words together, and the two fields now
disagreed with each other about where the next page starts.

Both derive from one `paged_slice_length` now, alongside the body hint, so a
third copy cannot drift the way the second one did. That the arithmetic was
written out three times is why fixing one site looked complete.

The earlier tests only exercised `truncate_content` directly and passed with
`zim/content.py` reverted, so they were not pinning this at all. The new one
drives a real payload builder and fails against the unfixed arithmetic
(20 vs 19 on a boundary that lands mid-gap).

Suite 4019 passed, 0 failed; mypy, flake8, black, isort clean.
… changed

The reappearance inference trusted absence alone, and the completeness guard
added with it only covers a scan that *raises*. The ordinary way a watched
directory loses its listing raises nothing: an NFS or CIFS mount dropping, a
Docker volume being remounted, a CSI volume re-attaching. The directory stays
readable and simply lists empty. Every archive was then recorded as removed
and, when the mount came back, announced as replaced — measured here as ten
`resources/updated` notifications for five untouched files. Every subscribed
client would evict a correct cached overview and re-read it, three blocking
archive opens apiece, because a mount flapped.

The memory now carries the `(mtime, size)` each path had when it vanished,
and a reappearance publishes only when that differs from what came back.
This settles the flap without having to detect one: a mount that returns
intact brings every file back byte-identical and says nothing, while the
straddling `rm` + `cp` this inference exists for changes mtime, size or both
and still publishes. It also closes the case where a path is deleted and much
later re-created — the stat is compared, not merely the name.

`_last_scan_complete` stays as the guard for a scan that genuinely failed
part way; it is now the second line rather than the only one.

2 new tests, the first confirmed to produce ten false replacements against
the previous commit. Suite 4021 passed, 0 failed, stable across three
`PYTHONHASHSEED` values.
Cursors are unsigned base64 JSON, so `s.l` is entirely client-controlled.
Every tool checks an explicit `limit` against its cap, but that check only
runs when the caller passes one — a request carrying nothing but a cursor
skipped it, and both resolvers accepted any positive int. Measured against a
real server: a forged cursor naming `l = 1_000_000_000` reached
`search_zim_file_data` with exactly that, versus a documented cap of 1000.

`search_zim_file_data` has no range check of its own, so
`result_count = min(limit, total_results - offset)` becomes the whole match
set. On a full English Wikipedia archive a common term matches on the order
of a million entries, each row costing a libzim lookup plus an HTML snippet
render, and the finished payload is then serialized and written to the
response cache. One unauthenticated request, no explicit `limit` for anything
to validate.

Both resolvers now bound `s.l` by `MAX_SEARCH_RESULT_LIMIT`, which is exactly
the rule already documented for the lower bound: a hand-built cursor can
carry anything, and anything outside the acceptable range falls back to the
handler default rather than erroring. The server never mints a page above
that cap, so no legitimate cursor is turned away — asserted by a test that a
cursor at the cap is still honoured.

The tighter per-tool caps keep working as before: `walk_namespace` already
rejects a forged 100000 with "limit must be between 1 and 500", which is why
this only ever bit the search backend.

8 tests, 3 confirmed to fail against the unfixed resolvers. Suite 4029
passed, 0 failed.
The SDK's DNS-rebinding Host/Origin check runs inside the transport, and on
the legacy branch that is too late to matter. Era routing sends any request
without an `MCP-Protocol-Version` header down that branch, which creates the
transport, registers it in the session manager's table and starts its task
*before* reaching the handler that validates the headers. Measured: ten
requests carrying `Origin: http://evil.example`, every one correctly answered
`403 Invalid Origin header`, and the session table grew from 0 to 10.

Nothing reaps them. The app builder exposes no `session_idle_timeout`, so the
manager takes the `None` default and its idle-expiry branch never runs; the
table is emptied only by an explicit DELETE or process exit.

The exposure is the default deployment rather than an exotic one:
`--transport http` binds 127.0.0.1 with no token — which `check_safe_startup`
permits precisely because it is localhost — and no CORS origins, so the auth
middleware short-circuits and no CORS middleware is installed. Any page the
user visits can `fetch()` the endpoint in a loop and leak a session and a
live task per request, with the tool surface never invoked and nothing in the
logs naming a leak.

The same check now runs as the innermost of the three middlewares, so a
request that fails it is answered without the SDK ever seeing it. Auth and
CORS still answer first, and the health probes are exempt for the same reason
they are exempt from auth — asserted, so the gate cannot shadow them.

2 tests; the first is confirmed to fail against the ungated app (0 -> 10).
Suite 4031 passed, live suite 38 passed, 0 failed. bandit -ll clean.
…elease rewrites

`_MD_LINK_RE` stopped at the first `)`, but html2text backslash-escapes the
parens inside a link target — so a heading linking to a parenthetically
disambiguated article, which is how Wikipedia distinguishes most ambiguous
titles, reduced to `Mercury (mythology) "Mercury (mythology)")` instead of
`Naming after Mercury (mythology)`. That never matches the soup-side text, so
the heading was dropped, its section vanished from the bundle, and the
preceding section's slice ran on through and swallowed its body. It now uses
the same escape-aware alternation `compact_format._MARKDOWN_LINK_RE` adopted
for the CodeQL `py/redos` finding, where each character belongs to exactly
one branch.

Cache keys follow the rule the bundle prefix already states — a change to
what a value CONTAINS needs a new prefix:

- `bundle:v2e` -> `v2f`, because this pass makes two more classes of heading
  locatable, so affected articles gain sections they previously lacked.
- `entry:` -> `entry:v3:`, which was never versioned at all. Its stat token
  covers the archive changing, not this server changing what it renders from
  an unchanged archive — and 3.0.0 does that three times over (the empty
  `M/<key>` fallback, the page-boundary whitespace fix, `more_at_offset`). An
  operator upgrading with `persistence_enabled` would have been served the
  2.7.0 rendering until the TTL expired, every fix inert on exactly the
  entries it was written for.
- `browse_ns_data:v2d` -> `v2e`, since the new-scheme W payload gained
  `scanned_count` this release.

The transport-security gate's test no longer needs the ZIM corpus; hard-coding
that path failed the four CI jobs that run without it.

Suite 4039 passed with the corpus and 3978 without, 0 failed either way.
…rtesy word

`_TOPIC_LIKE_POLITENESS` exists because `Cheers` and `tack` are ordinary
article titles as well as courtesy words, and the strip refuses a pass that
would consume the query's last topic-bearing token. But that guard asks
`_carries_topic` what is left, and its scaffolding set covered only half the
intents: it held `structure`, `section` and `toc` but not `outline`, `table`
or `contents`. So `outline of tack` left `outline of` behind, which read as
topic-bearing, the guard stayed silent, the operand was peeled, and the
extractor anchored on the last keyword — producing a confident structure dump
for an entry named `of`, with nothing surfaced to say the topic had been lost.
`table of contents for cheers` gave `entry_path='for'` the same way.

The set now covers the scaffolding of the phrases that reach a greedy-tail
extractor. Adding a word here can only make the guard fire more often, and
firing means keeping the query the user actually typed, so the direction is
the safe one — `outline of Berlin` and friends are asserted unchanged, as is
a genuine trailing `please`.

9 tests, the first five confirmed to return `of` / `for` against the previous
commit. Suite 4048 passed, 0 failed.
The reassembly tests built their `ContentProcessor` against a hard-coded
`/tmp`, which Windows resolves to `D:\tmp` — not present, so config
validation rejected it and both Windows jobs failed while every other
platform passed. `tmp_path` gives a real directory on all three. The repo
already carries this note in `test_availability_fixes` and `test_config`;
this file missed it.
SDK 2.0.0 ships its per-version method tables without a ping row for the
modern revision (python-sdk#3273), so every keepalive from a
2026-07-28-negotiated client dies as -32601 before the SDK's own ping
handler is consulted - and fixing only the request gate would turn the
miss into an unhandled KeyError in the outbound sieve. install a startup
shim that copies the 2025-11-25 ping rows into both tables, through the
one seam every consumer shares: the dict under the MappingProxyType.

A canary test pins the upstream gap and fails the day the locked SDK
defines the rows itself, which is the signal to delete the shim. The
wire tests assert the behavior in-memory and, via the live suite, over
real HTTP against a spawned server process.

This was the concrete blocker holding the v2 port (#360) out of a
release; with modern keepalives answered, merging can cut 3.0.0.
paged_slice_length returned 0 whenever a mid-article slice was entirely
whitespace: lead is 0 past the top of the article and rstrip empties the
rest. The footer hint and _meta.more_at_offset both derive from it, so
each named the offset the caller was already at - an infinite loop for
any client following either, on any preformatted body with a whitespace
run at least one page wide.

Deferral now has a floor: the all-whitespace page consumes its full
slice, and truncate_content emits the run verbatim. Deferring it to the
next page defers it to the same page forever, and dropping it would fuse
the words on either side of the run - the exact defect the reassembly
suite exists to prevent. The sweep-8 harness's own nxt > offset assert
catches regressions.
This release changes what _generate_search_suggestions produces - the
canonical probe's Strategy B now takes the minimum over all prefix
matches and declines when the shortest title is already on the page -
but the suggestions_data key kept its v2b prefix. The key embeds an
archive stat token and the archive did not change, so an operator
upgrading with persistence_enabled restored the 2.7.0 snapshot and kept
serving the wrongly promoted payloads until TTL expiry, the fix inert on
exactly the cached queries it was written for.

Bumped to v2c under the release's own rule, stated at the entry:v3 and
browse_ns_data:v2e bumps: a change to what a payload contains needs a
new prefix. A sweep-9 guard test pins the retired spelling out of the
source the same way the sweep-8 one does for entry keys.
The range said <3.0 but the code performs 2.0.0-specific surgery:
sdk_compat.py patches the dicts under the SDK's method tables, and
server.py/subscriptions.py install handlers through the private
_request_handlers registry. CI resolves the lockfile and stays green
either way; fresh installs (pip/uvx, Smithery, Docker) resolve the
newest wheel the range admits, so a 2.1 that moves any of those seams
would break at runtime with the suite still green - the
lockfile-masks-range-defects failure mode again.

Raising the ceiling is a deliberate bump PR that re-audits the seams;
the ping-shim canary says when the biggest one can go.
Three claims the branch made false, and one name that no longer exists.

The subscriptions page said delivery "no longer touches SDK internals"
- the bounded listen handler and sdk_compat.py are exactly that - and
told clients a raw non-ASCII stem "would never match", which the
four-spelling publish exists precisely to make untrue. The server's own
pre-ack listen bound (256 URIs per stream, 2048 chars per URI,
INVALID_PARAMS) was documented nowhere while the pages presented the
SDK's two stream caps as the only limits; it now sits in the delivery
list, the security page's defense-in-depth table, and the deployment
bullet, with the rejection codes distinguished (fix the request vs try
again later).

FastMCP does not exist in SDK v2. Every remaining reference on the
website and in code comments now names what ships - the SDK, MCPServer,
or the MCP SDK default, whichever keeps the sentence's claim precise;
each behavior claim (base64 wrapping, non-dict wrapping, func_metadata,
the host allow-list default) was re-verified against the installed 2.0.0
before renaming. The one survivor is sdk_compat.py's "FastMCP 4",
which names the third-party client framework.
@sonarqubecloud

Copy link
Copy Markdown

@cameronrye
cameronrye marked this pull request as ready for review August 20, 2026 00:54
@cameronrye
cameronrye merged commit 7e92b3a into main Aug 20, 2026
14 checks passed
@cameronrye
cameronrye deleted the feat/mcp-sdk-v2-port branch August 20, 2026 00:58
@github-actions github-actions Bot mentioned this pull request Aug 20, 2026
cameronrye pushed a commit that referenced this pull request Aug 20, 2026
🤖 **Automated Release**

This PR was created automatically by release-please based on
conventional commits since the last release.

**Changes included:**
---


<details><summary>3.0.0</summary>

##
[3.0.0](v2.7.0...v3.0.0)
(2026-08-20)


### ⚠ BREAKING CHANGES

* requires MCP Python SDK v2 (`mcp>=2.0.0,<2.1` — held to 2.0.x while
`sdk_compat.py` and the handler-registry seams depend on SDK internals;
raising the ceiling is a deliberate bump that re-audits them) and adopts
the 2026-07-28 protocol revision. The server answers both protocol eras
from one endpoint, so 2025-era clients keep working for tools,
resources, and prompts with no client change. Two things do break for
them. this build no longer serves `resources/subscribe`, so a legacy
client gets `-32601` and cannot receive change notifications from it.
That is this server's choice, not an SDK limitation: the lowlevel
`Server` still accepts a handler for the method and advertises the
capability when one is registered. Restoring it would mean restoring the
per-session subscriber registry that this change deletes. Subscriptions
have always been HTTP-only here, so no stdio client is affected. And
`zim://files` is no longer a valid subscription target, because a `.zim`
file appearing or disappearing is now published as
`resources/list_changed`, which carries no URI. Clients on the
2026-07-28 revision subscribe through `subscriptions/listen`: ask for
`resourcesListChanged` to hear about archives appearing or disappearing,
and list `zim://{name}` under `resourceSubscriptions` to hear about
in-place replacements.

### Added

* port to MCP Python SDK v2 (2026-07-28 protocol revision)
([#362](#362))
([7e92b3a](7e92b3a))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants