Skip to content

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

iso_utc(timestamp: float | None) -> str | None

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 timestamp is None.

Source code in src/gopher_mcp/models.py
def iso_utc(timestamp: float | None) -> str | None:
    """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.

    Args:
        timestamp: UNIX timestamp in seconds, or None.

    Returns:
        The ISO-8601 UTC rendering, or None when ``timestamp`` is None.

    """
    if timestamp is None:
        return None
    return datetime.fromtimestamp(timestamp, UTC).replace(microsecond=0).isoformat()

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_gopher_url(v: str) -> str

Validate that the URL is a proper Gopher URL.

Source code in src/gopher_mcp/models.py
@field_validator("url")
@classmethod
def validate_gopher_url(cls, v: str) -> str:
    """Validate that the URL is a proper Gopher URL."""
    v = _canonical_scheme(v, "gopher")
    if len(v.encode("utf-8")) > 8192:
        raise ValueError("URL must not exceed 8192 bytes")
    return v

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_gemini_url(v: str) -> str

Validate that the URL is a proper Gemini URL.

Source code in src/gopher_mcp/models.py
@field_validator("url")
@classmethod
def validate_gemini_url(cls, v: str) -> str:
    """Validate that the URL is a proper Gemini URL."""
    v = _canonical_scheme(v, "gemini")
    if len(v.encode("utf-8")) > 1024:
        raise ValueError("URL must not exceed 1024 bytes")
    return v

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

Bases: _ObjectRoot[Annotated[GopherFetchResponse, _KIND]]

One Gopher fetch result: a menu, text, binary metadata, or an error.

model_config class-attribute instance-attribute

model_config = ConfigDict(
    json_schema_extra={"type": "object"}
)

gopher_mcp.models.GeminiFetchOutput

Bases: _ObjectRoot[Annotated[GeminiFetchResponse, _KIND]]

One Gemini fetch result: gemtext, success, binary metadata, input, redirect, certificate, or an error.

model_config class-attribute instance-attribute

model_config = ConfigDict(
    json_schema_extra={"type": "object"}
)

Gopher Result Models

gopher_mcp.models.GopherMenuItem

Bases: BaseModel

Model for a single Gopher menu item.

type class-attribute instance-attribute

type: str = Field(
    ..., description="Gopher item type (single character)"
)

title class-attribute instance-attribute

title: str = Field(
    ..., description="Human-readable item title"
)

selector class-attribute instance-attribute

selector: str = Field(
    ..., description="Selector string for this item"
)

host class-attribute instance-attribute

host: str = Field(
    ..., description="Hostname where item resides"
)

port class-attribute instance-attribute

port: int = Field(
    ...,
    ge=0,
    le=65535,
    description="Port number (typically 70)",
)

next_url class-attribute instance-attribute

next_url: str = Field(
    ...,
    validation_alias=AliasChoices("next_url", "nextUrl"),
    serialization_alias="next_url",
    description="Fully formed gopher:// URL for this item",
)

gopher_mcp.models.MenuResult

Bases: BaseModel

Result model for Gopher menu responses.

kind class-attribute instance-attribute

kind: Literal['menu'] = 'menu'

items class-attribute instance-attribute

items: list[GopherMenuItem] = Field(
    ..., description="List of menu items"
)

truncated class-attribute instance-attribute

truncated: bool = Field(
    default=False,
    description="True if the directory holds more items after this window. `next_offset` is where they start -- call again with `offset` set to it rather than treating `items` as the whole directory.",
)

total_items class-attribute instance-attribute

total_items: _TotalItems = None

next_offset class-attribute instance-attribute

next_offset: _NextOffset = None

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

gopher_mcp.models.TextResult

Bases: BaseModel

Result model for Gopher text responses.

kind class-attribute instance-attribute

kind: Literal['text'] = 'text'

charset class-attribute instance-attribute

charset: str = Field(
    default="utf-8", description="Character encoding"
)

bytes class-attribute instance-attribute

bytes: int = Field(
    ..., ge=0, description="Size of content in bytes"
)

text class-attribute instance-attribute

text: str = Field(..., description='Text content')

