Repository navigation
Release v0.10.0
Added
- The two tools that change stored state ask the user first, on clients that
can carry the question. Removing or replacing a certificate pin, and creating or
removing a client identity, are destructive, model-initiated, and not undone by trying
again -- a capsule knows an identity by its public key, so a replacement
certificate is a different person. MCP has had a channel for putting that
decision to a human since before this server existed, and nothing used it.
This can turn a call that always succeeded into a failure, so it is in the
migration notes as well as here: an automated caller running on an
elicitation-capable client has to handleUSER_DECLINED, and should treat it
as an answer rather than a transient failure, because retrying asks again.
Behaviour is unchanged for every client that does not advertise elicitation:
it is never asked and never sees a refusal, because consent a client cannot
express is not consent withheld. A client that CAN be asked and answers no
gets the newUSER_DECLINEDcode and nothing is changed. A client that
advertises the capability and then fails to deliver the question is also
refused -- the one place failing open would be indefensible, since it would
mean acting on consent nobody actually gave. - A continuation window rendered from a held body reports itself as the
snapshot it is (again, only when the cache is enabled):cachedtrue, withcached_atandcache_age_seconds
naming when the bytes actually came off the wire. Holding the body is what
makes this necessary -- those windows are no longer fetched during the call
that returns them, andcachedis documented as exactly that condition, so
leaving it false would hand the model content up to a full cache TTL old
while telling it to treat that as the resource's current state. A window
cached for later replay is now stamped with when its content was fetched
rather than when it was rendered, for the same reason. - Reading one document now costs one download, when the response cache is
enabled (the default; both halves of the mechanism bail out if it is off)
and, on Gemini, the URL carries no query. A query-bearing Gemini request is never
held, because that is where the answer to a status-10/11 prompt travels
and a status-11 answer is a password — so those pages, search results
included, still re-fetch for every window, by design. Neither protocol has a range
request, so a windowed read transfers the whole resource and shows a slice of
it -- and every continuation went back to the server for the same bytes.
Measured on a 208 KB page at a 10k cap: 21 windows, 21 downloads, 4.4 MB, and
with the shipped per-host rate limit those 21 downloads are also 21 seconds,
paid by whoever is hosting it. Each client now holds the most recently
truncated body, so the windows after the first are free; the same walk is now
21 windows and 1 download.
The rationale this replaces was tested rather than assumed. Both clients
carried a comment saying that caching bodies "would put full-size bodies in a
cache whose entry cap exists to bound its memory", and the opposite turned out
to be true: a full walk left 21 per-window entries holding 217,438 bytes,
MORE than the 208,005-byte body they declined to cache. One slot rather than a
cache keeps the memory bounded bymax_response_sizehowever many resources
are read, and a continuation from a held body skips the per-host rate limit,
which exists to pace requests to a server and should not charge for one that
is never made.refreshstill goes back to the server, the slot expires on
the same TTL a cached response does, and the offset stays in the response
cache key -- the two do different jobs, and dropping the key would serve
window 0 for a request for window 1.
A Gemini window rendered from a held page replays the TLS version, cipher,
certificate fingerprint and TOFU warning of the transfer those bytes came
from. Dropping them would be misreadable rather than merely absent: a window
with no fingerprint beside windows that have one reads as "this one was not
authenticated". - The batch fetch tools report progress. A batch is the one call here slow
enough to need it: the per-host rate limit spaces same-host requests a second
apart, so fifty URLs aimed at one capsule take at least forty-nine seconds,
and a client saw the request go out and then nothing at all until every URL
had been fetched. One notification lands per COMPLETED item, carrying the
batch size as the total -- per completion rather than per start, because a
batch spends nearly all its time waiting, so "started" would jump to the
concurrency cap and then sit still. Nothing changes for a client that does not
ask: MCP progress is only sent when the caller supplies a progress token, the
tools' input schemas are unchanged, and a notification that cannot be
delivered is logged and swallowed rather than allowed to fail a fetch that has
already succeeded.
Changed
SECURITY.mdno longer dates itself. Its support policy said "as a pre-1.0
project, those fixes ship on the latest minor version", which would have
become false the day 1.0 was tagged -- in the one document where a stale
claim about which versions receive security fixes is not cosmetic.- All six remaining tools advertise a real
outputSchema. 0.9.0 gave the two
fetch tools a discriminated union and left the rest on the default: the four
trust-store and client-identity tools advertised a bare open object, and the
two batch tools an array of open objects, while their docstrings told the
model each item is exactly what the single-URL tool returns. A client could
not check any of it -- and the MCP SDK'sClientSessionvalidates every
result against the advertised schema, so an open schema means that check
passes on a payload from a different tool entirely. Each schema is now the
union of what that tool can actually return, the success shape and
ErrorResultboth, since every one of them can also fail. No payload moves:
the batch wire already carried{"result": [...]}and still does, byte for
byte, and the schemas grew from 88-202 bytes to 5.9-20kB of description. - Breaking:
request_infois a described object rather than an open one. It
was the last unconstraineddict[str, Any]on the wire, declared identically
on fourteen result models, so theoutputSchema0.9.0 worked to make precise
still said "any object" for the field every result carries. It is now a
RequestInfomodel with fourteen documented, individually optional fields
(url,timestamp,host,port,type,selector,search_ignored,
path,has_query,tls_version,cipher,cert_fingerprint,
tofu_warning,client_cert_warning), and the advertised schema references
it withadditionalProperties: false. What a caller receives is otherwise
unchanged: the values are byte-identical to 0.9.1 and a key absent before is
absent now. Two things do change. A client validating strictly will reject a
payload carrying any other key, which previously passed through; and the key
ORDER withinrequest_infonow follows the field declarations rather than the
order the producer happened to insert them, which is invisible to a JSON
parser but visible in the pretty-printed text block many hosts show the model. portinRequestInfois deliberately unbounded despite naming a port.
gemini_trust_updatebuilds its provenance echo before validating its
arguments and reports a bad port by quoting it back, so ale=65535here
turned the one call whose job is to report an out-of-range port into an
unhandled crash.- The server tells the model two things it previously only knew internally. A
Gopheri(info) item is display text whosenext_urlis empty, which is
most of a typical menu -- a model told to navigate bynext_urlcould only
discover that by spending a tool call on each one and getting
INVALID_REQUEST. And an answer to a status-11 (SENSITIVE INPUT) prompt must
never be echoed: the result already carried asensitiveflag and the AI
assistant guide already said so, but nothing that reached the model over MCP
did. Both now appear in the server instructions and at the point of use in the
relevant tool description. - README states which protocols are in scope, and that Titan, Misfin, Spartan,
Nex and Gopher+ are deliberately out of it rather than merely unbuilt -- the
first two because they are write protocols and this is a read-only gateway.
It also stops claiming every standard RFC 1436 item type is categorised:+
(redundant server) has no category of its own and takes the unknown-type path,
which is deliberate and was already pinned by a test.
Fixed
tools/listfailed outright for every TypeScript-SDK client. The six tools
whose output is a discriminated union advertised anoutputSchemathat was a
bareoneOfwith no root"type": "object". EveryoutputSchemain the
spec's own examples has one, and the TypeScript SDK turns that into a hard
requirement -- at 1.30.0ToolSchemadeclaresoutputSchemaas
z.object({ type: z.literal('object'), ... })-- so those clients rejected
the response with-32603. BecauseListToolsResultSchemaparses the tools
array as a unit,
one rootless schema hid ALL eight tools, not just its own: the server
completedinitializeand then appeared to have no tools at all.
Nothing caught it, and the reason is worth recording. Every test here
validates the way a Python client does, and the Python path is genuinely
more permissive: the SDK'sClientSessionchecks results with
jsonschema, which does not require a root type. So the 52 round-trip tests
in the entry below were asking the right question about the wrong thing --
whether each payload matches the advertised union -- while the question
nobody asked was whether the advertised schema is itself legal. It is now
a wire-level assertion over every tool intests/test_mcp_protocol.py.
Found by audit rather than by a bug report, and confirmed against the
TypeScript SDK's own source at 1.30.0. Worth knowing that the constraint is
narrower than it looks: SEP-2106 has since widened the SDK's neutral schema
to accept any JSON Schema root,oneOfincluded, keepingtype: "object"
only in the frozen 2025-11-25 wire revision. Declaring the root is still the
compatible choice -- it satisfies the strict parse and the widened one alike
-- but a client new enough to carry SEP-2106 would have accepted the old
schema. The two*_batch_fetchtools were never affected either way,
because a list return is already wrapped in aresultobject.
No payload changes. Every branch of every union was already an object, so
the constraint was true before it was stated; each advertised schema gained
exactly one key and lost nothing, andstructuredContentis untouched. Note
that 0.9.0 and 0.9.1 carry the same defect ongopher_fetchand
gemini_fetch, and are only repaired by upgrading.- The
outputSchemaround-trip test validated through Pydantic, which is not
what clients do -- the MCP SDK's ownClientSessionruns
jsonschema.validateagainst the advertised schema on every call, and
jsonschemaignores thediscriminatorkeyword, so the union is enforced as
a bareoneOfthat must match exactly one branch. That path had no test. It
now has 52, covering everykindof both fetch tools as minimal and maximal
payloads, asserting each matches exactly one branch, checking the schema is
itself valid, and exercising the cache-replay path wheremark_from_cache
stamps fields viamodel_copy(update=...)without validating them. No
currently-reachable payload fails, and the guards were mutation-tested to
confirm they are not vacuous.
Release Information:
- Version: 0.10.0
- Release Date: 2026-09-06
- Commit: 76044af
- Workflow: 33999691984
Installation:
pip install gopher-mcp==0.10.0Verification:
All packages are signed and can be verified using Sigstore.