Repository navigation
v2.3.0
Fixed
-
Catalog listings are now read to the end.
tools/list,resources/list
andprompts/listwere sent once with no cursor and whatever came back was
taken as the whole catalog, so a downstream with more entries than its page
size had the rest silently missing. That truncation predated downstream
fan-out, but once reconciliation began publishinglist_changedit started
asserting the catalog was current over a partial view — and re-applying the
truncation on every downstream notification rather than once at connect.
nextCursor(and thenext_cursorspelling) is now followed to the end.A failure on any page makes the whole kind unreadable rather than
partial: prior entries are kept and nothing is published. Merging the pages
that did arrive would drop entries the server still has and announce the drop
— the same false-removal shape this module has been corrected for repeatedly.
Following the cursor is bounded, and a server that repeats a cursor or never
stops paginating is treated as unreadable rather than looping or indexing a
truncated view.Connect and refresh share this path, so they read complete listings too.
One consequence worth stating plainly: a server whosetools/listfails on a
later page now connects successfully with an empty tools catalog, where
before pagination existed there was no later page to fail. Only a page-one
failure is still a connect error. That matches how connect already treats a
first page whose every entry is unparseable, but such a server sits at zero
tools until a downstream notification or a refresh reconciles it. -
A downstream server's JSON-RPC
errorobject no longer loses itscode
anddata. Both dispatch paths kept onlymessage, discarding the rest
of theerrormember.ClientManagernow raises a typedDownstreamError
carryingcodeanddataalongsidemessage. Scoped to the
ClientManagerboundary —gateway.invokestill maps every exception to
E302throughstr(e), which is byte-identical to the old message, so no
gateway.*output changes; surfacingcode/datato MCP clients is
tracked separately.
Added
-
A downstream server's own
notifications/tools/list_changed,
notifications/resources/list_changed, andnotifications/prompts/list_changed
now reach subscribed clients. Previously the read loop parsed these frames
and silently dropped them on both transports — a notification has noid, so
it fell through the pending-request gate with noelse. A downstream server
that added or removed a tool at runtime was invisible until the next
gateway.refresh(); that gap was v11 P3B's own Non-Goal.This is reconciliation, not forwarding:
ClientManager's indexes back
gateway.catalog_search,gateway.describe, andgateway.invoke, so
relaying the raw notification to the subscription sink would have told a
client "refetch" and handed it the old catalog — with a tool the server
just removed still invocable. The gateway now re-indexes the announcing
server first and publishes only once that finishes, and only for the catalog
kinds that actually changed. Changed by content, not by identifier and not
by count: a rename publishes, and so does a tool whose description or input
schema was edited under an unchanged name.Reconciliation fetches first and swaps second. It lists the server's tools,
resources, and prompts without touching the catalog, then removes and
re-indexes in a single synchronous block that contains noawait— so a
gateway.invokearriving mid-reconcile sees either the whole old catalog or
the whole new one, and never the empty window in between. A downstream that
announces a change and then failstools/listtherefore costs nothing:
nothing was removed, so there is nothing to roll back, and nothing is
published. Each kind is handled independently — aresources/listthat
fails (which is also how a server that simply does not implement resources
answers) leaves the existing resources in place and publishes nothing for
them, while the kinds that did answer still reconcile normally. The
guarantee for a subscribed client: the catalog is reconciled before the
notification goes out, so a client that refetches on receipt sees the change,
every time.A malformed catalog entry costs only itself. Indexing guards each entry
individually, so one tool the gateway cannot parse is logged and skipped
while the rest of that listing is indexed normally — the entries before it
and after it. This matters most on the reconcile path, where the swap has
already removed the server's previous entries by the time indexing runs: an
exception escaping there would have left the server with no catalog at all,
permanently, because the read loop stays healthy and no reconnect arrives to
heal it.gateway.refreshand connect-time indexing reach the same code, so
this is a deliberate connect-time behaviour change too: a server with one
unparseable tool now connects with the rest of its catalog instead of failing
outright.A listing whose entries are all unparseable is treated as a failed listing,
not as an empty one. Offered entries of which not one survives parsing leaves
the gateway in the same epistemic state as a request that failed — it could
not read the answer — so that kind keeps its previous entries and publishes
nothing. Failing to parse a listing costs visibility of the server's catalog;
it does not stop the server's tools from working, and announcing a removal on
the strength of it would tell every subscribed client those tools are gone.
The boundary is the count offered, not the count indexed: a listing that
offers zero entries is a genuine answer — the server emptied that kind —
and still clears the entries and publishes.A reply the gateway cannot read is a failed listing too, and an absent
collection is not an empty one. Atools/listreply of{}— missing the
toolsarray the protocol requires — is malformed, not an announcement that
the server has no tools, and the same goes for a reply carrying something
other than an array in its place ({"tools": {}},{"tools": null}). Each
of those now keeps the kind's previous entries and publishes nothing, exactly
like a request that failed; only a genuine array is an answer, and an empty
array still clears. Per kind, still: an unreadabletoolsreply no longer
costs an honestresourcesanswer arriving in the same pass.A catalog entry carrying no identity fails to parse rather than acquiring
one. A resource with nouri, or a prompt or tool with noname(or an
empty one), used to be indexed under a synthesized identifier of the form
server::— a catalog entry the downstream never offered, which replaced the
real entries and was published as a change. Such an entry is now skipped like
any other unparseable one, and a listing of nothing but those falls under the
all-unparseable rule above and keeps the previous entries.Failure classification is conservative by design. Any failure to list a kind
— a transport error, a server that does not implement it, a reply whose
collection is absent or is not an array, or a listing that could not be
parsed at all — keeps that kind's previous entries; only an explicit empty
answer clears them. The accepted cost is the mirror case: a
server that drops a capability mid-session and never reconnects keeps stale
entries in the catalog, which then fail loudly at invoke time. That is the
deliberate trade — a stale entry that errors when called is recoverable and
self-announcing, whereas a falsely removed entry is invisible: it silently
disappears from every subscribed client's catalog with nothing to point at.Reconciliation runs as a spawned, per-server-coalesced background task
rather than inline in the read loop — re-indexing awaits a response that the
very read loop which received the notification is responsible for
resolving, so an inline await would deadlock the connection instantly. A
downstream that emitslist_changedin reply to reconciliation's own
tools/listis bounded by a debounce on the re-run, not just coalescing, so
it costs one extra reconcile per interval instead of a hot spin. Both
transports are covered: stdio (_handle_stdout_line) and streamable
HTTP/SSE (_read_sse) previously shared the same silent-drop, and both now
dispatch through the same reconcile path. Unrecognisednotifications/*
methods (progress, logging) remain a no-op, as before.
Changed
version_checker.compare_versions(current, latest, package_type)is now the
sole version-classification path;is_version_newerand
are_versions_comparableare deleted, not deprecated. The old pair
answered "is X newer" and "can X and Y be ordered at all" as two separate
booleans, andis_version_newerfailed closed, so itsFalsemeant either
"up to date" or "cannot be ordered" — the same ambiguityare_versions_comparable
existed to guard against. A caller combining them as
are_versions_comparable(...) and not is_version_newer(...), or skipping the
guard and just negating, collapsed those two meanings back into oneFalse.
That exact collapse shipped three times (#155, #156, #163), and an AST lint
written to police the pattern was bypassed by reviewers four times, because a
syntactic check cannot prove a dataflow property.compare_versionsreturns
a three-wayLiteral["newer", "not_newer", "incomparable"]instead, so a
caller has to name the branch it means. Deleting the two wrappers — rather
than leaving them as deprecated aliases — is what makes the collapse
unrepresentable instead of merely detectable: a function that no longer
exists cannot be negated into the old ambiguity. The AST lint is deleted
with them, since there is nothing left for it to police.is_version_orderable
is unaffected and remains. Behavior is unchanged: all prerelease ordering,
SemVer-vs-PEP 440 disagreement on1.0.0-1, build-metadata, digest
canonicalization, CalVer, and mixed version/digest cases classify identically
to before.