truncated class-attribute instance-attribute

truncated: bool = Field(
    default=False,
    description="True if the body continues after this window. `next_offset` is where it continues; `bytes` still reports the full original size (in bytes, which is not the unit an offset counts in).",
)

total_chars class-attribute instance-attribute

total_chars: _TotalChars = None

next_offset class-attribute instance-attribute

next_offset: _NextOffset = None

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

gopher_mcp.models.BinaryResult

Bases: BaseModel

Result model for Gopher binary responses.

kind class-attribute instance-attribute

kind: Literal['binary'] = 'binary'

bytes class-attribute instance-attribute

bytes: int = Field(
    ..., ge=0, description="Size of content in bytes"
)

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",
)

note class-attribute instance-attribute

note: str = Field(
    default="Binary content not returned to preserve context",
    description="Note about binary handling",
)

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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.

kind class-attribute instance-attribute

kind: Literal['error'] = 'error'

error class-attribute instance-attribute

error: dict[str, Any] = Field(
    ..., description="Error information"
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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.

kind class-attribute instance-attribute

kind: Literal['success'] = 'success'

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

content: str = Field(
    ..., description="Decoded text response content"
)

size class-attribute instance-attribute

size: int = Field(
    ..., ge=0, description="Content size in bytes"
)

truncated class-attribute instance-attribute

truncated: bool = Field(
    default=False,
    description="True if the body continues after this window. `next_offset` is where it continues; `size` still reports the full original size (in bytes, which is not the unit an offset counts in).",
)

total_chars class-attribute instance-attribute

total_chars: _TotalChars = None

next_offset class-attribute instance-attribute

next_offset: _NextOffset = None

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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.

kind class-attribute instance-attribute

kind: Literal['binary'] = 'binary'

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

size: int = Field(
    ..., ge=0, description="Content size in bytes"
)

note class-attribute instance-attribute

note: str = Field(
    default="Binary content not returned to preserve context",
    description="Note about binary handling",
)

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

gopher_mcp.models.GeminiGemtextResult

Bases: BaseModel

Result model for gemtext content responses.

kind class-attribute instance-attribute

kind: Literal['gemtext'] = 'gemtext'

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

charset: str = Field(
    default="utf-8", description="Character encoding"
)

lang class-attribute instance-attribute

lang: str | None = Field(None, description='Language tag')

size class-attribute instance-attribute

size: int = Field(..., description='Content size in bytes')

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",
)

total_chars class-attribute instance-attribute

total_chars: _TotalChars = None

next_offset class-attribute instance-attribute

next_offset: _NextOffset = None

cached class-attribute instance-attribute

cached: _CachedFlag = False

cached_at class-attribute instance-attribute

cached_at: _CachedAt = None

cache_age_seconds class-attribute instance-attribute

cache_age_seconds: _CacheAgeSeconds = None

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

gopher_mcp.models.GeminiInputResult

Bases: BaseModel

Result model for input request responses (status 10/11).

kind class-attribute instance-attribute

kind: Literal['input'] = 'input'

prompt class-attribute instance-attribute

prompt: str = Field(..., description='Input prompt text')

sensitive class-attribute instance-attribute

