Migration Guide
This guide is the upgrade path between releases of the Gopher & Gemini MCP Server: what breaks, what a client has to change, and what is merely additive. It runs newest release first. Read down from the version you are upgrading to until you reach the one you are coming from, and stop there — an upgrade that crosses three releases means reading three sections.
| Version | Why you have to read it |
|---|---|
| 0.10.0 | request_info is a closed, described object; destructive tools may now ask the user and refuse; continuation windows report their age |
| 0.9.0 | Result payload shapes, four new or widened error codes, where trust state lives on disk, and the project's own tooling |
| 0.8.0 | Four error codes split or renamed — anything switching on error["code"] |
| 0.7.0 | robots.txt is honoured by default; a fetch that used to succeed can now be refused |
| 0.6.0 | Rate limits on by default, an empty allowlist flipped meaning, public Python API removed |
| 0.2.0 | Gemini added alongside Gopher; nothing that already worked changed |
There is no need to migrate stored data at any version. Caches are rebuilt on demand, and the one on-disk format that moved (see Where trust state lives) keeps reading its old location permanently.
v0.10.0
One breaking change to what a result contains, and one change to what a call
may do. Nothing was renamed or removed, and no existing argument changed
meaning, so a client that fetches a URL and branches on kind needs no change.
request_info is a closed object
Breaking. It was dict[str, Any] on every result model, so the schema
described the one field every result carries as "any object". It is now a
described object with fourteen named, individually optional fields — url,
timestamp, host, port, type, selector, search_ignored, path,
has_query, tls_version, cipher, cert_fingerprint, tofu_warning,
client_cert_warning — advertised with additionalProperties: false.
The values you receive are byte-identical to 0.9.1. No key was added, removed or renamed, and a key that was absent before is absent now. Two things do change:
- A client that validates results against the advertised
outputSchemawill now reject a payload carrying any other key. Nothing this server emits does; this bites a fork or a proxy that appends its own provenance key. - The order of keys within
request_infofollows the field declarations rather than the order the producer happened to insert them. JSON objects are unordered so nothing parses differently, but the pretty-printed text block that many hosts show the model reads differently.
The same strictness now applies to six more tools. The four trust-store and
client-identity tools, and both batch tools, previously advertised an open
object and now advertise the real union of what they return — with this same
request_info inside it. Their payloads are unchanged, but a validating client
that tolerated an extra key from them will no longer.
If you import gopher_mcp directly, request_info is no longer a dict:
| Was | Now |
|---|---|
request_info["url"], "url" in request_info, .get("url") |
unchanged, all still work |
request_info.update({...}) |
request_info.merge(RequestInfo(...)) |
request_info.keys(), dict(**request_info) |
iterate the model, or model_dump() |
request_info["key"] = value |
request_info.key = value (declared fields only) |
Destructive tools may now ask, and may refuse
Not a schema change, but it can turn a call that always succeeded into a
failure. gemini_trust_update and gemini_client_cert_update now put the
change to the user before making it, using MCP elicitation, and return the new
USER_DECLINED error code if the answer is no.
This only happens on a client that advertises the elicitation capability. A
client without it is never asked, never sees USER_DECLINED, and behaves
exactly as it did in 0.9.1. So:
- Interactive clients gain a confirmation step on the two calls that change stored state. Nothing to do.
- Automated callers on an elicitation-capable client should handle
USER_DECLINED. Treat it as an answer, not a transient failure — retrying asks again. If a workflow must not be interrupted, run it on a session that does not advertise elicitation.
A confirmation that cannot be delivered is also refused, deliberately: a client that advertised the capability and then failed to carry the question means the user was never actually asked, and these calls write and destroy private keys.
Continuation windows report their age
Reading a truncated resource with offset now downloads it once rather than
once per window — provided the response cache is enabled (it is by default;
a *_CACHE_ENABLED=false or a zero TTL turns it off) and, on Gemini, the URL
carries no query. A query-bearing Gemini request is deliberately never held: that is
where a status-10/11 answer travels, and a status-11 answer is a password.
So a Gemini search result still re-fetches for every window, and reports
cached: false, exactly as it did before. The windows after the first are rendered from the body already
in hand, so they are snapshots rather than fresh reads and now say so: cached
is true, with cached_at and cache_age_seconds naming when the bytes came
off the wire.
If you branch on cached to decide whether to re-read something, continuation
windows will now take the cached branch. refresh=true still goes to the
server, and the held body expires on the same TTL as a cached response.
Additive, nothing to do
- The batch fetch tools report progress as each URL completes, for clients that send a progress token.
v0.9.0
The largest upgrade since 0.2.0: twenty-two breaking changes, most of them in
the shape of what a tool returns rather than in what you may call. No tool was
renamed or removed and no existing argument changed meaning — the new ones are
all optional — so a client that fetches a URL and branches on kind needs no
change at all. Everything below matters to code that reads a particular
field, switches on an error code, keeps trust state on disk, or imports
gopher_mcp directly.
Result payloads
Gemtext lines are flat. A parsed line used to nest a second object beside
its own text, and every one of those objects repeated the line's raw text under
a different name; the whole page then arrived a third time in the result's
raw_content. A line now carries only what its type and content cannot
already say:
| Was | Now |
|---|---|
line["heading"]["text"], line["heading"]["level"] |
line["text"], line["level"] |
line["list_item"]["text"] |
line["text"] |
line["quote"]["text"] |
line["text"] |
line["preformat"]["alt_text"], ["language"] |
line["alt_text"], line["language"] |
line[...]["raw_content"] |
line["content"] |
result["raw_content"] |
gone — join document["lines"][*]["content"] |
line["link"] (url, text) and document["links"] are unchanged, and
content is still the line exactly as the server sent it, leading marker
included. Fields that do not apply to a line are omitted rather than sent as
null, so read them with .get(). A preformatted block's alt_text and
language sit on the opening ``` toggle only; they are no longer repeated
on every line inside the block. GemtextHeading, GemtextList, GemtextQuote
and GemtextPreformat are deleted from gopher_mcp.models.
On a 233-byte sample page this takes the tool payload from 2,325 to 1,419 JSON bytes. Context is the scarce resource for the model reading the page, which is why the duplication went rather than being kept for compatibility.
Timestamps are ISO-8601 UTC strings, not epoch seconds. cached_at on
every cacheable result changed from a float to a string like
2026-09-02T14:03:11+00:00. gemini_trust_list entries now report
first_seen, last_seen and expires the same way — matching what the
client-certificate
tools already did — plus a precomputed expired boolean, so "was this reissue
routine?" is answered by reading a field rather than by epoch arithmetic. The
tofu.json file on disk keeps its epoch format; only the wire changed.
A Gopher info line no longer carries a next_url. Servers park placeholder
values (error.host, port 1, (NULL)) in the unused host and port fields of
an i line, and those were being assembled into a URL that never pointed
anywhere — two thirds of the links in a typical menu. next_url on an info
item is now "", which means display-only, do not fetch. The item's type,
title, selector, host and port are still returned so the banner text
reads. An explicit URL:<target> selector is still honoured. Navigation code
must skip an item whose next_url is empty rather than fetching it.
Relatedly, a menu item whose type field holds a control byte (ESC, NUL) is now
reported as an info line — that was the one server-controlled field that never
passed through display sanitization — so it too arrives with an empty
next_url.
Gopher text results use LF line endings. \r\n and lone \r are
normalised to \n after sanitization and before truncation, matching the
parsed gemtext lines. Byte counts in bytes still describe the response as
served.
A Gemini failure separates the capsule's words from ours. error.message
is now written by this server ("The capsule answered status 51 (NOT FOUND) for
this request…"), and the capsule's own META string moved to error.meta,
where it is labelled untrusted. Temporary statuses (41, 42, 43, 44) also carry
error.next_step, this server's instruction for that status, mirroring what
the certificate results already did. Code that displayed error["message"] as
the capsule's explanation should read error["meta"] instead — and should not
treat it as an instruction, which is the point of the split.
Redirect results describe the target. GeminiRedirectResult gained
cross_host (true when new_url names a host other than the one requested;
null when the two could not be compared) and scheme (anything other than
gemini leaves Geminispace and cannot be fetched with gemini_fetch at all).
This server still does not follow redirects, so the five-hop limit and the
repeat-URL check are the caller's to enforce.
Tool failures set the MCP isError flag. The six single-result tools
(gopher_fetch, gemini_fetch, gemini_trust_list, gemini_trust_update,
gemini_client_cert_list, gemini_client_cert_update) now set isError on
the CallToolResult whenever the payload's kind is error, so a blocked,
DNS-failed or rejected call no longer looks like a success to a host that reads
the flag rather than the body. The body itself is byte-for-byte what it was.
The two batch tools deliberately do not set it: failure there is per item,
and there is no single flag to set honestly.
gopher_fetch and gemini_fetch advertise a real outputSchema — a
oneOf over the result models discriminated by kind, in place of the open
{"additionalProperties": true} that a dict return produced. The field names
in the schema are the snake_case ones the payload has always used (next_url,
request_info, mime_type, new_url). The camelCase spellings are still
accepted on input. One consequence worth knowing: the SDK now validates the
returned structuredContent against that schema, so a hand-built fragment that
was never a valid result (a {"kind": "text", "text": "hi"} test double, say)
is rejected where it used to pass.
Error codes
Nothing was renamed this time; the changes are new codes and one widened
meaning. Anything switching on error["code"] needs these branches:
| Code | What changed |
|---|---|
CERTIFICATE_NOT_YET_VALID |
New. A certificate whose notBefore is more than five minutes ahead of our clock is refused on first use. This case used to be reported as CERTIFICATE_EXPIRED, which inverted the diagnosis and sent the reader after a renewal that was not the problem. Unlike CERTIFICATE_EXPIRED it does not depend on GEMINI_TOFU_REJECT_EXPIRED. Under five minutes of skew is now tolerated rather than failed. |
SLOW_DOWN |
New. A host that answered 44 SLOW_DOWN is still inside its backoff. The result carries error.retry_after_seconds; nothing was sent. The client used to sleep out the wait inside the call. |
CERTIFICATE_STORE_UNAVAILABLE |
Widened. It used to mean only "the store is locked by another process". It now also covers a store that cannot be written — a read-only disk, a misdirected GEMINI_TOFU_STORAGE_PATH. That OSError previously escaped into the robots probe's transport handler and was reported as an unreachable capsule whose stated remedy was to retry, advice that could never succeed. The trust and client-certificate tools return this code too, where they used to return FETCH_ERROR. |
BLOCKED |
Widened. A non-ASCII hostname that cannot be IDNA-encoded is refused here rather than being passed through raw. |
INVALID_REQUEST |
Narrowed. A #fragment on a Gemini URL is now stripped instead of refused, so a gemtext link or redirect target this server emitted is one the tool will follow. A negative offset reaches this code only on a direct Python call: over MCP the published input schema carries minimum: 0, so the SDK refuses the arguments as a tool error (isError, no structuredContent) before the tool body runs. |
NOT_FETCHABLE |
Unchanged in meaning, but the result now echoes the request in request_info, so a batch entry for an interactive item can be matched to the URL that produced it. |
error.code on a SLOW_DOWN or a widened CERTIFICATE_STORE_UNAVAILABLE is
the whole answer: neither is worth an immediate retry, and the second is a
local fault, not the capsule's.
Where trust state lives
New installs keep TOFU pins and client identities in gopher-mcp's own data
directory instead of ~/.gemini/:
| Platform | Default location |
|---|---|
| Linux and other Unix | $XDG_DATA_HOME/gopher-mcp/ when XDG_DATA_HOME is an absolute path, otherwise ~/.local/share/gopher-mcp/ |
| macOS | ~/Library/Application Support/gopher-mcp/ |
| Windows | %LOCALAPPDATA%\gopher-mcp\ |
giving tofu.json and certs/ inside it. ~/.gemini/ belongs to Google's
Gemini CLI and holds the very settings.json a user edits to register this
server, so writing generically named state into it — and tightening its
permissions — reached into another product's configuration.
No existing install has to move anything. An existing ~/.gemini/tofu.json
or ~/.gemini/certs/ continues to be read and written in place, permanently
and with no deprecation: relocating a trust store behind the user's back would
either lose every pin or make a pinned host look unpinned, and a client
certificate's private key cannot be regenerated. GEMINI_TOFU_STORAGE_PATH and
GEMINI_CLIENT_CERTS_STORAGE_PATH override both, as before. The
Configuration Guide carries the
same rules as ongoing reference, once the upgrade is behind you.
Two further changes to trust state:
- Internationalized hostnames are pinned under one spelling. A host is now
IDNA-encoded when it is normalized, so
exämple.organdxn--exmple-cua.orgshare one pin, one client-certificate scope and one robots policy instead of each quietly getting its own — which let a link using the Unicode spelling of an already-pinned capsule take a fresh trust-on-first-use rather than raisingCERTIFICATE_CHANGED. Any store entry written under a Unicode spelling is orphaned by this and re-pinned under the A-label on the next visit. - A pin that fails to persist is no longer trusted in memory. The retry
after a
CERTIFICATE_STORE_UNAVAILABLEused to be served as "already trusted" against a pin written nowhere, which re-opened the first-use window the fail-closed error exists to deny. Failed pin changes and removals are rolled back the same way.
Python API
For code that imports gopher_mcp rather than calling the tools:
| Change | What to do |
|---|---|
GemtextHeading, GemtextList, GemtextQuote, GemtextPreformat removed from gopher_mcp.models |
Read the flat GemtextLine fields; see Result payloads above |
TOFUTrustEntry added |
The result-side projection of TOFUEntry that TOFUTrustListResult.entries now holds; stored TOFUEntry records are projected automatically |
GeminiGemtextResult.raw_content is exclude=True |
Still readable in process, no longer serialized |
TOFUNotYetValidError added (subclass of TOFUExpiredError) |
Catch it before TOFUExpiredError, or a not-yet-valid certificate reports as expired |
ClientCertificateStorageError added (subclass of ClientCertificateError) |
An unwritable certificate store raises this rather than a bare OSError |
RateLimited added; RateLimiter takes max_wait_seconds |
Past that wait acquire raises instead of sleeping |
helpers.describe_oserror added |
Use it instead of str(exc) on a connect failure: asyncio puts the resolved IP address inside OSError.strerror, and that address must not reach the caller |
Dependency floors moved, which matters to anything resolving alongside this
package: mcp>=1.28.1,<2 (was >=1.10.0), cryptography>=50.0.0 (was
>=43.0.0), pydantic>=2.11.0 (was >=2.5.0). anyio is gone as a declared
runtime dependency and uvicorn>=0.31.1 is now a declared one. The mcp floor
is a security floor: below 1.28.1 the SDK left DNS-rebinding protection off by
default, and that is the protection the HTTP transports rely on.
Server, CLI and transports
All additive — nothing here breaks an existing invocation:
gopher_fetchtakessearch, which percent-encodes the user's terms into a type-7 query. A query written into the URL by hand loses everything after a#and turns a literal+into a space, so the server answers a search that was never asked. The URL form still works, since menunext_urlvalues carry one.gopher_fetchandgemini_fetchtakeoffset, and truncated results carrynext_offsetplustotal_items(menus) ortotal_chars(bodies), so a result cut at the render limit can be read to the end. The batch tools deliberately do not takeoffset: one offset cannot mean anything across a list of URLs.gopher_batch_fetchandgemini_batch_fetchtakerefresh, which they previously accepted at the protocol level and silently discarded.- The server registers its first resource,
gopher-mcp://policy, which renders the effective fetch policy for both protocols, and two prompts,explore_capsule(url)andsummarize_gemlog(url, posts). - The HTTP transports answer
GET /healthwith{"status": "ok", "version": …}. It bypasses authorization by SDK design and exposes no configuration. gopher-mcp --versionexists, and--allowed-host HOST[:PORT](repeatable) is new. A non-loopback--hostnow turns FastMCP's DNS-rebinding Host check off, matching what the SDK does when it is constructed with such a host;--allowed-hostkeeps the check on for a named proxy or container hostname. Without either, a request arriving under an unexpectedHostwas refused with a bare421 Misdirected Request.- An invalid environment value now fails at startup with one line naming the variable, instead of a pydantic dump.
- Under
sseandstreamable-http, uvicorn's startup and access logs now go through this project's logging pipeline, so they honourGOPHER_MCP_LOG_LEVEL, reachGOPHER_MCP_LOG_FILE_PATH, and never land on stdout.
Project tooling
Only for contributors and anyone building from a checkout:
task.pyis gone.uv run task <cmd>is the way to run a project task, andmake <cmd>delegates straight to it.python task.py <cmd>no longer exists.makewith no target now prints the task list. The two task tables had already drifted once, and nothing compared them.- The
dev,docsandtestextras are now PEP 735 dependency groups. The wheel carries noProvides-Extraat all, sopip install gopher-mcp[dev]installs the base package and no tooling — it does not fail, it quietly gives you nothing.uv sync --all-extrasis the same trap: it succeeds and installs no tooling whatsoever, so a script left on the old flag goes green while checking nothing. Useuv sync --all-groups, oruv sync --no-default-groups --group docsfor one group. - The package now ships the
py.typedmarker itsTyping :: Typedclassifier has promised since 0.1.0, somypysees its annotations from an installed copy. - Python 3.14 is covered by CI and advertised in the trove classifiers.
Error-code changes in 0.8.0
Four error codes changed meaning. If you switch on error["code"], these are
the branches to update.
| Was | Now | Why |
|---|---|---|
BLOCKED_BY_ROBOTS for an unretrievable robots.txt |
ROBOTS_UNAVAILABLE |
A capsule that never answered has not disallowed anything. Match both codes wherever you previously matched BLOCKED_BY_ROBOTS, and retry only ROBOTS_UNAVAILABLE: a Disallow is permanent, an unreachable policy is transient. Gopher fails open and so does not currently emit the new code. |
BLOCKED for a hostname that will not resolve |
DNS_ERROR |
BLOCKED means the SSRF guard refused the target. A name that does not resolve was never refused — there was no address to evaluate — so a typo was being reported as a security block. HostResolutionError still subclasses SSRFError, so a handler catching the base is unaffected. |
TLS_ERROR for an oversize response or a refused, unreachable or reset connection |
FETCH_ERROR |
Neither is a handshake fault. TLS_ERROR now means an actual ssl.SSLError. The oversize message names the cap instead of discarding it. |
INVALID_REQUEST for a 3x redirect whose target will not parse |
INVALID_REDIRECT |
The caller's URL was never wrong; the capsule's redirect target was. |
Also in 0.8.0: GOPHER_ROBOTS_FAILURE_BACKOFF_SECONDS and
GEMINI_ROBOTS_FAILURE_BACKOFF_SECONDS (default 60, range 0–3600) make
the robots.txt failure backoff configurable; 0 restores the pre-0.7.0
behaviour of re-probing on the very next request.
Changed defaults in 0.7.0
robots.txt is honoured by default on both protocols.
GOPHER_RESPECT_ROBOTS_TXT and GEMINI_RESPECT_ROBOTS_TXT both default to
true; set either to false to restore the previous behaviour. The policy is
cached per host for 24 hours, so the cost is one probe per host rather than one
per fetch — and defaulting to ignoring an operator's stated policy is not a
reasonable default for a tool an LLM drives unattended.
Two consequences to plan for:
- A fetch that previously succeeded can now be refused, if the host
disallows it. Geminispace search is the case most people hit:
kennedy.gemi.devandtlgs.onebothDisallow: /search. - Gemini fails closed. Per RFC 9309 §2.3.1.4, a capsule whose
robots.txtcannot be retrieved at all — including during a plain network or TLS outage — is refused rather than fetched. In 0.7.0 that was reported asBLOCKED_BY_ROBOTS; 0.8.0 split it out asROBOTS_UNAVAILABLE(above). Gopher fails open, since it has no status codes to distinguish an absent policy from an unreachable one.
A User-agent: gopher-mcp group is honoured by name on both protocols, so an
operator can exclude this tool specifically without excluding anything else.
Public API changes in 0.6.0
These affect code that imports from gopher_mcp directly. Nothing in this
section changes the MCP tool surface, so MCP clients are unaffected.
Removed as unused
Importing any of these now raises ImportError (or AttributeError for the
attributes and methods):
| Removed | Was in |
|---|---|
guess_mime_type |
gopher_mcp.utils |
format_gopher_url |
gopher_mcp.utils |
validate_gemini_url_components |
gopher_mcp.utils |
sanitize_selector |
gopher_mcp.utils |
TOFUManager.cleanup_expired |
gopher_mcp.tofu |
ClientCertificateManager.cleanup_expired |
gopher_mcp.client_certs |
GeminiMimeType.is_image / .is_audio / .is_video / .is_application |
gopher_mcp.models |
GeminiMimeType.supports_charset() / .get_file_extension() |
gopher_mcp.models |
GemtextLink.is_external |
gopher_mcp.models |
GemtextDocument.link_count / .has_headings / .line_count |
gopher_mcp.models |
GemtextDocument.content_summary / .heading_hierarchy / .text_content |
gopher_mcp.models |
the allowed_hosts keyword of validate_target |
gopher_mcp.ssrf |
The gopher_mcp.models entries were all computed properties and methods over
data the model already carries. None of them was ever included in
model_dump(), so MCP tool output was byte-for-byte unaffected — this
broke an embedder that reads doc.text_content or mime.get_file_extension(),
not a tool user. Recompute what you need from the model's own fields.
validate_target(..., allowed_hosts=...) now raises TypeError. The clients
apply their own host allowlist in _validate_security against a set normalized
once at construction, so the parameter was a second, redundant copy of that
check; GopherClient/GeminiClient and the *_ALLOWED_HOSTS settings behave
as before.
Changed defaults in 0.6.0
Two settings that shipped in 0.4.0 defaulting to off are now on. No configuration file changes, but throughput does:
| Setting | Was | Now |
|---|---|---|
GOPHER_REQUESTS_PER_MINUTE / GEMINI_REQUESTS_PER_MINUTE |
0 (unlimited) |
60 (one request per second, per host) |
GOPHER_MAX_CONCURRENT_REQUESTS / GEMINI_MAX_CONCURRENT_REQUESTS |
0 (unlimited) |
5 |
Requests to one host are now paced, so a batch aimed at a single server is
spaced out rather than parallel. Set all four to 0 to restore the 0.5.x
behaviour.
Separately, an explicitly empty host allowlist flipped meaning:
GopherClient(allowed_hosts=[]) and GeminiClient(allowed_hosts=[]) used to
mean allow-all and now deny every host. Pass None (the default) for "no
restriction". The equivalent misconfiguration via GOPHER_ALLOWED_HOSTS /
GEMINI_ALLOWED_HOSTS is now a startup error.
Added to gopher_mcp.utils
| Added | Purpose |
|---|---|
sanitize_display_text |
Strip dangerous invisible characters from server-controlled text before returning it |
resolve_gemini_reference |
Resolve a gemtext link or redirect target against the URL it was fetched from |
gopher_mcp.utils is now purely a backward-compatibility facade for external
importers: no module inside the package imports it any more, and a test
enforces that.
GeminiErrorResult is now ErrorResult
The two error models were merged. gopher_mcp.models.GeminiErrorResult is an
alias for ErrorResult, so isinstance(x, ErrorResult) is now true for a Gemini
error and both spellings import fine. The merged model's error field is
dict[str, Any] rather than the old Gopher-only dict[str, str] — which is what
lets a Gemini failure carry the numeric status and boolean temporary
alongside code and message. Code that assumed dict[str, str] should read
error["code"] and use error.get("status") / error.get("temporary").
New tools and result fields
- Two tools were added:
gemini_trust_listandgemini_trust_update, with the result modelsTOFUTrustListResultandTOFUTrustUpdateResult. - Two more were added for client identities:
gemini_client_cert_listandgemini_client_cert_update, with the result modelsGeminiClientCertListResultandGeminiClientCertUpdateResult. They make a status-60 (certificate required) capsule reachable, which it previously was not — deliberately, only on an explicit call, never automatically on a status-60 response. - Cacheable result models grew
cached,cached_atandcache_age_seconds;gopher_fetchandgemini_fetchgrew an optionalrefreshargument, andGopherClient.fetch/GeminiClient.fetchgrew a keyword-onlyrefresh. Both are additive. GeminiCertificateResultgrewnext_step: this server's own instruction for that status (60, 61 and 62 need different answers), beside the capsule's untrustedmessage.
Client-certificate store changes
Embedders driving ClientCertificateManager directly should know three things:
- Certificate and key files are now named after a random per-certificate
key_id, recorded inregistry.json, instead of the certificate's common name — two identities on one host could share that name and so share one key pair. Existing entries have nokey_idand keep resolving to their common-name filenames, so no store needs migrating. generate_certificateandremove_certificateroll back their in-memory change if the registry cannot be persisted, so a raised error now always means the store is unchanged.remove_certificateraisesClientCertificateKeyRetainedError(aClientCertificateError) when the registry entry was removed but the private key file survived its unlink, rather than returning True as though the key had been destroyed.ClientCertificateManager.get_certificate_info_for_scopeis new: the same resolutionget_certificate_for_scopeperforms, returning the registry entry instead of file paths.
The v0.2.0 Gemini addition
v0.2.0 added comprehensive Gemini protocol support alongside the existing Gopher functionality:
- Gemini Protocol Support: Full implementation of Gemini v0.24.1
gemini_fetchTool: New MCP tool for Gemini protocol access- TLS Security: Mandatory TLS with TOFU certificate validation
- Client Certificates: Scoped storage, attached automatically when present
- Gemtext Parser: Native gemtext parsing with structured output
- Dual Caching: Separate cache systems for each protocol
Adding Gemini alongside Gopher changed nothing that already worked:
- ✅ All existing
gopher_fetchfunctionality preserved - ✅ Existing configuration variables unchanged
- ✅ Existing scripts and integrations continue to work
That statement is scoped to the v0.2.0 addition and is not a standing guarantee about every later release — see the sections above for the ones that do break things.
The rest of this page is the step-by-step for that upgrade: coming from a Gopher-only install, this is what to do.
Migration Steps
1. Update Dependencies
If you're using pip:
If you're using uv:
2. Review New Configuration Options
The server now supports additional environment variables for Gemini:
# Optional Gemini configuration (all have sensible defaults)
GEMINI_MAX_RESPONSE_SIZE=1048576
GEMINI_TIMEOUT_SECONDS=30
GEMINI_CACHE_ENABLED=true
GEMINI_CACHE_TTL_SECONDS=300
GEMINI_MAX_CACHE_ENTRIES=1000
GEMINI_ALLOWED_HOSTS=
GEMINI_TOFU_ENABLED=true
GEMINI_CLIENT_CERTS_ENABLED=true
Important: You don't need to set these variables. The server will use sensible defaults.
3. Test Existing Functionality
Verify your existing Gopher functionality still works:
# This should work exactly as before
result = await gopher_fetch("gopher://gopher.floodgap.com/1/")
print(result["kind"]) # Should be "menu" or "text"
4. Try New Gemini Features
Test the new Gemini functionality:
# New Gemini support
result = await gemini_fetch("gemini://geminiprotocol.net/")
# One of "gemtext", "success", "binary", "input", "redirect", "certificate"
# or "error".
print(result["kind"])
Configuration Migration
Existing Configuration
Your existing configuration continues to work unchanged:
# These variables work exactly as before
GOPHER_MAX_RESPONSE_SIZE=1048576
GOPHER_TIMEOUT_SECONDS=30
GOPHER_CACHE_ENABLED=true
GOPHER_CACHE_TTL_SECONDS=300
GOPHER_MAX_CACHE_ENTRIES=1000
GOPHER_ALLOWED_HOSTS=gopher.floodgap.com,gopher.quux.org
New Optional Configuration
You can optionally add Gemini configuration:
# Add these if you want to customize Gemini behavior
GEMINI_ALLOWED_HOSTS=geminiprotocol.net,skyjake.fi
GEMINI_TIMEOUT_SECONDS=60
GEMINI_CACHE_TTL_SECONDS=600
Configuration Validation
Use the new validation script to check your configuration:
Feature Comparison
| Feature | Gopher | Gemini | Notes |
|---|---|---|---|
| Protocol | Plain text | TLS encrypted | Gemini requires TLS |
| Content Format | Plain text/binary | Gemtext/binary | Gemini has rich text format |
| Caching | ✅ | ✅ | Separate cache systems |
| Host Allowlists | ✅ | ✅ | Independent configuration |
| Timeout Configuration | ✅ | ✅ | Independent settings |
| Certificate Validation | N/A | ✅ TOFU | Gemini-specific security; inspect and recover with gemini_trust_list / gemini_trust_update |
| Client Certificates | N/A | ✅ Scoped storage | Attached automatically when one exists; create or remove one deliberately with gemini_client_cert_update |
Common Migration Scenarios
Scenario 1: Basic User (No Custom Configuration)
Before: Using default Gopher settings After: Everything works the same, plus Gemini is available
Action Required: None! Just update and start using gemini_fetch when needed.
Scenario 2: Custom Gopher Configuration
Before: Custom timeout, cache, or host allowlist settings After: Gopher settings unchanged, can optionally configure Gemini
Action Required:
- Keep existing configuration
- Optionally add Gemini-specific settings if desired
Scenario 3: Security-Conscious User
Before: Using Gopher host allowlists for security After: Same Gopher security, plus enhanced Gemini security
Recommended Actions:
# Keep existing Gopher allowlist
GOPHER_ALLOWED_HOSTS=trusted-gopher-hosts.com
# Add Gemini allowlist for consistency
GEMINI_ALLOWED_HOSTS=trusted-gemini-hosts.org
# Ensure TOFU is enabled (default)
GEMINI_TOFU_ENABLED=true
Scenario 4: High-Performance User
Before: Optimized cache settings for Gopher After: Can optimize both protocols independently
Recommended Actions:
# Keep existing Gopher optimization
GOPHER_CACHE_TTL_SECONDS=1800
GOPHER_MAX_CACHE_ENTRIES=5000
# Add similar Gemini optimization
GEMINI_CACHE_TTL_SECONDS=1800
GEMINI_MAX_CACHE_ENTRIES=5000
Troubleshooting Migration Issues
Issue: "Module not found" errors
Cause: Incomplete installation or environment issues Solution:
# Reinstall completely
pip uninstall gopher-mcp
pip install gopher-mcp
# Or with uv
uv sync --reinstall
Issue: Configuration validation errors
Cause: Invalid configuration values Solution:
# Run validation to see specific issues
python scripts/validate-config.py
# Reset problematic variables to defaults
unset PROBLEMATIC_VARIABLE
Issue: Gemini connections fail
Cause: Network or TLS configuration issues Solution:
# Test with relaxed security (development only)
export GEMINI_TOFU_ENABLED=false
# Check network connectivity
ping geminiprotocol.net
Issue: Certificate storage errors
Cause: The trust store or certificate store cannot be locked or written —
a read-only home directory, a container filesystem, a misdirected
GEMINI_TOFU_STORAGE_PATH, or no disk space. This is reported as
CERTIFICATE_STORE_UNAVAILABLE, and it is a local fault, not the capsule's, so
retrying does not help.
Solution: find out which directory is in use and fix its permissions. New
installs store state under $XDG_DATA_HOME/gopher-mcp/ (or
~/Library/Application Support/gopher-mcp/ on macOS,
%LOCALAPPDATA%\gopher-mcp\ on Windows); an install that already had
~/.gemini/tofu.json or ~/.gemini/certs/ keeps using that. See
Where trust state lives.
# Linux/BSD default, or wherever GEMINI_TOFU_STORAGE_PATH points
STATE="${XDG_DATA_HOME:-$HOME/.local/share}/gopher-mcp"
mkdir -p "$STATE"
chmod 700 "$STATE"
df -h "$STATE"
The server never returns the store path in an error — it is logged, not reported — so read the log rather than the tool result to find out which path it tried.
Best Practices for Migration
1. Gradual Adoption
- Start with existing Gopher functionality
- Gradually introduce Gemini features
- Test both protocols in development first
2. Configuration Management
- Use the provided
config/example.envas a template - Validate configuration before deployment
- Document any custom settings
3. Security Considerations
- Enable TOFU for Gemini in production
- Use host allowlists for both protocols
- Monitor certificate validation logs
4. Performance Optimization
- Monitor cache hit rates for both protocols
- Adjust cache settings based on usage patterns
- Consider separate timeout values for each protocol
Testing Your Migration
1. Functional Testing
# Test Gopher functionality
python -c "
import asyncio
from gopher_mcp.server import gopher_fetch
async def test():
result = await gopher_fetch('gopher://gopher.floodgap.com/1/')
print(f'Gopher test: {result[\"kind\"]}')
asyncio.run(test())
"
# Test Gemini functionality
python -c "
import asyncio
from gopher_mcp.server import gemini_fetch
async def test():
result = await gemini_fetch('gemini://geminiprotocol.net/')
print(f'Gemini test: {result[\"kind\"]}')
asyncio.run(test())
"
2. Configuration Testing
# Validate all configuration
python scripts/validate-config.py
# Test with your specific configuration
export YOUR_CONFIG_VARS=values
python scripts/validate-config.py
3. Integration Testing
# Run the full test suite
python -m pytest tests/ -v
# Run only integration tests
python -m pytest tests/test_server.py -v
Getting Help
If you encounter issues during migration:
- Check the logs with debug logging enabled:
- Validate your configuration:
-
Review the troubleshooting guide: See
docs/gemini-troubleshooting.md -
Test with minimal configuration: Remove all custom environment variables and test with defaults
-
Check GitHub issues: Look for similar migration issues
-
Create a new issue: Include your configuration and error details
Summary
The v0.2.0 step covered above — Gopher-only to dual-protocol — was seamless:
- ✅ Zero breaking changes to what already worked
- ✅ Optional new features available when needed
- ✅ Independent configuration for each protocol
That is a statement about v0.2.0 and nothing later. Releases since have broken things deliberately, each for a stated reason:
- 0.6.0 removed public Python API and turned rate limiting on.
- 0.7.0 made
robots.txtbinding by default, so a fetch that used to succeed can now be refused. - 0.8.0 renamed or split four error codes.
- 0.9.0 reshapes gemtext lines, the Gemini error payload and result
timestamps, empties an info line's
next_url, moves the default trust store, and dropstask.pyand the published extras.
If you switch on error["code"], read a named result field, or drive the
Python API, work back through the version sections above from the release you
are moving to.