Repository navigation
Release v0.9.0
Everything below applies the findings of a full review of the code, the
packaging and the documentation site. Each finding was independently verified
against the code, or by running it, before it was applied.
This is the largest set of breaking changes the project has made. Almost all of
them are in what a tool result looks like, so a client that reads only kind
and the fields it needs will be unaffected, while one that switches on error
codes, parses timestamps, or reads gemtext line internals will need the
migration notes below. See the
Migration Guide.
Added
gopher_fetchandgemini_fetchtake anoffset, and a truncated result
carriesnext_offset, plustotal_itemsfor a menu andtotal_charsfor a
body. Truncation used to be a dead end: the result said it had been cut and
offered no way to read the rest, so anything past the configured cap was
unreachable. A menu larger than the cap reports a null total rather than
parsing the whole directory to count it, which is the work the cap exists to
avoid.- Both fetch tools advertise a real
outputSchema, a discriminated union over
thekindvalues, instead of an unconstrained object. gopher_fetchtakes the search terms directly, so a Veronica-2 query
containing#,+or non-ASCII is no longer mangled on its way to the
server. This is whatinputalready does for Gemini.- The batch fetch tools accept
refresh, which the server instructions already
told the model to use. - The server registers MCP resources and prompts: the effective fetch policy is
readable over the protocol, and the navigation and safety rules are available
as prompts rather than only as prose in the instructions. - The HTTP transports answer
GET /health, and the container declares a
HEALTHCHECKagainst it. partial_lineon a gemtext result, for the one case where a window cannot end
on a line boundary because a single line is longer than the render limit.gopher-mcp --version, and help text that mentions Gemini and points at the
environment variables everything else is configured through. The version was
previously readable only by importing the package, which neither the
recommendeduvxinstall nor the container can do.- A redirect result says whether the target leaves the capsule or the
gemini
scheme, and states the five-hop limit the caller has to enforce, since this
server deliberately does not follow redirects itself. - A warning when a client certificate is sent over TLS 1.2, which transmits it
where a passive observer can read it. - Python 3.14 is covered by the CI matrix on Linux, macOS and Windows, and
advertised on PyPI. - The MCP registry manifest is published on a tagged release, and the container
image is published toghcr.io/cameronrye/gopher-mcpwith a signed
provenance attestation. The manifest had been gated on every tag for four
releases and uploaded on none of them.
Changed
- Breaking: a gemtext line is serialized once. Each parsed line used to
appear two and three times, ascontent, inside a nested per-type object
that repeated the same text under another name, and again in a
whole-documentrawContentfield that is now gone. A 487-byte page produced
3,526 bytes of JSON. If you readline.heading.text, readline.text. - Breaking: timestamps in tool results are ISO-8601 UTC strings rather than
epoch seconds.cached_atandrequest_info.timestamppreviously disagreed
about what a timestamp looks like, and the certificate tools already used
ISO. Stored values keep their epoch format, sotofu.jsonis unchanged. - Breaking: a Gemini 4x/5x failure reports the capsule's own text in
error.meta, anderror.messagenow carries only this server's explanation.
A hostile capsule could previously answer51 <instruction>and have its
text read as guidance from us. If you displayerror.message, you may want
error.metaas well, clearly marked as remote content. - Breaking: a tool failure sets the protocol's
isErrorflag. A blocked,
DNS-failed or rejected call used to look like a success to any host that
reads the flag rather than the body. The documented no-raise contract is
unchanged: no tool raises. - Breaking: info lines in a Gopher menu no longer carry a fabricated
nextUrl. The host and port a server parks in those fields never pointed
anywhere, and on a real Floodgap menu those unfollowable URLs were roughly
two thirds of the payload the model was told to navigate by. - Breaking: a hostname is IDNA-encoded when it is normalized, so the
Unicode and punycode spellings of an internationalized host share one trust
pin, one certificate scope and one robots policy. A link using the Unicode
spelling of an already-pinned capsule previously got a fresh trust-on-first-
use, letting an on-path certificate through with only a "trusted on first
use" note instead ofCERTIFICATE_CHANGED. Any pin stored under a Unicode
spelling is re-pinned under the encoded form on the next visit. - Breaking: trust pins and client identities default to this project's own
data directory rather than~/.gemini/, which belongs to Google's Gemini CLI
and holds thesettings.jsona user edits to register this server. Writing
generically named state there, and tightening its mode, reached into another
product's configuration. An existing~/.gemini/tofu.jsonor~/.gemini/certs/
keeps being read and written where it is, permanently, because relocating a
trust store silently would either lose every pin or make a pinned host look
unpinned. - Breaking: Gopher text results report LF line endings. The carriage return
of a CRLF-served page carried no information and spent an escaped\ron
every line of the JSON handed to the model. - Breaking: the dev, test and docs extras are PEP 735 dependency groups.
pip install gopher-mcp[dev]was a published, versioned install surface that
nothing documented and nobody supported. Contributors use
uv sync --all-groups. - Breaking:
task.pyis removed. The task table existed twice, in that
file and in taskipy, they had already drifted, and nothing compared them.
uv run task <cmd>is the one way to run project tasks, andmake <cmd>
delegates to it. - A mistyped or wrong-scheme URL answers with the one sentence that corrects
it, and names the sibling tool, instead of a pydantic dump of internals. - An invalid environment value fails with one line naming the variable, the
value and the accepted range, instead of a 26-line traceback pointing at an
internal field. It no longer takes--helpdown with it. - Both fetch tools accept an uppercase or mixed-case scheme and canonicalise
it, which RFC 3986 requires. The tools previously rejected URLs their own
parsers accept, including gemtext links this server had just handed back. - The robots refusal no longer tells the model to switch robots checking off,
and the documentation no longer points at a Geminispace search engine without
saying that it refuses automated queries. - All four fetch tools say in their own description that what comes back is
untrusted remote content, rather than relying on server instructions a client
may drop. - Settings are handed to the clients as a whole rather than one keyword at a
time, so a new setting cannot be added to the configuration and silently
forgotten at the call site. - The coverage gate rises from 85% to 95%, matching the suite's measured 97%.
The old gate had roughly 350 statements of slack for a regression to hide in. - Dependabot updates
uv.lockon its weekly run. The Python entry was on the
pipecosystem, which can only edit version floors and structurally cannot
touch the lockfile every CI job installs, so scheduled lock updates never
arrived and only security advisories moved it. - A release tag is refused unless the full nine-way CI matrix passed on that
exact commit. Releases previously re-tested Ubuntu on 3.11 only, so a
Windows-only regression could publish behind a green Release run.
Fixed
- A gemtext line longer than the render limit no longer costs its whole window.
There was no complete line to end the window on, so the window was returned
empty whilenext_offsetadvanced past it, which put those characters beyond
reach at every later offset: a caller following the documented continuation
loop assembled a fragment and was told nothing was wrong. On a real capsule
page at a 200-character limit, more than half the windows were affected. The
span now arrives as a plaintextline withpartial_line: true, so it is
never parsed as half a link and the characters still reach the caller. - Breaking:
--hoston the HTTP transports makes the server reachable
under that host. The container binds0.0.0.0precisely to be reachable and
answered421 Misdirected Requestto every client that was not on localhost,
because the SDK fixes a loopback-only Host allowlist when it is constructed.
A new repeatable--allowed-hostnarrows the accepted headers rather than
turning the check off. - Breaking: a trust or certificate store that cannot be written reports
CERTIFICATE_STORE_UNAVAILABLEand names the setting to check. The raw
OSErrorused to reach the robots probe's transport handler, so a read-only
disk was described to the model as a transient network failure whose stated
remedy was to retry, which could never succeed. - Breaking: a trust-on-first-use pin that fails to persist is no longer
trusted in memory. The retry after a storage error was served as "already
trusted" on a pin written nowhere, so a restart re-opened the first-use
window that error exists to deny. - Breaking: Gopher item types
Pand:are handled as binary rather than
decoded as latin-1 and handed to the model as up to 50,000 characters of
mojibake. - Breaking: one damaged byte no longer re-reads a whole UTF-8 page as
latin-1. Only the bad bytes become U+FFFD, and pervasively 8-bit bodies still
decode as latin-1. The mojibake was previously cached for the full TTL. - Breaking: a latin-1 server's menu links are followable again. A non-ASCII
selector byte is preserved throughnextUrland put back on the wire
unchanged, instead of being re-encoded as UTF-8 into a selector the server
has never heard of. - Breaking: a
#fragmentis stripped from a Gemini URL rather than
refused, so a link or redirect target this server emitted is one it will
follow. - Breaking: an internationalized Gemini host is sent as its punycode form
and non-ASCII path bytes are percent-encoded, so the request line conforms to
RFC 3986 and matches the name the TLS layer already sends. - Breaking: an empty answer to a status 10 or 11 prompt is preserved.
gemini_fetch(url, input="")reached the capsule as the bare URL it had just
answered with a 10, so a prompt that accepts an empty value could not be
answered. - Breaking: a Gopher menu item whose type field holds a control byte is
reported as an info line, so a raw escape can no longer reach the model
through the one field that was never sanitized. - Failing over to the next resolved address now happens when the first one
black-holes the connection, not only when it refuses it. The advertised
dual-homed fail-over previously helped only in the rare refused case. - A batch of URLs on one unreachable host costs one
robots.txtprobe rather
than one per URL. The lock that exists to make it a single request re-checked
the policy cache but not the failure backoff, so every waiter paid a fresh
connect timeout, serially, inside that lock. - A first fetch to a cold host no longer sleeps a full rate-limit interval, or
resolves the same name twice, because therobots.txtprobe and the fetch it
guards each paid separately for one user request. - A long status 44 backoff answers with a structured "retry in N seconds"
instead of sleeping up to five minutes inside the tool call while holding a
batch concurrency slot. - A certificate whose validity starts up to five minutes ahead of our clock is
pinned on first contact rather than refused. Capsules mint self-signed
certificates at startup, so seconds of clock skew used to fail the connection
and leave nothing pinned to recover with. Beyond that window the refusal is
nowCERTIFICATE_NOT_YET_VALIDrather than being reported as an expiry. - Server-supplied text is no longer stripped of characters that are visible.
The sanitizer removed every space separator except U+0020 and every format
character, so a non-breaking space and the CJK ideographic space were deleted
outright, fusing the words either side, and a family emoji came back as three
separate people. Controls, surrogates, private-use and separator characters
are still removed. - A failed connection no longer echoes the resolved IP address back to the
caller, which the transport's own comment already claimed it did not. - ANSI escapes are stripped from the Gemini redirect target, MIME type and
malformed-status message, the three server-controlled strings that still
reached the model raw. - The Gemini cache is keyed on the request that actually goes on the wire, so
a bare host, a trailing slash, an explicit:1965and an encoded dot segment
stop re-fetching identical content into separate entries. - A Gopher timeout names the configured deadline rather than the fraction of it
left when the transport was called, which read as an 18-digit number. NOT_FETCHABLEresults echo the request they refused, like every other
Gopher error.- A
?queryon a non-type-7 Gopher URL is still dropped, but the response says
so rather than returning an unrelated page silently. - Standard-library and MCP SDK log records go through the same pipeline as
ours, and the HTTP transports route uvicorn's startup and access logs through
it too, so a stream configured as JSON is JSON on every line, the optional
log file mirrors those records, and nothing reaches stdout. For a stdio
server, stdout carries the protocol stream. - The
py.typedmarker ships. TheTyping :: Typedclassifier has promised it
since 0.1.0, and without it every downstream type checker skipped this
package as untyped, discarding its strict-typing work at the import boundary. - The documentation site's architecture diagram renders. It had shipped as a
wall of literalgraph TBtext for a year, because the superfence format was
quoted as a string, and a strict build never noticed. - The documentation site no longer loads a script from
polyfill.io, a domain
seized after a supply-chain attack, nor a megabyte of MathJax for pages that
contain no mathematics. - The release scripts enforce the same coverage gate as the project, rather
than a threshold retired several releases ago, andscripts/verify-setup.py
no longer fails on a workflow that was deleted a year ago. - Documentation that described a different program: the Gemini error tables,
the Gopher item-type coverage, the response-type lists, the configuration
reference, a troubleshooting example the code cannot produce, and a dozen
references to an example host whose domain no longer exists.
Security
- Breaking: the
mcpfloor rises to 1.28.1 andcryptographyto 50.0.0.
The old floors admitted SDK versions with DNS-rebinding protection off by
default, the one protection the HTTP transports rely on, and the
minimum-versions job proved that set importable while never proving it safe.
Release Information:
- Version: 0.9.0
- Release Date: 2026-09-03
- Commit: a62727a
- Workflow: 33757737659
Installation:
pip install gopher-mcp==0.9.0Verification:
All packages are signed and can be verified using Sigstore.