sensitive: bool = Field(
    default=False, description="Whether input is sensitive"
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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.

kind class-attribute instance-attribute

kind: Literal['redirect'] = 'redirect'

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

permanent: bool = Field(
    default=False,
    description="Whether redirect is permanent",
)

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",
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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
@model_validator(mode="after")
def describe_target(self) -> "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.
    """
    try:
        target = urlsplit(self.new_url)
        # ``.url`` rather than ``.get("url")``: the echo is a typed model
        # now, so the "is it even a string?" guard the dict needed is gone.
        requested = self.request_info.url
        source = urlsplit(requested) if requested is not None else None
    except ValueError:  # a target too malformed to split tells us nothing
        return self

    if self.scheme is None:
        # A relative target ("/elsewhere") stays in the request's scheme.
        inherited = source.scheme if source is not None else ""
        self.scheme = (target.scheme or inherited).lower() or None

    if self.cross_host is None:
        if not target.netloc:
            self.cross_host = False
        elif source is not None and source.hostname:
            self.cross_host = target.hostname != source.hostname

    return self

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.

kind class-attribute instance-attribute

kind: Literal['certificate'] = 'certificate'

message class-attribute instance-attribute

message: str = Field(
    ..., description="Certificate-related message"
)

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.",
)

next_step class-attribute instance-attribute

next_step: str = Field(
    default="",
    description="What to do about this response, written by this server rather than by the capsule. The three sub-codes need different answers, and only one of them is fixed by creating a certificate.",
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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

host: str = Field(
    ..., description="Host this certificate is pinned for"
)

port class-attribute instance-attribute

port: int = Field(
    default=1965,
    description="Port this certificate is pinned for",
)

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

last_seen: str = Field(
    ...,
    description="ISO-8601 UTC time this certificate was last presented",
)

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 gemini_trust_list reports it.

Source code in src/gopher_mcp/models.py
@classmethod
def from_entry(cls, entry: TOFUEntry, now: float | None = None) -> "TOFUTrustEntry":
    """Project a stored :class:`TOFUEntry` onto the reported shape.

    Args:
        entry: The stored trust-store record.
        now: UNIX timestamp to judge expiry against (default: now).

    Returns:
        The entry as ``gemini_trust_list`` reports it.

    """
    current_time = time.time() if now is None else now
    first_seen = iso_utc(entry.first_seen)
    last_seen = iso_utc(entry.last_seen)
    return cls(
        host=entry.host,
        port=entry.port,
        fingerprint=entry.fingerprint,
        # iso_utc only returns None for a None input, and both are floats.
        first_seen=first_seen or "",
        last_seen=last_seen or "",
        expires=iso_utc(entry.expires),
        expired=entry.is_expired(current_time),
    )

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.

kind class-attribute instance-attribute

kind: Literal['trust_list'] = 'trust_list'

entries class-attribute instance-attribute

entries: list[TOFUTrustEntry] = Field(
    ...,
    description="Pinned certificates matching the request, ordered by host",
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

project_stored_entries classmethod

project_stored_entries(v: Any) -> Any

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
@field_validator("entries", mode="before")
@classmethod
def project_stored_entries(cls, v: Any) -> Any:
    """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.
    """
    if isinstance(v, list):
        return [
            TOFUTrustEntry.from_entry(item) if isinstance(item, TOFUEntry) else item
            for item in v
        ]
    return v

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.

kind class-attribute instance-attribute

kind: Literal['trust_update'] = 'trust_update'

action class-attribute instance-attribute

action: Literal["remove", "pin"] = Field(
    ..., description="The change that was requested"
)

host class-attribute instance-attribute

host: str = Field(
    ..., description="Host whose pin was targeted"
)

port class-attribute instance-attribute

port: int = Field(
    ...,
    ge=1,
    le=65535,
    description="Port whose pin was targeted",
)

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

message: str = Field(
    ..., description="Human-readable summary of the outcome"
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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

host: str = Field(
    ...,
    description="Host of the scope this identity covers",
)

port class-attribute instance-attribute

port: int = Field(
    default=1965, description="Port of the scope"
)

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_before: str = Field(
    ..., description="Start of the validity window"
)

not_after class-attribute instance-attribute

not_after: str = Field(
    ..., description="End of the validity window"
)

expired class-attribute instance-attribute

expired: bool = Field(
    ...,
    description="True if the certificate's validity window has ended, in which case the capsule will reject it (status 62)",
)

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.

kind class-attribute instance-attribute

kind: Literal['client_cert_list'] = 'client_cert_list'

entries class-attribute instance-attribute

entries: list[GeminiClientCertificateEntry] = Field(
    ...,
    description="Stored client certificates matching the request, ordered by host, port and path scope",
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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.

kind class-attribute instance-attribute

kind: Literal['client_cert_update'] = 'client_cert_update'

action class-attribute instance-attribute

action: Literal["create", "remove"] = Field(
    ..., description="The change that was requested"
)

host class-attribute instance-attribute

host: str = Field(
    ..., description="Host of the scope acted on"
)

port class-attribute instance-attribute

port: int = Field(
    ...,
    ge=1,
    le=65535,
    description="Port of the scope acted on",
)

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

message: str = Field(
    ..., description="Human-readable summary of the outcome"
)

request_info class-attribute instance-attribute

request_info: _RequestInfo = _REQUEST_INFO

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: 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

content: str = Field(
    ...,
    description="The line as the server sent it, leading marker included",
)

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: GemtextLink | None = Field(
    None,
    description="Link target and text (for link lines)",
)

level class-attribute instance-attribute

level: int | None = Field(
    None, description="Heading level (1-3, for headings)"
)

alt_text class-attribute instance-attribute

alt_text: str | None = Field(
    None,
    description="Alt text of a preformatted block, carried on the opening ``` toggle that declares it rather than repeated on every line inside",
)

language class-attribute instance-attribute

language: str | None = Field(
    None,
    description="Programming language recognised from `alt_text`, on the opening toggle of a preformatted block",
)

Bases: BaseModel

Model for gemtext link lines.

url class-attribute instance-attribute

url: str = Field(
    ...,
    description="Link URL, resolved against the request URL when the document was fetched, so links returned by a fetch are absolute",
)

text class-attribute instance-attribute

text: str | None = Field(
    None, description="Link text (optional)"
)

validate_url_not_empty classmethod

validate_url_not_empty(v: str) -> str

Validate URL is not empty.

Source code in src/gopher_mcp/models.py
@field_validator("url")
@classmethod
def validate_url_not_empty(cls, v: str) -> str:
    """Validate URL is not empty."""
    if not v.strip():
        raise ValueError("Link URL cannot be empty")
    return v.strip()

gopher_mcp.models.GemtextLineType

Bases: StrEnum

Types of lines in gemtext format.

TEXT class-attribute instance-attribute

TEXT = 'text'
LINK = 'link'

HEADING_1 class-attribute instance-attribute

HEADING_1 = 'heading1'

HEADING_2 class-attribute instance-attribute

HEADING_2 = 'heading2'

HEADING_3 class-attribute instance-attribute

HEADING_3 = 'heading3'

LIST_ITEM class-attribute instance-attribute

LIST_ITEM = 'list'

QUOTE class-attribute instance-attribute

QUOTE = 'quote'

PREFORMAT class-attribute instance-attribute

PREFORMAT = 'preformat'

MIME and Protocol Types

gopher_mcp.models.GeminiMimeType

Bases: BaseModel

Model for Gemini MIME type parsing.

type class-attribute instance-attribute

type: str = Field(
    ..., description="Main MIME type (e.g., 'text')"
)

subtype class-attribute instance-attribute

subtype: str = Field(
    ..., description="MIME subtype (e.g., 'gemini')"
)

charset class-attribute instance-attribute

charset: str = Field(
    default="utf-8", description="Character encoding"
)

lang class-attribute instance-attribute

lang: str | None = Field(
    None, description="Language tag (BCP47)"
)

full_type property

full_type: str

Get full MIME type string.

is_text property

is_text: bool

Check if this is a text MIME type.

is_gemtext property

is_gemtext: bool

Check if this is text/gemini.

is_binary property

is_binary: bool

Check if this is a binary MIME type.

gopher_mcp.models.GeminiStatusCode

Bases: IntEnum

Gemini protocol status codes.

INPUT class-attribute instance-attribute

INPUT = 10

SENSITIVE_INPUT class-attribute instance-attribute

SENSITIVE_INPUT = 11

SUCCESS class-attribute instance-attribute

SUCCESS = 20

TEMPORARY_REDIRECT class-attribute instance-attribute

TEMPORARY_REDIRECT = 30

PERMANENT_REDIRECT class-attribute instance-attribute

PERMANENT_REDIRECT = 31

TEMPORARY_FAILURE class-attribute instance-attribute

TEMPORARY_FAILURE = 40

SERVER_UNAVAILABLE class-attribute instance-attribute

SERVER_UNAVAILABLE = 41

CGI_ERROR class-attribute instance-attribute

CGI_ERROR = 42

PROXY_ERROR class-attribute instance-attribute

PROXY_ERROR = 43

SLOW_DOWN class-attribute instance-attribute

SLOW_DOWN = 44

PERMANENT_FAILURE class-attribute instance-attribute

PERMANENT_FAILURE = 50

NOT_FOUND class-attribute instance-attribute

NOT_FOUND = 51

GONE class-attribute instance-attribute

GONE = 52

PROXY_REQUEST_REFUSED class-attribute instance-attribute

PROXY_REQUEST_REFUSED = 53

BAD_REQUEST class-attribute instance-attribute

BAD_REQUEST = 59

CERTIFICATE_REQUIRED class-attribute instance-attribute

CERTIFICATE_REQUIRED = 60

CERTIFICATE_NOT_AUTHORIZED class-attribute instance-attribute

CERTIFICATE_NOT_AUTHORIZED = 61

CERTIFICATE_NOT_VALID class-attribute instance-attribute

CERTIFICATE_NOT_VALID = 62

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

meta: str = Field(
    ..., description="Status-dependent metadata"
)

body class-attribute instance-attribute

body: bytes | None = Field(
    None, description="Response body (if any)"
)

validate_meta_length classmethod

validate_meta_length(v: str) -> str

Validate meta field length (reasonable limit).

Source code in src/gopher_mcp/models.py
@field_validator("meta")
@classmethod
def validate_meta_length(cls, v: str) -> str:
    """Validate meta field length (reasonable limit)."""
    if len(v.encode("utf-8")) > 1024:
        raise ValueError("Meta field too long")
    return v

URL Models

gopher_mcp.models.GopherURL

Bases: BaseModel

Model for parsed Gopher URLs.

host class-attribute instance-attribute

host: str = Field(..., description='Hostname')

port class-attribute instance-attribute

port: int = Field(default=70, description='Port number')

gopher_type class-attribute instance-attribute

gopher_type: str = Field(
    default="1",
    alias="gopherType",
    description="Gopher item type",
)

selector class-attribute instance-attribute

selector: str = Field(
    default="", description="Selector string"
)

search class-attribute instance-attribute

search: str | None = Field(
    None, description="Search string for type 7 items"
)

validate_port classmethod

validate_port(v: int) -> int

Validate port number range.

Source code in src/gopher_mcp/models.py
@field_validator("port")
@classmethod
def validate_port(cls, v: int) -> int:
    """Validate port number range."""
    if not 1 <= v <= 65535:
        raise ValueError("Port must be between 1 and 65535")
    return v

validate_gopher_type classmethod

validate_gopher_type(v: str) -> str

Validate Gopher type is a single character.

Source code in src/gopher_mcp/models.py
@field_validator("gopher_type")
@classmethod
def validate_gopher_type(cls, v: str) -> str:
    """Validate Gopher type is a single character."""
    if len(v) != 1:
        raise ValueError("Gopher type must be a single character")
    return v

validate_host classmethod

validate_host(v: str) -> str

Validate hostname is not empty (mirrors GeminiURL).

Source code in src/gopher_mcp/models.py
@field_validator("host")
@classmethod
def validate_host(cls, v: str) -> str:
    """Validate hostname is not empty (mirrors GeminiURL)."""
    if not v.strip():
        raise ValueError("Host cannot be empty")
    return v.strip()

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

host: str = Field(..., description="Hostname or IP address")

port class-attribute instance-attribute

port: int = Field(
    default=1965, description="Port number (default: 1965)"
)

path class-attribute instance-attribute

path: str = Field(default='/', description='Resource path')

query class-attribute instance-attribute

query: str | None = Field(
    None, description="Query string for user input"
)

validate_port classmethod

validate_port(v: int) -> int

Validate port number range.

Source code in src/gopher_mcp/models.py
@field_validator("port")
@classmethod
def validate_port(cls, v: int) -> int:
    """Validate port number range."""
    if not 1 <= v <= 65535:
        raise ValueError("Port must be between 1 and 65535")
    return v

validate_host classmethod

validate_host(v: str) -> str

Validate hostname is not empty.

Source code in src/gopher_mcp/models.py
@field_validator("host")
@classmethod
def validate_host(cls, v: str) -> str:
    """Validate hostname is not empty."""
    if not v.strip():
        raise ValueError("Host cannot be empty")
    return v.strip()

Caching and Security Models

gopher_mcp.models.CacheEntry

Bases: _BaseCacheEntry[GopherFetchResponse]

Model for Gopher cache entries.

key class-attribute instance-attribute

key: str = Field(..., description='Cache key')

value class-attribute instance-attribute

value: _CacheValueT = Field(
    ..., description="Cached response"
)

timestamp class-attribute instance-attribute

timestamp: float = Field(
    ..., description="Cache entry timestamp"
)

ttl class-attribute instance-attribute

ttl: int = Field(..., description="Time to live in seconds")

is_expired

is_expired(current_time: float) -> bool

Check if cache entry is expired.

Source code in src/gopher_mcp/models.py
def is_expired(self, current_time: float) -> bool:
    """Check if cache entry is expired."""
    return current_time - self.timestamp > self.ttl

gopher_mcp.models.GeminiCacheEntry

Bases: _BaseCacheEntry[GeminiFetchResponse]

Model for Gemini cache entries.

key class-attribute instance-attribute

key: str = Field(..., description='Cache key')

value class-attribute instance-attribute

value: _CacheValueT = Field(
    ..., description="Cached response"
)

timestamp class-attribute instance-attribute

timestamp: float = Field(
    ..., description="Cache entry timestamp"
)

ttl class-attribute instance-attribute

ttl: int = Field(..., description="Time to live in seconds")

is_expired

is_expired(current_time: float) -> bool

Check if cache entry is expired.

Source code in src/gopher_mcp/models.py
def is_expired(self, current_time: float) -> bool:
    """Check if cache entry is expired."""
    return current_time - self.timestamp > self.ttl

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

fingerprint: str = Field(
    ..., description="Certificate SHA-256 fingerprint"
)

subject class-attribute instance-attribute

subject: str = Field(..., description="Certificate subject")

issuer class-attribute instance-attribute

issuer: str = Field(..., description='Certificate issuer')

not_before class-attribute instance-attribute

not_before: str = Field(
    ..., description="Certificate validity start"
)

not_after class-attribute instance-attribute

not_after: str = Field(
    ..., description="Certificate validity end"
)

host class-attribute instance-attribute

host: str = Field(..., description='Associated hostname')

port class-attribute instance-attribute

port: int = Field(
    default=1965, description="Associated port"
)

path class-attribute instance-attribute

path: str = Field(
    default="/", description="Associated path scope"
)

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

is_expired(current_time: float | None = None) -> bool

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
def is_expired(self, current_time: float | None = None) -> bool:
    """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).

    Args:
        current_time: UNIX timestamp to compare against (default: now).

    Returns:
        True if the certificate is no longer valid.

    """
    try:
        expires = datetime.fromisoformat(self.not_after)
    except ValueError:
        return False
    if expires.tzinfo is None:
        expires = expires.replace(tzinfo=UTC)
    now = time.time() if current_time is None else current_time
    return expires.timestamp() <= now

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.

host class-attribute instance-attribute

host: str = Field(..., description='Hostname')

port class-attribute instance-attribute

port: int = Field(default=1965, description='Port number')

fingerprint class-attribute instance-attribute

fingerprint: str = Field(
    ..., description="Certificate SHA-256 fingerprint"
)

first_seen class-attribute instance-attribute

first_seen: float = Field(
    ..., description="Timestamp of first connection"
)

last_seen class-attribute instance-attribute

last_seen: float = Field(
    ..., description="Timestamp of last connection"
)

expires class-attribute instance-attribute

expires: float | None = Field(
    None, description="Certificate expiry timestamp"
)

is_expired

is_expired(current_time: float) -> bool

Check if certificate is expired.

Source code in src/gopher_mcp/models.py
def is_expired(self, current_time: float) -> bool:
    """Check if certificate is expired."""
    return self.expires is not None and current_time > self.expires