Data Models
Authoritative, auto-generated reference for the Pydantic models that define tool
inputs and outputs. These are generated directly from
gopher_mcp.models,
so they never drift from the code. For usage examples and error-handling
recipes, see the API Reference.
Conventions
Three rules run through every result model, and knowing them saves reading each one in turn.
Every result carries a kind. It is the discriminator of a tagged union, so
branch on it rather than probing for fields. gopher_fetch returns one of
menu, text, binary or error; gemini_fetch returns one of gemtext,
success, binary, input, redirect, certificate or error. Both tools
advertise that union as a real outputSchema (a oneOf keyed on kind)
through the RootModel wrappers documented under
Tool Output Wrappers.
Every instant is an ISO-8601 UTC string, never epoch seconds — cached_at,
the trust store's first_seen/last_seen/expires, and the timestamp inside
every result's request_info. Times the server computes with rather than
reports stay floats (cache TTLs, cache_age_seconds, the rate limiter, the
on-disk tofu.json format); only the wire changed.
gopher_mcp.models.iso_utc
Render a UNIX timestamp the way tool results report instants.
Results speak ISO-8601 UTC (2026-09-02T12:00:00+00:00) rather than epoch
seconds: the client-certificate tools already report validity windows that
way, so an epoch float elsewhere made the identical expires concept arrive
in two incompatible formats, and left a model doing arithmetic to answer
"when was this pinned" or "has this expired". Sub-second precision is
dropped -- it is noise to every reader of a payload.
EVERY instant a result reports goes through here -- cached_at, the trust
store's first_seen/last_seen/expires, and the timestamp in
each result's request_info -- so one payload can never carry two
spellings of the same concept. Times that are computed with rather than
reported stay floats: cache entry timestamps and cache_age_seconds, the
rate limiter and robots clocks, the deadline and budget arithmetic, and the
on-disk tofu.json epoch format (docs/architecture.md). Only the wire
changes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timestamp
|
float | None
|
UNIX timestamp in seconds, or None. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The ISO-8601 UTC rendering, or None when |
Source code in src/gopher_mcp/models.py
Sizes are bytes and offsets are characters. They are different units and are never interchangeable — see Reading a truncated result.
Request Models
gopher_mcp.models.GopherFetchRequest
Bases: BaseModel
Request model for gopher.fetch tool.
url
class-attribute
instance-attribute
url: str = Field(
...,
description="Gopher URL to fetch (e.g., gopher://gopher.floodgap.com/1/)",
examples=[
"gopher://gopher.floodgap.com/1/",
"gopher://gopher.floodgap.com/0/about.txt",
],
)
validate_gopher_url
classmethod
Validate that the URL is a proper Gopher URL.
Source code in src/gopher_mcp/models.py
gopher_mcp.models.GeminiFetchRequest
Bases: BaseModel
Request model for gemini_fetch tool.
url
class-attribute
instance-attribute
url: str = Field(
...,
description="Gemini URL to fetch (e.g., gemini://geminiprotocol.net/)",
examples=[
"gemini://geminiprotocol.net/",
"gemini://skyjake.fi/",
],
)
validate_gemini_url
classmethod
Validate that the URL is a proper Gemini URL.
Source code in src/gopher_mcp/models.py
Tool Output Wrappers
These exist so gopher_fetch and gemini_fetch can declare a real
outputSchema without changing the payload. A RootModel is a BaseModel, so
the MCP SDK uses it unwrapped: the result keeps the exact shape it has always
had, while the advertised schema becomes a oneOf over the result kinds instead
of an open {"additionalProperties": true} object.
gopher_mcp.models.GopherFetchOutput
gopher_mcp.models.GeminiFetchOutput
Gopher Result Models
gopher_mcp.models.GopherMenuItem
Bases: BaseModel
Model for a single Gopher menu item.
type
class-attribute
instance-attribute
title
class-attribute
instance-attribute
selector
class-attribute
instance-attribute
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
gopher_mcp.models.MenuResult
Bases: BaseModel
Result model for Gopher menu responses.
items
class-attribute
instance-attribute
items: list[GopherMenuItem] = Field(
..., description="List of menu items"
)
gopher_mcp.models.TextResult
Bases: BaseModel
Result model for Gopher text responses.
charset
class-attribute
instance-attribute
bytes
class-attribute
instance-attribute
gopher_mcp.models.BinaryResult
Bases: BaseModel
Result model for Gopher binary responses.
bytes
class-attribute
instance-attribute
mime_type
class-attribute
instance-attribute
mime_type: str | None = Field(
None,
validation_alias=AliasChoices("mime_type", "mimeType"),
serialization_alias="mime_type",
description="Guessed MIME type",
)
gopher_mcp.models.ErrorResult
Bases: BaseModel
Result model for error responses, shared by both protocols.
error always carries code and message; a Gemini failure adds the
numeric status and a boolean temporary saying whether retrying may help.
What error holds
error is an open dict because the two protocols report different things
in it. Both always carry code and message, where message is written by
this server. A Gemini 4x/5x failure adds the numeric status, the boolean
temporary, and meta — the capsule's own text, which is untrusted and is
deliberately kept out of message so a hostile capsule cannot have a
kilobyte of its own prose read as this server's guidance. A temporary
failure also carries next_step, which this server writes. A host that is
still serving out a status-44 backoff answers with code SLOW_DOWN and a
retry_after_seconds float rather than sleeping inside the tool call.
Reading a truncated result
truncated: true means there is more after this window, not content was
discarded. The result that carries it also carries next_offset: pass it back
as the fetch tool's offset argument to read the next window, and keep going
until next_offset is null.
| Field | On | Counts |
|---|---|---|
total_items, next_offset |
MenuResult |
menu items |
total_chars, next_offset |
TextResult, GeminiSuccessResult, GeminiGemtextResult |
characters |
A total is null when it was not counted — a directory larger than the render cap
does not get walked twice to total it. For gemtext, next_offset lands on the
last complete line, so consecutive windows abut exactly. Neither batch tool
accepts offset; continue a truncated batch item with the single-URL tool.
bytes (Gopher) and size (Gemini) are byte counts of the whole original
resource and are never offsets — a byte offset cannot be expressed without the
risk of splitting a UTF-8 sequence.
Gemini Result Models
Gemini results name the content length size where Gopher results name it
bytes. Same concept, two wire names, kept distinct because both are published
tool output that renaming would break for every existing consumer. Code that
needs it protocol-agnostically goes through FetchClientBase._response_size,
which is the single place that knows which protocol says which.
gopher_mcp.models.GeminiSuccessResult
Bases: BaseModel
Result model for a successful Gemini response carrying TEXT content.
Binary success responses use :class:GeminiBinaryResult (metadata only), so
content is always decoded text here.
mime_type
class-attribute
instance-attribute
mime_type: GeminiMimeType = Field(
...,
validation_alias=AliasChoices("mime_type", "mimeType"),
serialization_alias="mime_type",
description="Content MIME type",
)
content
class-attribute
instance-attribute
size
class-attribute
instance-attribute
gopher_mcp.models.GeminiBinaryResult
Bases: BaseModel
Result model for a successful BINARY Gemini response (metadata only).
Mirrors the Gopher :class:BinaryResult: the raw bytes are NOT returned to
the model. A 1 MB body is ~1.4M base64 characters (~350k tokens), so
inlining it would flood the context for content the model can't render
anyway. The consumer gets the size and detected MIME type and can fetch the
resource directly if it genuinely needs the bytes.
mime_type
class-attribute
instance-attribute
mime_type: GeminiMimeType = Field(
...,
validation_alias=AliasChoices("mime_type", "mimeType"),
serialization_alias="mime_type",
description="Detected content MIME type",
)
size
class-attribute
instance-attribute
gopher_mcp.models.GeminiGemtextResult
Bases: BaseModel
Result model for gemtext content responses.
document
class-attribute
instance-attribute
document: GemtextDocument = Field(
..., description="Parsed gemtext document"
)
raw_content
class-attribute
instance-attribute
raw_content: str = Field(
default="",
validation_alias=AliasChoices(
"raw_content", "rawContent"
),
serialization_alias="raw_content",
exclude=True,
description="Raw gemtext content (server-side only; see `document`)",
)
charset
class-attribute
instance-attribute
truncated
class-attribute
instance-attribute
truncated: bool = Field(
default=False,
description="True if the page continues after this window. `next_offset` is where it continues -- at the last complete line, so windows abut exactly; `size` still reports the full original byte size (bytes are not the unit an offset counts in).",
)
partial_line
class-attribute
instance-attribute
partial_line: bool = Field(
default=False,
validation_alias=AliasChoices(
"partial_line", "partialLine"
),
serialization_alias="partial_line",
description="True when this window both begins and ends inside a single line that is longer than the render limit. That line is delivered as a plain `text` line here and continues in the next window, so join it to the next window's first line rather than reading the two as separate lines. It is deliberately not parsed: half of a `=> url text` line would otherwise look like a complete link to a target the server never sent",
)
gopher_mcp.models.GeminiInputResult
Bases: BaseModel
Result model for input request responses (status 10/11).
sensitive
class-attribute
instance-attribute
gopher_mcp.models.GeminiRedirectResult
Bases: BaseModel
Result model for redirect responses (status 30/31).
This server does not follow redirects: the caller does, by fetching
new_url. So the payload has to carry what a caller needs to decide
whether following is safe -- the Gemini spec's five-hop limit is only
enforceable by whoever is counting the hops, and a target on another host
or in another scheme is the one worth stopping on.
new_url
class-attribute
instance-attribute
new_url: str = Field(
...,
validation_alias=AliasChoices("new_url", "newUrl"),
serialization_alias="new_url",
description="Redirect target URL. Follow at most five in a row, and stop if a URL you have already visited comes back: a capsule can otherwise spin a client through an unbounded chain of fetches",
)
permanent
class-attribute
instance-attribute
cross_host
class-attribute
instance-attribute
cross_host: bool | None = Field(
default=None,
description="True when `new_url` names a host other than the one that was requested, so the content it serves is a different party's. Null when the target could not be compared with the request",
)
scheme
class-attribute
instance-attribute
scheme: str | None = Field(
default=None,
description="Scheme of `new_url`. Anything other than `gemini` leaves Geminispace and cannot be fetched with this tool. Null when the target names no scheme and the request's is unknown",
)
describe_target
describe_target() -> GeminiRedirectResult
Fill scheme and cross_host from the target and the request.
Derived here rather than at the call site so every redirect result carries them, whichever code path built it.
Source code in src/gopher_mcp/models.py
gopher_mcp.models.GeminiCertificateResult
Bases: BaseModel
Result model for certificate request responses (status 60-62).
message is the capsule's own text and is untrusted; next_step is
written by this server and is the only instruction in the payload.
message
class-attribute
instance-attribute
status
class-attribute
instance-attribute
status: int = Field(
default=60,
ge=60,
le=69,
description="Gemini certificate status code: 60 required, 61 not authorized, 62 not valid",
)
required
class-attribute
instance-attribute
required: bool = Field(
default=True,
description="Whether the server is prompting for a certificate (status 60). False for 61/62, which are rejections of a presented identity.",
)
GeminiErrorResult is ErrorResult
There is no separate Gemini error model: GeminiErrorResult is an alias for the ErrorResult documented under Gopher Result Models above, which both protocols return. Its error field is dict[str, Any] precisely so a Gemini failure can carry the numeric status, the boolean temporary and the capsule's own meta beside the code and message; a Gopher failure omits those keys.
Trust-Store Tool Results
Returned by the gemini_trust_list and gemini_trust_update tools. See the
API Reference for the recovery
procedure they support. gemini_trust_list reports
TOFUTrustEntry, a projection of the stored
TOFUEntry: the store keeps epoch seconds,
but a tool whose whole job is explaining a CERTIFICATE_CHANGED failure has to
be readable without arithmetic, so the projection renders the three timestamps
as ISO-8601 UTC and precomputes expired.
gopher_mcp.models.TOFUTrustEntry
Bases: BaseModel
A pinned certificate as reported by gemini_trust_list.
A result-side projection of :class:TOFUEntry, following the
:class:GeminiClientCertificateEntry precedent: the store keeps epoch
seconds, but the tool that explains a CERTIFICATE_CHANGED failure has to be
read by a model, and epoch floats made it answer "was this reissue routine?"
by arithmetic -- while the client-certificate tools reported the very same
expires concept as an ISO-8601 string, so the two disagreed about what a
timestamp looks like. expired is precomputed for the same reason.
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
fingerprint
class-attribute
instance-attribute
fingerprint: str = Field(
...,
description="SHA-256 fingerprint of the pinned certificate. This is the value gemini_trust_update requires before it will drop the pin",
)
first_seen
class-attribute
instance-attribute
first_seen: str = Field(
...,
description="ISO-8601 UTC time this certificate was first seen, i.e. when the pin was established",
)
last_seen
class-attribute
instance-attribute
expires
class-attribute
instance-attribute
expires: str | None = Field(
None,
description="ISO-8601 UTC end of the certificate's validity window. Null when the certificate carries no expiry",
)
expired
class-attribute
instance-attribute
expired: bool = Field(
default=False,
description="True if the validity window has ended, which makes a reissue -- and so a changed fingerprint -- the likely explanation",
)
from_entry
classmethod
from_entry(
entry: TOFUEntry, now: float | None = None
) -> TOFUTrustEntry
Project a stored :class:TOFUEntry onto the reported shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
entry
|
TOFUEntry
|
The stored trust-store record. |
required |
now
|
float | None
|
UNIX timestamp to judge expiry against (default: now). |
None
|
Returns:
| Type | Description |
|---|---|
TOFUTrustEntry
|
The entry as |
Source code in src/gopher_mcp/models.py
gopher_mcp.models.TOFUTrustListResult
Bases: BaseModel
Result model for a read-only inspection of the TOFU trust store.
Only the entries the caller asked about are returned. The store's own filesystem path is deliberately absent: it is operator configuration that belongs in the server log, not in a payload handed to a model.
entries
class-attribute
instance-attribute
entries: list[TOFUTrustEntry] = Field(
...,
description="Pinned certificates matching the request, ordered by host",
)
project_stored_entries
classmethod
Accept stored :class:TOFUEntry records and project them.
The projection happens here rather than at the call site so the store's epoch timestamps cannot reach the wire by anyone assembling this result from what the trust manager hands back.
Source code in src/gopher_mcp/models.py
gopher_mcp.models.TOFUTrustUpdateResult
Bases: BaseModel
Result model for a change to the TOFU trust store.
Reports only the host the caller named, so a modification can never become a way to enumerate the rest of the store.
action
class-attribute
instance-attribute
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
changed
class-attribute
instance-attribute
changed: bool = Field(
...,
description="True if the trust store was actually modified. False means there was nothing to change (e.g. the host had no pin to remove)",
)
message
class-attribute
instance-attribute
Client-Certificate Tool Results
Returned by the gemini_client_cert_list and gemini_client_cert_update tools.
See the API Reference for the
status-60 procedure they support. None of these models carries key material or
the certificate store's filesystem path. GeminiClientCertificateEntry is a
projection of the stored GeminiCertificateInfo (under
Caching and Security Models): it adds the scope
as a ready-to-use URL and the expiry resolved against the current time, and
leaves out the certificate's subject and issuer, which this server generated and
under which it stores the key pair.
gopher_mcp.models.GeminiClientCertificateEntry
Bases: BaseModel
A stored client certificate as reported by gemini_client_cert_list.
A deliberate projection of :class:GeminiCertificateInfo rather than a
subclass of it: only what a model needs to act on an identity is reported.
The certificate's own subject and issuer are left out because for a
self-signed identity this server minted they say nothing about the capsule,
and the subject doubles as the local name of the key pair on disk -- which
the parent's rule keeps out of every tool result. The scope URL is carried
ready-made so acting on an entry never means reassembling one.
url
class-attribute
instance-attribute
url: str = Field(
...,
description="The scope URL this identity covers. Pass it verbatim as gemini_client_cert_update's `url` to act on this entry",
)
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
path
class-attribute
instance-attribute
path: str = Field(
default="/",
description="Path scope. The identity is sent for this path and every path below it",
)
fingerprint
class-attribute
instance-attribute
fingerprint: str = Field(
...,
description="SHA-256 fingerprint of the certificate. This is the value gemini_client_cert_update requires before it will destroy it",
)
not_before
class-attribute
instance-attribute
not_after
class-attribute
instance-attribute
gopher_mcp.models.GeminiClientCertListResult
Bases: BaseModel
Result model for a read-only inspection of the client certificate store.
Only the certificates the caller asked about are returned, and each is
reported through :class:GeminiClientCertificateEntry, which carries no
private key and no path to one.
entries
class-attribute
instance-attribute
entries: list[GeminiClientCertificateEntry] = Field(
...,
description="Stored client certificates matching the request, ordered by host, port and path scope",
)
gopher_mcp.models.GeminiClientCertUpdateResult
Bases: BaseModel
Result model for a change to the client certificate store.
Reports only the scope the caller named, so creating or removing an identity can never become a way to enumerate the others.
action
class-attribute
instance-attribute
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
path
class-attribute
instance-attribute
path: str = Field(
...,
description="Path scope acted on. The certificate applies to this path and every path below it",
)
fingerprint
class-attribute
instance-attribute
fingerprint: str | None = Field(
None,
description="SHA-256 fingerprint of the certificate created or removed. Null when nothing changed",
)
expires
class-attribute
instance-attribute
expires: str | None = Field(
None,
description="End of the created certificate's validity window. Null on removal and when nothing changed",
)
changed
class-attribute
instance-attribute
changed: bool = Field(
...,
description="True if the certificate store was actually modified. False means there was nothing to change (e.g. no certificate covered the scope named for removal)",
)
message
class-attribute
instance-attribute
Gemtext Document Models
One parsed line is one GemtextLine. There are no per-type nested models: a
heading, list item, quote or preformatted line used to nest a second object that
repeated the line's own text under another name, so a page serialized each of
its lines two or three times. GemtextLine now carries type and content —
the line exactly as the server sent it, marker included — plus only what the
marker cannot say: the marker-stripped text, a heading level, the resolved
link, and a preformatted block's alt_text and detected language. Fields a
line does not use are omitted from the payload rather than serialized as null.
For the same reason GeminiGemtextResult.raw_content is no longer serialized at
all. It remains readable in-process (the robots.txt reader parses it), but every
line of it is already in document.lines[*].content, and shipping the whole
page a second time was a third of the payload.
gopher_mcp.models.GemtextDocument
Bases: BaseModel
Model for parsed gemtext document.
lines
class-attribute
instance-attribute
lines: list[GemtextLine] = Field(
..., description="Document lines"
)
links
class-attribute
instance-attribute
links: list[GemtextLink] = Field(
default_factory=list, description="Extracted links"
)
gopher_mcp.models.GemtextLine
Bases: BaseModel
One line of a gemtext document: type, content, and nothing repeated.
Beyond type and content a line carries only what those two cannot say --
a link's resolved url, a heading's level, the marker-stripped text,
and a preformatted block's alt-text and detected language.
type
class-attribute
instance-attribute
type: GemtextLineType = Field(
..., description="Type of gemtext line"
)
content
class-attribute
instance-attribute
text
class-attribute
instance-attribute
text: str | None = Field(
None,
description="The line's text with its leading marker removed, for heading, list-item and quote lines. Absent where `content` is already the text",
)
link
class-attribute
instance-attribute
link: GemtextLink | None = Field(
None,
description="Link target and text (for link lines)",
)
level
class-attribute
instance-attribute
gopher_mcp.models.GemtextLink
Bases: BaseModel
Model for gemtext link lines.
gopher_mcp.models.GemtextLineType
Bases: StrEnum
Types of lines in gemtext format.
MIME and Protocol Types
gopher_mcp.models.GeminiMimeType
Bases: BaseModel
Model for Gemini MIME type parsing.
type
class-attribute
instance-attribute
subtype
class-attribute
instance-attribute
charset
class-attribute
instance-attribute
lang
class-attribute
instance-attribute
gopher_mcp.models.GeminiStatusCode
Bases: IntEnum
Gemini protocol status codes.
gopher_mcp.models.GeminiResponse
Bases: BaseModel
Base model for Gemini protocol responses.
status
class-attribute
instance-attribute
status: GeminiStatusCode | int = Field(
..., description="Gemini status code"
)
meta
class-attribute
instance-attribute
body
class-attribute
instance-attribute
validate_meta_length
classmethod
Validate meta field length (reasonable limit).
URL Models
gopher_mcp.models.GopherURL
Bases: BaseModel
Model for parsed Gopher URLs.
gopher_type
class-attribute
instance-attribute
selector
class-attribute
instance-attribute
search
class-attribute
instance-attribute
validate_port
classmethod
validate_gopher_type
classmethod
Validate Gopher type is a single character.
validate_host
classmethod
Validate hostname is not empty (mirrors GeminiURL).
gopher_mcp.models.GeminiURL
Bases: BaseModel
Model for parsed Gemini URLs.
Based on the gemini://<host>[:<port>][/<path>][?<query>] format.
host
class-attribute
instance-attribute
port
class-attribute
instance-attribute
query
class-attribute
instance-attribute
validate_port
classmethod
validate_host
classmethod
Caching and Security Models
gopher_mcp.models.CacheEntry
Bases: _BaseCacheEntry[GopherFetchResponse]
Model for Gopher cache entries.
value
class-attribute
instance-attribute
timestamp
class-attribute
instance-attribute
gopher_mcp.models.GeminiCacheEntry
Bases: _BaseCacheEntry[GeminiFetchResponse]
Model for Gemini cache entries.
value
class-attribute
instance-attribute
timestamp
class-attribute
instance-attribute
gopher_mcp.models.GeminiCertificateInfo
Bases: BaseModel
Model for client certificate information.
Records what a stored client certificate is and where it applies. It deliberately holds no key material and no filesystem path: the private key is the identity itself, and its location is operator state that must not reach a model through any tool result.
fingerprint
class-attribute
instance-attribute
subject
class-attribute
instance-attribute
issuer
class-attribute
instance-attribute
not_before
class-attribute
instance-attribute
not_after
class-attribute
instance-attribute
port
class-attribute
instance-attribute
path
class-attribute
instance-attribute
key_id
class-attribute
instance-attribute
key_id: str | None = Field(
default=None,
description="Opaque per-certificate identifier naming this entry's key pair within the certificate store. Not a path, and not part of any tool result. Absent on entries written before it existed, whose files are named after the certificate's own common name",
)
is_expired
Check if the certificate's validity window has ended.
An unparseable not_after reports False rather than True: reporting a
certificate expired is what prompts a user to destroy an unrecoverable
private key, so an unreadable timestamp must not be the reason for it.
A genuinely unusable certificate is rejected by the capsule anyway
(status 62).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current_time
|
float | None
|
UNIX timestamp to compare against (default: now). |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
True if the certificate is no longer valid. |
Source code in src/gopher_mcp/models.py
gopher_mcp.models.TOFUEntry
Bases: BaseModel
Model for Trust-on-First-Use certificate storage.
The on-disk record, kept in epoch seconds because that is what tofu.json
holds. What gemini_trust_list reports is :class:TOFUTrustEntry.