Skip to content

Release v0.10.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 00:07
· 9 commits to main since this release

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 handle USER_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 new USER_DECLINED code 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): cached true, with cached_at and cache_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, and cached is 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 by max_response_size however 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. refresh still 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.md no 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's ClientSession validates 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
    ErrorResult both, 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_info is a described object rather than an open one. It
    was the last unconstrained dict[str, Any] on the wire, declared identically
    on fourteen result models, so the outputSchema 0.9.0 worked to make precise
    still said "any object" for the field every result carries. It is now a
    RequestInfo model 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 with additionalProperties: 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 within request_info now 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.
  • port in RequestInfo is deliberately unbounded despite naming a port.
    gemini_trust_update builds its provenance echo before validating its
    arguments and reports a bad port by quoting it back, so a le=65535 here
    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
    Gopher i (info) item is display text whose next_url is empty, which is
    most of a typical menu -- a model told to navigate by next_url could 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 a sensitive flag 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/list failed outright for every TypeScript-SDK client. The six tools
    whose output is a discriminated union advertised an outputSchema that was a
    bare oneOf with no root "type": "object". Every outputSchema in the
    spec's own examples has one, and the TypeScript SDK turns that into a hard
    requirement -- at 1.30.0 ToolSchema declares outputSchema as
    z.object({ type: z.literal('object'), ... }) -- so those clients rejected
    the response with -32603. Because ListToolsResultSchema parses the tools
    array as a unit,
    one rootless schema hid ALL eight tools, not just its own: the server
    completed initialize and 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's ClientSession checks 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 in tests/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, oneOf included, keeping type: "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_fetch tools were never affected either way,
    because a list return is already wrapped in a result object.
    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, and structuredContent is untouched. Note
    that 0.9.0 and 0.9.1 carry the same defect on gopher_fetch and
    gemini_fetch, and are only repaired by upgrading.
  • The outputSchema round-trip test validated through Pydantic, which is not
    what clients do -- the MCP SDK's own ClientSession runs
    jsonschema.validate against the advertised schema on every call, and
    jsonschema ignores the discriminator keyword, so the union is enforced as
    a bare oneOf that must match exactly one branch. That path had no test. It
    now has 52, covering every kind of 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 where mark_from_cache
    stamps fields via model_copy(update=...) without validating them. No
    currently-reachable payload fails, and the guards were mutation-tested to
    confirm they are not vacuous.

Release Information:

Installation:

pip install gopher-mcp==0.10.0

Verification:
All packages are signed and can be verified using Sigstore.