{
  "markdown": "# Gopher & Gemini MCP Server\n\n<!-- mcp-name: io.github.cameronrye/gopher-mcp -->\n\n[![CI](https://github.com/cameronrye/gopher-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/cameronrye/gopher-mcp/actions/workflows/ci.yml)\n[![Documentation](https://github.com/cameronrye/gopher-mcp/actions/workflows/docs.yml/badge.svg)](https://github.com/cameronrye/gopher-mcp/actions/workflows/docs.yml)\n[![PyPI version](https://badge.fury.io/py/gopher-mcp.svg)](https://badge.fury.io/py/gopher-mcp)\n[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)\n[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)\n[![Downloads](https://pepy.tech/badge/gopher-mcp)](https://pepy.tech/project/gopher-mcp)\n\nA modern, cross-platform [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants to\nbrowse and interact with both [Gopher protocol](<https://en.wikipedia.org/wiki/Gopher_(protocol)>) and\n[Gemini protocol](https://geminiprotocol.net/) resources safely and efficiently.\n\n## Overview\n\nThe Gopher & Gemini MCP Server bridges vintage and modern alternative internet protocols with AI assistants, allowing LLMs like\nClaude to explore the unique content and communities that thrive on both Gopherspace and Geminispace. Built with FastMCP and\nmodern Python practices, it provides secure, efficient gateways to these distinctive internet protocols.\n\n**Key Benefits:**\n\n- **Discover alternative internet content** - Access unique resources on both Gopher and Gemini protocols\n- **Safe exploration** - Built-in security safeguards, TLS encryption, and content filtering\n- **Modern implementation** - Uses FastMCP framework with async/await patterns\n- **Developer-friendly** - Comprehensive testing, type hints, and documentation\n- **Advanced security** - TOFU certificate validation and client certificate support for Gemini\n\n## Features\n\n- **Dual Protocol Support**: `gopher_fetch` and `gemini_fetch` tools for comprehensive protocol coverage\n- **Comprehensive Gopher Support**: Every standard RFC 1436 item type — menus\n  (`1`) and Index-Search servers (`7`) as structured menus, text (`0`), HTML\n  (`h`), info (`i`) and error (`3`) lines as text, the fourteen binary types as\n  metadata only, and the three interactive ones (`2`, `8`, `T`) refused without\n  opening a connection. The one standard type with no category of its own is `+`\n  (redundant server), which names an alternate host for the preceding item\n  rather than content to render; it takes the unknown-type path below. An\n  unknown type is read as text, best-effort, and an\n  hURL `URL:<target>` selector is followed to the destination the server\n  actually stated\n- **Full Gemini Implementation**: Native gemtext parsing, TLS security, and status code handling\n- **Advanced Security**: TOFU certificate validation with dedicated inspection and recovery tools, scoped client certificates, and secure TLS connections\n- **Safety First**: Built-in timeouts, size limits, input sanitization, SSRF protection, per-host rate limiting, and host allowlists\n- **LLM-Optimized**: Returns structured JSON responses designed for AI consumption\n- **Cross-Platform**: Works seamlessly on Windows, macOS, and Linux\n- **Modern Development**: Full type checking, linting, testing, and CI/CD pipeline\n- **High Performance**: Async/await patterns with intelligent caching — and cached results say so, with a per-request `refresh` bypass\n- **Continuable Reads**: A menu or page cut at the render limit reports where it stops, so `offset` reads the rest instead of leaving a partial view\n\n### Protocols in scope\n\nGopher (RFC 1436, including the de-facto item types in common use) and Gemini,\nboth read-only. That is the whole surface, and the neighbouring protocols are\ndeliberately out of scope rather than merely unbuilt:\n\n- **Titan** and **Misfin** are write protocols — upload and mail. A tool an LLM\n  drives should not be able to publish to someone's capsule or send mail as\n  them, and adding either would make every safeguard here (robots, rate limits,\n  allowlists) protect a much smaller share of what the tool can do.\n- **Spartan** and **Nex** are separate protocols with their own parsers and\n  their own failure modes, serving a small fraction of the hosts these two do.\n  They would double the security-relevant surface for a rounding error in reach.\n- **Gopher+** is not implemented. The `:` item type is recognised because it\n  appears in ordinary menus, but no Gopher+ attribute or metadata request is\n  ever sent.\n\nIf you need one of these, an MCP server that does it well is a better answer\nthan a flag on this one.\n\n## Documentation\n\nComplete documentation is available at **[cameronrye.github.io/gopher-mcp](https://cameronrye.github.io/gopher-mcp)**\n\n- [Installation Guide](https://cameronrye.github.io/gopher-mcp/installation/)\n- [Configuration Guide](https://cameronrye.github.io/gopher-mcp/configuration/)\n- [API Reference](https://cameronrye.github.io/gopher-mcp/api-reference/)\n- [AI Assistant Guide](https://cameronrye.github.io/gopher-mcp/ai-assistant-guide/)\n- [Migration Guide](https://cameronrye.github.io/gopher-mcp/migration-guide/) and\n  [Changelog](https://cameronrye.github.io/gopher-mcp/changelog/) — what changed,\n  and what an upgrade asks of you\n\n## Quick Start\n\n### Prerequisites\n\n- **Python 3.11+** - [Download here](https://www.python.org/downloads/)\n- **uv package manager** - [Install uv](https://docs.astral.sh/uv/getting-started/installation/)\n\n### Installation\n\n#### Option 1: Zero-install with uvx (Recommended)\n\nNo clone, no checkout — [uv](https://docs.astral.sh/uv/) fetches and runs the\npublished package on demand:\n\n```bash\nuvx gopher-mcp\n```\n\n#### Option 2: PyPI Installation\n\n```bash\n# Install from PyPI\npip install gopher-mcp\n\n# Or with uv\nuv add gopher-mcp\n```\n\n#### Option 3: Development Installation\n\n```bash\n# Clone the repository\ngit clone https://github.com/cameronrye/gopher-mcp.git\ncd gopher-mcp\n\n# Set up development environment\n./scripts/dev-setup.sh  # Unix/macOS\n# or\nscripts\\dev-setup.bat   # Windows\n\n# Run the server\nuv run task serve\n```\n\n#### Option 4: Docker\n\nTagged releases publish a slim, non-root image to\n`ghcr.io/cameronrye/gopher-mcp`, tagged with the release version plus `:latest`\nfor stable (non-pre-release) tags:\n\n```bash\n# The default CMD serves streamable-http on 0.0.0.0:8000\ndocker run --rm -p 8000:8000 \\\n  -v gopher-mcp-state:/home/app/.local/share/gopher-mcp \\\n  ghcr.io/cameronrye/gopher-mcp:latest\n\n# Or run over stdio, e.g. for an MCP client\ndocker run --rm -i --no-healthcheck \\\n  -v gopher-mcp-state:/home/app/.local/share/gopher-mcp \\\n  ghcr.io/cameronrye/gopher-mcp:latest --transport stdio\n```\n\nTo run a modified tree, the repository ships the `Dockerfile` the published\nimage is built from: `docker build -t gopher-mcp .`\n\n**Mount a volume, or Gemini trust is meaningless.** Without one, the TOFU pins\nand the client certificates' private keys die with the container, so every start\nre-arms blind trust-on-first-use — the pin is the only thing that authenticates\na Gemini capsule — and destroys any identity you minted, whose private key\ncannot be recovered.\n\n**Mount it at that exact path.** `/home/app/.local/share/gopher-mcp` is where\nthe server writes (`tofu.json` and `certs/`), and it is the one directory the\nimage pre-creates owned by the runtime user and mode `700` — which is what lets\na named volume come up writable instead of root-owned. Mounting anywhere else\npersists an empty directory. `~/.gemini` is **not** the path: it is only a\nread-in-place upgrade route for installs that pinned certificates before\ngopher-mcp had a directory of its own, and it is honoured only when its store\nfile is already there, which it never is in a fresh image.\n\n**Health checks.** The HTTP transports serve `GET /health`, which answers\n`{\"status\": \"ok\", \"version\": \"...\"}` and nothing else — no configuration, no\nallowlists, no store paths. It bypasses authorization by SDK design, which is\nwhat makes it usable as a probe. The image's `HEALTHCHECK` polls it on the\nhard-coded port `8000` to match the default `CMD`, so override the healthcheck\nalongside `--port`, and pass `--no-healthcheck` when running stdio — a stdio\ncontainer serves no HTTP and would otherwise be reported unhealthy while working\nperfectly.\n\n> **Note:** the default `CMD` binds `0.0.0.0` so the container is reachable out\n> of the box. A non-loopback `--host` also turns off FastMCP's DNS-rebinding\n> `Host`/`Origin` check, matching what the SDK does when it is constructed with\n> such a host — otherwise every client that was not on localhost got\n> `421 Misdirected Request`. Keep the check on by naming the hostnames the\n> deployment answers to with `--allowed-host NAME` (repeatable; a bare name\n> matches any port). The HTTP transports are unauthenticated and have no TLS\n> either — put the container behind a trusted reverse proxy, or use\n> `--transport stdio`, before exposing it beyond your machine.\n\n### MCP Client Integration\n\nEvery client below runs the server over **stdio** — no ports, no TLS, no\nlistening socket. The entry is the same three fields everywhere; only the file\nand the top-level key change:\n\n| Client         | Where the entry goes                                                                                                                                                                      | Top-level key     |\n| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- |\n| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows), `~/.config/Claude/claude_desktop_config.json` (Linux) | `mcpServers`      |\n| Claude Code    | `claude mcp add --scope user gopher -- uvx gopher-mcp`, or `.mcp.json` at the repository root for `--scope project`                                                                       | `mcpServers`      |\n| Cursor         | `~/.cursor/mcp.json`, or `.cursor/mcp.json` for one project                                                                                                                               | `mcpServers`      |\n| VS Code        | `.vscode/mcp.json` in the workspace, or **MCP: Open User Configuration**                                                                                                                  | `servers`         |\n| Zed            | `settings.json` (**zed: open settings**)                                                                                                                                                  | `context_servers` |\n| Windsurf       | `~/.codeium/windsurf/mcp_config.json`                                                                                                                                                     | `mcpServers`      |\n\n```json\n{\n  \"mcpServers\": {\n    \"gopher\": {\n      \"command\": \"uvx\",\n      \"args\": [\"gopher-mcp\"]\n    }\n  }\n}\n```\n\nThat is the whole entry: every setting in [Configuration](#configuration) has a\nworking default, so add an `\"env\"` block only when you actually want to change\none. Installed with `pip` rather than `uvx`? Use `\"command\": \"gopher-mcp\"` and\n`\"args\": []` instead.\n\nThe [Installation Guide](https://cameronrye.github.io/gopher-mcp/installation/#mcp-client-integration)\nhas the exact JSON for each client, including the two that do not use the\n`mcpServers` key.\n\nIf a GUI client reports that the server failed to start, it is almost always\n`PATH`: a GUI-launched application does not inherit your shell's, so `uvx` may\nnot be found. Use the absolute path (`which uvx`) as `\"command\"`, and restart\nthe application fully rather than reloading the window.\n\n<details>\n<summary>Alternative: run from a local checkout</summary>\n\n```json\n{\n  \"mcpServers\": {\n    \"gopher\": {\n      \"command\": \"uv\",\n      \"args\": [\"--directory\", \"/path/to/gopher-mcp\", \"run\", \"task\", \"serve\"]\n    }\n  }\n}\n```\n\nOn Windows use the absolute path with escaped backslashes\n(`C:\\\\path\\\\to\\\\gopher-mcp`).\n\n</details>\n\n## Usage\n\nThe server registers eight MCP tools:\n\n| Tool                        | Purpose                                                         |\n| --------------------------- | --------------------------------------------------------------- |\n| `gopher_fetch`              | Fetch one Gopher resource                                       |\n| `gemini_fetch`              | Fetch one Gemini resource                                       |\n| `gopher_batch_fetch`        | Fetch several Gopher URLs at once (bounded concurrency, max 50) |\n| `gemini_batch_fetch`        | Fetch several Gemini URLs at once (bounded concurrency, max 50) |\n| `gemini_trust_list`         | Inspect the Gemini TOFU trust store (read-only)                 |\n| `gemini_trust_update`       | Remove or re-pin one host's certificate (**destructive**)       |\n| `gemini_client_cert_list`   | Inspect the stored Gemini client identities (read-only)         |\n| `gemini_client_cert_update` | Create or remove one client identity (**destructive**)          |\n\nThe four fetch tools are annotated read-only and open-world. The four\ncertificate tools never touch the network, and each pair is split read from\nwrite so a client can gate the destructive one on its own.\n\nAlongside them the server exposes one resource, `gopher-mcp://policy`, which\nrenders the fetch policy this process is actually running with — the allowlists,\ncaps and robots settings a refusal is decided from, with the two store paths\nreduced to `<configured>` / `<default>`. There is deliberately no tool that\nedits it: a fetched page talked into widening an allowlist would have widened it\nfor every later fetch. Two prompts, **Explore a capsule or Gopher hole** and\n**Summarize a gemlog or phlog**, package the navigation and safety rules as a\none-click starting point.\n\n### `gopher_fetch` Tool\n\nFetches Gopher menus, text files, or metadata by URL with comprehensive error handling and security safeguards.\n\n**Parameters:**\n\n- `url` (string, required): Full Gopher URL (e.g., `gopher://gopher.floodgap.com/1/`)\n- `search` (string, optional): Terms for a type-7 (Index-Search) selector. They are\n  percent-encoded and sent as the query string, so pass the user's words raw — a\n  query holding `#`, `+`, `&` or non-ASCII is truncated or mangled when written\n  into the URL by hand. RFC 1436 gives only type 7 a query field, so leave it\n  unset for every other item type\n- `refresh` (boolean, optional, default `false`): Skip the cached copy and re-fetch from the server\n- `offset` (integer, optional, default `0`): Continue a truncated result — pass the\n  previous result's `next_offset`, which counts menu items for a menu\n\n**Response Types:**\n\n- **MenuResult** (`kind: \"menu\"`): For Gopher menus (type 1) and search results (type 7)\n  - Structured menu items with type, title, selector, host and port, each with a\n    `next_url` to follow. An empty `next_url` marks a display-only info line\n- **TextResult** (`kind: \"text\"`): For text files (type 0)\n  - Returns the text content with metadata\n- **BinaryResult** (`kind: \"binary\"`): Metadata only for the binary item types\n  (`4`, `5`, `6`, `9`, `g`, `I`, `d`, `s`, `;`, `p`, `P`, `:`, `M`, `<`)\n  - Provides `bytes` and `mime_type` without downloading binary content\n- **ErrorResult** (`kind: \"error\"`): For errors and unfetchable content\n  - `error.code` and `error.message`; nothing was fetched. The interactive types\n    (`2` CSO, `8` telnet, `T` tn3270) have no fetchable body at all and answer\n    `NOT_FETCHABLE` without opening a connection\n\n### `gemini_fetch` Tool\n\nFetches Gemini content with full TLS security, TOFU certificate validation, and native gemtext parsing.\n\n**Parameters:**\n\n- `url` (string, required): Full Gemini URL (e.g., `gemini://geminiprotocol.net/`)\n- `input` (string, optional): Text to answer a Gemini input prompt (status 10/11); it is percent-encoded into the query string\n- `refresh` (boolean, optional, default `false`): Skip the cached copy and re-fetch from the server\n- `offset` (integer, optional, default `0`): Continue a truncated result — pass the\n  previous result's `next_offset`, which counts characters for a page body\n\n**Response Types:** seven, one per `kind`.\n\n- **GeminiGemtextResult** (`kind: \"gemtext\"`): For gemtext content (`text/gemini`)\n  - Parsed document in `document.lines` and `document.links`, whose `url` fields\n    are already resolved. A line carries its own `type`, `content` and whatever\n    the marker cannot say (`text`, `level`, `alt_text`, `language`); there is no\n    nested per-line object and no whole-document `raw_content` in the payload\n- **GeminiSuccessResult** (`kind: \"success\"`): For other **text** content types\n  - Decoded text in `content`, with MIME type information\n- **GeminiBinaryResult** (`kind: \"binary\"`): For binary content\n  - Metadata only — `size` and the detected `mime_type`, never the bytes. A 1 MB\n    body would be ~350k tokens of base64 the model cannot render anyway\n- **GeminiInputResult** (`kind: \"input\"`): For input requests (status 1x)\n  - The capsule's `prompt`, with `sensitive: true` on status 11. Answer it by\n    calling again with `input=`, never by hand-building a query string\n- **GeminiRedirectResult** (`kind: \"redirect\"`): For redirects (status 3x, where\n  31 is permanent)\n  - `new_url` is the target. Redirects are **not**\n    followed for you, so the result also carries `cross_host` (the target\n    belongs to a different party than the one you asked for) and `scheme`\n    (anything but `gemini` leaves Geminispace and cannot be fetched with this\n    tool). Follow at most five in a row and stop on a URL already seen\n- **GeminiErrorResult** (`kind: \"error\"`): For errors (status 40-59), and for\n  failures raised on this side of the wire — SSRF and allowlist refusals, a\n  robots block, a certificate mismatch, a timeout\n  - `error.code` and `error.message`, where `message` is written by this server.\n    The capsule's own untrusted `META` text is kept apart in `error.meta`, so a\n    hostile `51 <instruction>` cannot be read as this server's guidance. Where a\n    status has a defined remedy — the whole temporary 4x family included — that\n    remedy is in `error.next_step`\n- **GeminiCertificateResult** (`kind: \"certificate\"`): For certificate statuses (60-69)\n  - Certificate requirement information, plus a `next_step` written by this\n    server (`message` is the capsule's own text). A certificate that already\n    exists for the host/port/path scope is attached automatically and the fetch\n    path never creates one, so retrying unchanged returns status 60 again;\n    `gemini_client_cert_update` mints one for that scope, but only once the user\n    has agreed to hold a persistent identity on that capsule.\n\n`GeminiErrorResult` is an alias for the same `ErrorResult` model `gopher_fetch`\nreturns, not a separate type — its `error` object simply carries the extra\n`status`, `temporary` and `meta` keys when the capsule actually answered.\n\nGemini results name the content length `size` where the Gopher results name the\nsame fact `bytes`. One concept, two wire names, kept apart only because renaming\neither would break every existing consumer.\n\n### Cached Results and `refresh`\n\nSuccessful bodies are cached per protocol for a few minutes. A result that came\nfrom the cache says so, so a replay is never mistaken for the current state of a\nresource:\n\n- `cached` — `true` when the result was replayed from the local cache\n- `cached_at` — when that copy was actually fetched, as an ISO-8601 UTC\n  timestamp (`2026-09-02T12:00:00+00:00`)\n- `cache_age_seconds` — how old the copy was when it was returned\n\nThese appear only on the kinds that are actually cached (Gopher `menu`, `text`,\n`binary`; Gemini `gemtext`, `success`, `binary`). Errors, redirects and\ninput/certificate prompts are never cached.\n\nPass `refresh: true` when the user wants the current state — it skips the cache\nfor that one call and still stores the fresh response. All four fetch tools take\nit, the batch pair included.\n\n### Truncated Results and `offset`\n\nMenus and page bodies are capped before they reach the model\n(`*_MAX_RENDERED_CHARS`, `GOPHER_MAX_MENU_ITEMS`), but a cap is not a dead end.\nA result cut short sets `truncated: true` and says where to resume:\n\n- `next_offset` — where the part that was cut begins, or `null` when there is\n  nothing more\n- `total_items` (Gopher `menu`) / `total_chars` (`text`, `success`, `gemtext`) —\n  how big the whole resource is. `total_items` is `null` when the directory was\n  larger than the render cap, because the total is not counted in that case\n\nCall the same tool again with `offset` set to the previous `next_offset` and keep\ngoing until `next_offset` comes back `null`. The unit is items for a menu and\n**characters** for a body; `bytes` and `size` are byte counts and are never\noffsets. For gemtext, a window ends on the last complete line, so consecutive\nwindows abut exactly and half a link never parses as a whole one.\n\nNeither batch tool takes `offset`: one offset cannot mean anything sensible\nacross a list of different URLs. Continue a truncated batch item with the\nsingle-URL tool, which is where `next_offset` is answerable.\n\n### Gemini Trust-Store Tools\n\nGemini has no certificate authorities: the first certificate seen for a host is\npinned, and every later connection must present the same one. When a host reissues\nits certificate — routine for self-signed certs, usually at expiry — the fetch\nfails with `CERTIFICATE_CHANGED`. Two tools handle that without hand-editing the\ntrust store on disk — `$XDG_DATA_HOME/gopher-mcp/tofu.json`, falling back to\n`~/.local/share/gopher-mcp/`, `~/Library/Application Support/gopher-mcp/` on\nmacOS and `%LOCALAPPDATA%\\gopher-mcp\\` on Windows, and overridable with\n`GEMINI_TOFU_STORAGE_PATH`. An install that already has `~/.gemini/tofu.json`\nkeeps using it exactly where it is, permanently: moving pins would lose them or,\nworse, make a pinned host look unpinned. The full rules are in\n[Where Gemini state is stored](https://cameronrye.github.io/gopher-mcp/configuration/#where-gemini-state-is-stored).\n\n- **`gemini_trust_list`** (read-only) reports what is pinned, optionally for one\n  `host`: fingerprint, port, first/last seen and expiry as ISO-8601 UTC, plus a\n  precomputed `expired` — an ended validity window makes a routine reissue the\n  likely explanation for a changed fingerprint.\n- **`gemini_trust_update`** (destructive) removes (`action: \"remove\"`) or\n  replaces (`action: \"pin\"`) the pin of one named `host`. There is no wildcard.\n\nA fingerprint change is also exactly what an active machine-in-the-middle attack\nlooks like, and the two are indistinguishable from the client. So a pin is only\never changed after the user confirms the new certificate is expected — checked\nagainst the operator or another device, never on the say-so of a fetched page. To\nenforce that, `action: \"remove\"` requires the fingerprint **currently** pinned\n(as reported by `gemini_trust_list`); a mismatch returns `FINGERPRINT_MISMATCH`\nand changes nothing. On a client that supports MCP elicitation the change is\nalso put to you before it is made, and declining returns `USER_DECLINED` with\nthe pin untouched; a client without that capability is never asked, and behaves\nas it always has.\n\n### Gemini Client-Identity Tools\n\nA client certificate is the other half of Gemini's certificate story, and the\nopposite direction: it is the identity **this server presents to a capsule**,\nnot the one a capsule presents to us. Capsules with accounts ask for it with\n**status 60 (certificate required)**. The fetch path attaches a certificate that\nalready covers the requested scope but never creates one, so answering a 60 is\nan explicit call:\n\n- **`gemini_client_cert_list`** (read-only) reports the scopes that hold an\n  identity — each as a ready-to-use scope URL with its fingerprint, validity\n  window and whether it has expired. Never a private key or its location.\n- **`gemini_client_cert_update`** (destructive) creates the identity for the\n  scope of a named `gemini://` URL, or removes the one covering it. Like the\n  trust-store tool, it asks first on a client that supports elicitation —\n  creating an identity is a decision about being linkable across visits, and\n  removing one destroys a private key that nothing can recreate.\n\nThe certificate covers that URL's path and everything below it, so\n`gemini://host/app/page.gmi` covers one page, `gemini://host/app/` the section,\nand `gemini://host/` the whole capsule. While it exists, every request in that\nscope carries it, which is what lets the capsule link those visits — so it is\nthe user's decision, never a reaction to a page or `META` string asking for one.\nCreation refuses to replace a certificate already covering the scope, because\nthe private key cannot be recovered, and `action: \"remove\"` requires the\nfingerprint being destroyed, exactly as the trust tools require the pinned one.\n\n### Example URLs to Try\n\n#### Gopher Protocol\n\n```bash\n# Classic Gopher menu\ngopher://gopher.floodgap.com/1/\n\n# Gopher news and information\ngopher://gopher.floodgap.com/1/gopher\n\n# Search example (type 7)\ngopher://gopher.floodgap.com/7/v2/vs\n\n# Text file example\ngopher://gopher.floodgap.com/0/gopher/welcome\n```\n\n#### Gemini Protocol\n\n```bash\n# Gemini protocol homepage\ngemini://geminiprotocol.net/\n\n# Gemini software directory\ngemini://geminiprotocol.net/software/\n\n# Example personal capsule\ngemini://skyjake.fi/\n\n# A large, browsable aggregator capsule\ngemini://kennedy.gemi.dev/\n```\n\nGeminispace has no search engine this tool can drive. Kennedy and `tlgs.one` are\nworth browsing, but both `Disallow: /search` in their `robots.txt`, so a search\nURL on either comes back `BLOCKED_BY_ROBOTS`. That is a stop, not a setting to\nchange: the operators asked automated clients to stay off those paths.\n\n### Example AI Interactions\n\nOnce configured, you can ask Claude:\n\n**Gopher Exploration:**\n\n- _\"Browse the main Gopher menu at gopher.floodgap.com\"_\n- _\"Search for 'python' on the Veronica-2 search server\"_\n- _\"Show me the welcome text from Floodgap's Gopher server\"_\n- _\"What's available in the Gopher community directory?\"_\n\n**Gemini Exploration:**\n\n- _\"Fetch the Gemini protocol homepage\"_\n- _\"Show me the software directory on geminiprotocol.net\"_\n- _\"Browse the latest posts from a gemlog\"_\n- _\"What's the difference between Gopher and Gemini protocols?\"_\n\n## Development\n\n### Task Runner\n\nEvery development command is a task in `[tool.taskipy.tasks]` (pyproject.toml),\nwhich is the single definition of each one — there is no second table to keep in\nsync. Run them the same way on every platform:\n\n```bash\nuv run task dev-setup     # install dependencies and pre-commit hooks\nuv run task quality       # lint + typecheck + test\nuv run task ci            # what CI runs: check + test-cov\nuv run task help          # list every task (alias for `task --list`)\n```\n\nOn Unix and macOS, `make <command>` is a thin catch-all onto the same table, and\nbare `make` runs `help`. `uv run task help` is the authoritative list; the tasks\nand the reasoning behind the ones that are not obvious are described in\n[CONTRIBUTING.md](CONTRIBUTING.md#the-task-runner).\n\n### Project Structure\n\n```text\ngopher-mcp/\n├── src/gopher_mcp/          # Main package\n│   ├── __init__.py          # Package initialization\n│   ├── __main__.py          # CLI entry point (--transport/--host/--port/--allowed-host)\n│   ├── server.py            # FastMCP server + the eight MCP tool definitions\n│   ├── client_base.py       # Shared fetch scaffolding for both clients\n│   ├── gopher_client.py     # Gopher protocol client\n│   ├── gopher_transport.py  # Low-level Gopher transport\n│   ├── gopher_parse.py      # Gopher URL and menu parsing\n│   ├── gemini_client.py     # Gemini protocol client\n│   ├── gemini_tls.py        # Gemini TLS connection handling\n│   ├── gemini_parse.py      # Gemini URL and response parsing\n│   ├── gemtext.py           # Gemtext document parsing\n│   ├── mime.py              # MIME type detection and filtering\n│   ├── tofu.py              # Trust-on-First-Use certificate store\n│   ├── client_certs.py      # Gemini client certificate storage\n│   ├── identity.py          # Trust/identity decision and wording helpers\n│   ├── ssrf.py              # SSRF protection / address filtering\n│   ├── ratelimit.py         # Per-host rate limiting\n│   ├── robots.py            # robots.txt fetching and policy gate\n│   ├── cache.py             # Shared TTL + LRU response cache\n│   ├── config.py            # Pydantic settings models\n│   ├── models.py            # Pydantic data models\n│   ├── helpers.py           # Shared URL/IO/sanitization helpers\n│   └── utils.py             # Backward-compatible facade re-exporting the above\n├── tests/                   # Comprehensive test suite\n├── docs/                    # MkDocs documentation\n├── scripts/                 # Development scripts\n├── config/                  # Example configuration (example.env)\n├── .github/workflows/       # CI/CD pipelines\n├── Dockerfile               # Slim, non-root container image\n├── Makefile                 # Unix/macOS shortcut onto the taskipy tasks\n├── server.json              # MCP registry manifest\n└── pyproject.toml           # Modern Python project config\n```\n\n### Development Workflow\n\n1. **Setup**: `uv run task dev-setup` - Install dependencies and pre-commit hooks\n2. **Code**: Make your changes with full IDE support (type hints, linting)\n3. **Quality**: `uv run task quality` - Run all quality checks (lint + typecheck + test)\n4. **Test**: `uv run task test-cov` - Run tests with coverage reporting\n5. **Commit**: Pre-commit hooks ensure code quality automatically\n\n### Testing\n\n```bash\n# Run all tests\nuv run task test\n\n# Run with coverage\nuv run task test-cov\n\n# Run specific test types\nuv run task test-unit\nuv run task test-integration\n\n# Run one file\nuv run pytest tests/test_server.py\n```\n\n## Configuration\n\nThe server can be configured through environment variables for both protocols:\n\n### Gopher Configuration\n\n| Variable                         | Description                     | Default         | Example                |\n| -------------------------------- | ------------------------------- | --------------- | ---------------------- |\n| `GOPHER_MAX_RESPONSE_SIZE`       | Maximum response size in bytes  | `1048576` (1MB) | `2097152`              |\n| `GOPHER_TIMEOUT_SECONDS`         | Request timeout in seconds      | `30`            | `60`                   |\n| `GOPHER_CACHE_ENABLED`           | Enable response caching         | `true`          | `false`                |\n| `GOPHER_CACHE_TTL_SECONDS`       | Cache TTL in seconds; `0` = off | `300`           | `600`                  |\n| `GOPHER_MAX_CACHE_ENTRIES`       | Max cached entries (LRU)        | `1000`          | `2000`                 |\n| `GOPHER_ALLOWED_HOSTS`           | Allowed hosts (list)            | unset (all)     | `example.com,test.com` |\n| `GOPHER_ALLOWED_PORTS`           | Allowed ports (list)            | unset (any)     | `70`                   |\n| `GOPHER_ALLOW_LOCAL_HOSTS`       | Permit loopback/private hosts   | `false`         | `true`                 |\n| `GOPHER_REQUESTS_PER_MINUTE`     | Per-host request cap (0 = off)  | `60`            | `30`                   |\n| `GOPHER_MAX_CONCURRENT_REQUESTS` | Simultaneous fetches (0 = off)  | `5`             | `2`                    |\n| `GOPHER_RESPECT_ROBOTS_TXT`      | Honour `/robots.txt`            | `true`          | `false`                |\n\n### Gemini Configuration\n\n| Variable                         | Description                        | Default         | Example                |\n| -------------------------------- | ---------------------------------- | --------------- | ---------------------- |\n| `GEMINI_MAX_RESPONSE_SIZE`       | Maximum response size in bytes     | `1048576` (1MB) | `2097152`              |\n| `GEMINI_TIMEOUT_SECONDS`         | Whole-fetch wire-time budget       | `30`            | `60`                   |\n| `GEMINI_CACHE_ENABLED`           | Enable response caching            | `true`          | `false`                |\n| `GEMINI_CACHE_TTL_SECONDS`       | Cache TTL in seconds; `0` = off    | `300`           | `600`                  |\n| `GEMINI_MAX_CACHE_ENTRIES`       | Max cached entries (LRU)           | `1000`          | `2000`                 |\n| `GEMINI_ALLOWED_HOSTS`           | Allowed hosts (list)               | unset (all)     | `example.org,test.org` |\n| `GEMINI_ALLOWED_PORTS`           | Allowed ports (list)               | unset (any)     | `1965`                 |\n| `GEMINI_ALLOW_LOCAL_HOSTS`       | Permit loopback/private hosts      | `false`         | `true`                 |\n| `GEMINI_TOFU_ENABLED`            | Enable TOFU certificate validation | `true`          | `false`                |\n| `GEMINI_CLIENT_CERTS_ENABLED`    | Store and attach client certs      | `true`          | `false`                |\n| `GEMINI_REQUESTS_PER_MINUTE`     | Per-host request cap (0 = off)     | `60`            | `30`                   |\n| `GEMINI_MAX_CONCURRENT_REQUESTS` | Simultaneous fetches (0 = off)     | `5`             | `2`                    |\n| `GEMINI_RESPECT_ROBOTS_TXT`      | Honour `/robots.txt`               | `true`          | `false`                |\n\n> **SSRF protection:** by default both tools reject targets that resolve to loopback,\n> link-local (including cloud metadata `169.254.169.254`), or private/RFC1918 addresses.\n> Set `GOPHER_ALLOW_LOCAL_HOSTS` / `GEMINI_ALLOW_LOCAL_HOSTS` to `true` only when you\n> deliberately need to reach local hosts (e.g. testing a server on localhost).\n\n**Timeouts.** `*_TIMEOUT_SECONDS` is one overall deadline per fetch, not a\nper-phase timeout. For Gemini, DNS, connect and TLS handshake, the trust-store\nwrite, send and read all draw down the same budget, and when robots checking is\nenabled the `/robots.txt` probe spends from it too — so a slow host cannot spend\nthe full value on each step in turn.\n\n**List-valued variables.** `*_ALLOWED_HOSTS`, `*_ALLOWED_PORTS` and\n`GEMINI_DENIED_MIME_TYPES` accept either the comma-separated form (`a,b`) or a\nJSON array (`[\"a\", \"b\"]`); whitespace around entries is stripped. Leave one\n**unset** (or empty) to mean \"no restriction\". A value that is present but names\nno entries — `\" , \"`, or `\"$A,$B\"` where both shell variables are empty — is a\n**startup error**, because an empty allowlist cannot be told apart from an absent\none and would silently drop the restriction you meant to apply. A port outside\n`1`–`65535` in an allowlist is a startup error for the same reason: it could\nnever match, so every fetch would be refused at runtime instead.\n\n**Caching.** `*_CACHE_TTL_SECONDS=0` disables caching rather than storing entries\nthat expire the instant they are written.\n\nThe tables above cover the most common settings. Additional options include\nrobots policy caching and retry (`*_ROBOTS_CACHE_TTL_SECONDS`,\n`*_ROBOTS_HONOR_AI_TOKENS`, `*_ROBOTS_FAILURE_BACKOFF_SECONDS`),\nrendered-output limits (`*_MAX_RENDERED_CHARS`, `GOPHER_MAX_MENU_ITEMS`), Gemini\nTOFU/certificate storage paths and expiry policy (`GEMINI_TOFU_STORAGE_PATH`,\n`GEMINI_TOFU_REJECT_EXPIRED`, `GEMINI_CLIENT_CERTS_STORAGE_PATH`), MIME filtering\n(`GEMINI_DENIED_MIME_TYPES`), and server/logging settings under the `GOPHER_MCP_`\nprefix. See the full\n[Configuration Guide](https://cameronrye.github.io/gopher-mcp/configuration/) for\nevery variable, its type, range, and default, or `config/example.env` for a\nready-to-edit starting point.\n\n### Example Configuration\n\n```bash\n# Gopher settings\nexport GOPHER_MAX_RESPONSE_SIZE=2097152\nexport GOPHER_TIMEOUT_SECONDS=60\nexport GOPHER_CACHE_ENABLED=true\nexport GOPHER_ALLOWED_HOSTS=\"gopher.floodgap.com,gopher.quux.org\"\n\n# Gemini settings\nexport GEMINI_MAX_RESPONSE_SIZE=2097152\nexport GEMINI_TIMEOUT_SECONDS=60\nexport GEMINI_TOFU_ENABLED=true\nexport GEMINI_CLIENT_CERTS_ENABLED=true\nexport GEMINI_ALLOWED_HOSTS=\"geminiprotocol.net,skyjake.fi\"\n\n# Run with custom config\nuv run task serve\n```\n\n## Network Etiquette\n\nGopherspace and Geminispace are served largely by individuals running small\nmachines. This server is built to be a guest there.\n\n### What this tool does and does not do\n\n`gopher-mcp` fetches a resource when someone asks their assistant for it, and\nreturns the content to that conversation. It does **not** train on what it\nfetches, archive it, rehost it, or make it searchable. It does not follow links\non its own: every URL it retrieves was named by the caller.\n\nNeither protocol has a user-agent field, so nothing identifies this client on\nthe wire and a server cannot recognise or block it by name. That is a property\nof Gopher and Gemini, not a choice made here. What it does do, when robots\nchecking is enabled below, is honour rules written against the token\n`gopher-mcp`, so an operator who wants to single it out has a way to.\n\n### Rate limiting\n\nBoth clients space out requests to the same host and cap how many fetches run at\nonce. Unlike earlier versions, **these are on by default**: one request per\nsecond per host (`*_REQUESTS_PER_MINUTE=60`) and five concurrent fetches\n(`*_MAX_CONCURRENT_REQUESTS=5`). Set either to `0` to disable it. A Gemini server\nanswering `44 SLOW_DOWN` is always honoured regardless of these settings.\n\n### Robot exclusion (`robots.txt`)\n\nThe server fetches `/robots.txt` from the host root and honours it before\nretrieving anything. This is **on by default**: these are overwhelmingly\nhobbyist-run servers, and ignoring a stated policy is not a reasonable default\nfor a tool an LLM drives unattended. Policies are cached per host for 24 hours\n(`*_ROBOTS_CACHE_TTL_SECONDS`), so the extra round-trip is paid once per host,\nnot per fetch.\n\n`GOPHER_RESPECT_ROBOTS_TXT=false` / `GEMINI_RESPECT_ROBOTS_TXT=false` turns it\noff, but the override is for **a host you operate** — a blanket `Disallow: /` on\nsomeone else's server is a decision, not a misconfiguration. The error messages\nare written that way too, because they are read by the model, not by you: a\nblocked fetch returns `BLOCKED_BY_ROBOTS` and says to stop and tell the user\nrather than retry or try another spelling of the path. A fetch refused because\nthe policy could not be read at all returns `ROBOTS_UNAVAILABLE` instead — a\nseparate code because it is transient and means nothing disallowed you.\n\nWhich convention applies depends on the protocol:\n\n- **Gemini** follows the official [companion specification][gemini-robots],\n  which defines the virtual agents `archiver`, `indexer`, `researcher` and\n  `webproxy`. This server matches `gopher-mcp`, `webproxy`, `indexer` and `*`.\n  It does not claim `archiver` or `researcher`: nothing here retains content,\n  and `researcher` is defined for tools that operate without surfacing what they\n  fetch.\n- **Gopher** follows the convention Veronica-2 documents at\n  `gopher://gopher.floodgap.com/0/v2/help/indexer` (written out rather than\n  linked, because a `gopher://` href does not survive PyPI's sanitizer). This\n  server matches `gopher-mcp` and `*`. It does not claim `veronica`, which\n  belongs to Floodgap's indexer.\n\nBoth use the original 1994 `robots.txt` grammar rather than RFC 9309, so only\n`#`, `User-agent:` and `Disallow:` are recognised and every other field,\nincluding `Allow:`, is ignored. This matters: an RFC 9309 parser would act on\n`Allow:` lines that authors on these networks expect to be dropped, making it\n_more_ permissive than intended.\n\nBy default the server also honours rules naming AI crawler tokens such as\n`ClaudeBot`, `GPTBot` and `CCBot` (`*_ROBOTS_HONOR_AI_TOKENS`). These are not\npart of either protocol's convention, but an operator who wrote one meant \"no\nLLM tooling\", and that is the request being made.\n\nWhen a policy cannot be retrieved at all, the host is left alone for a short\nwhile before being probed again (`*_ROBOTS_FAILURE_BACKOFF_SECONDS`, 60s by\ndefault) rather than paying a fresh connect timeout on every request. Set it to\n`0` to re-probe immediately, or raise it if you routinely fetch from hosts that\nare down. A Gemini capsule answering `44 SLOW_DOWN` is the exception: it named\nits own retry period, so that is used instead.\n\nTwo known limitations, both documented rather than papered over:\n\n- **Gopher fails open.** The protocol has no status codes, so a missing\n  selector, an error document and an empty file are indistinguishable on the\n  wire. RFC 9309 §2.3.1.4 would have an unreachable policy deny everything,\n  which would block most of Gopherspace. Instead the parser is lenient: content\n  that yields no `User-agent:` group imposes no rules. Gemini, which does have\n  status codes, fails closed and treats `51 NOT FOUND` as \"no policy\". Note that\n  failing closed covers more than a 4x status: a capsule that is unreachable —\n  connection refused, TLS failure, timeout, malformed reply — also has no\n  retrievable policy, so it is refused too — but under the separate\n  `ROBOTS_UNAVAILABLE` code, since the capsule never disallowed anything. The\n  message names the underlying cause, and turning robots checking off will not\n  make such a capsule reachable.\n- **Gopher path rules are best-effort.** A Gopher URI carries the item type as\n  the first path character, so `gopher://host/1/archive` has the URI path\n  `/1/archive` but the on-wire selector `/archive`. Rules are tested against both\n  spellings, but `Disallow: /` is the only form guaranteed to behave the way its\n  author expects.\n\nThere is no per-directory or per-user `robots.txt`. Neither protocol convention\nnor RFC 9309 §2.3 defines one; on shared hosts the established pattern is a\nsingle file at the root using path prefixes.\n\n[gemini-robots]: https://geminiprotocol.net/docs/companion/robots.gmi\n\n## Contributing\n\nWe welcome contributions! Please see our [Contributing Guidelines](https://github.com/cameronrye/gopher-mcp/blob/main/CONTRIBUTING.md) for details.\n\n### Quick Contribution Steps\n\n1. **Fork** the repository on GitHub\n2. **Clone** your fork: `git clone https://github.com/your-username/gopher-mcp.git`\n3. **Setup** development environment: `uv run task dev-setup`\n4. **Create** a feature branch: `git checkout -b feature/amazing-feature`\n5. **Make** your changes with tests\n6. **Quality** check: `uv run task quality`\n7. **Commit** your changes: `git commit -m 'Add amazing feature'`\n8. **Push** to your fork: `git push origin feature/amazing-feature`\n9. **Submit** a pull request with a clear description\n\n### Development Standards\n\n- **Type hints** for all functions and methods\n- **Comprehensive tests** (CI enforces a minimum of 95% coverage)\n- **Documentation** for all public APIs\n- **Security** considerations for all network operations\n- **Cross-platform** compatibility (Windows, macOS, Linux)\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](https://github.com/cameronrye/gopher-mcp/blob/main/LICENSE) file for details.\n\n## Acknowledgments\n\n- **[Model Context Protocol](https://modelcontextprotocol.io/)** by Anthropic - The foundation that makes this integration possible\n- **[MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)** - Its bundled `FastMCP` (`mcp.server.fastmcp`) is what this server is built on, pinned to `mcp>=1.28.1,<2`\n- **The Gopher Protocol Community** - Keeping the spirit of the early internet alive\n\n## Related Projects\n\n- [Model Context Protocol Servers](https://github.com/modelcontextprotocol/servers) - Official MCP server implementations\n- [Awesome MCP Servers](https://github.com/punkpeye/awesome-mcp-servers) - Curated list of MCP servers\n- [Claude Desktop](https://claude.ai/download) - AI assistant that supports MCP\n\n## Support\n\n- **Bug Reports**: [GitHub Issues](https://github.com/cameronrye/gopher-mcp/issues)\n- **Feature Requests**: [Open a feature request](https://github.com/cameronrye/gopher-mcp/issues/new?template=feature_request.yml)\n- **Questions**: [Ask a question](https://github.com/cameronrye/gopher-mcp/issues/new?template=question.yml)\n- **Documentation**: [Project Docs](https://cameronrye.github.io/gopher-mcp/)\n- **Community**: [MCP Discord](https://discord.gg/modelcontextprotocol)\n\nWhichever you open, include the version: `gopher-mcp --version` reports it and\nworks for the `uvx` and Docker installs, where there is no checkout to import\nthe package from.\n\n---\n\n<div align=\"center\">\n\nMade with ❤️ by [Cameron Rye](https://rye.dev/)\n\n[Star this project](https://github.com/cameronrye/gopher-mcp) if you find it useful!\n\n</div>\n",
  "bytes": 45070,
  "sha": "156ddd67f2ed5ede8dee3e3b96bf1940a30cb32c29c2129df2022e82f98287d3",
  "repo_slug": "cameronrye/gopher-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cameronrye_gopher_mcp_4ea7b1be/readme"
}