[build-system] # Pinned deliberately: an unpinned backend is resolved fresh on every `uv build`, # so a hatchling release can turn a green repo red with no commit of ours. That # happened with 1.32.0, which raised the default core metadata version to 2.5 and # broke `twine check` until twine 7. Floor is what we test against. requires = ["hatchling>=1.32,<2"] build-backend = "hatchling.build" [project] name = "gopher-mcp" version = "0.10.2" description = "A cross-platform Model Context Protocol (MCP) server for browsing Gopher and Gemini resources" readme = "README.md" license = "MIT" authors = [{ name = "Cameron Rye", email = "c@meron.io" }] maintainers = [{ name = "Cameron Rye", email = "c@meron.io" }] # Gemini and gemtext are here because half the package is the Gemini client # (gemini_client.py, gemini_tls.py, gemtext.py, tofu.py, client_certs.py); a # PyPI or registry search for "gemini" matched nothing before, since only the # free-text summary mentioned it. keywords = [ "mcp", "gopher", "gemini", "gemtext", "protocol", "llm", "model-context-protocol", ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", # 3.14 is claimed because ci.yml runs the full suite on it across # ubuntu/windows/macos. Every version listed here must have a matrix leg; # this list is a promise to PyPI, not an aspiration. "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Networking", "Topic :: Communications", "Topic :: Internet :: WWW/HTTP :: Browsers", "Framework :: AsyncIO", "Typing :: Typed", ] requires-python = ">=3.11" dependencies = [ # Capped at <2 deliberately: mcp 2.x renamed FastMCP to MCPServer and changed # other APIs, so the server this package ships fails to import against it. # CI never sees that -- `uv sync --locked` installs the locked 1.x -- but a # plain `pip install gopher-mcp` resolves the newest mcp and would crash on # startup. Lift the cap only together with the migration. # # What should trigger that migration, recorded because "later" is how a cap # becomes permanent and this one has now been re-investigated from scratch # twice: # 1. Something a user would plausibly install alongside this requires # mcp>=2. ALREADY TRUE as of 2026-09-03, so this is a countdown rather # than a tripwire: llama-index-tools-mcp moved to `mcp<3,>=2.0.0` at # 0.5.0 (2026-08-11), and `uv pip install gopher-mcp==0.9.0 # llama-index-tools-mcp==0.6.0` already reports the two incompatible. # Unpinned, the resolver quietly walks that package back to 0.4.8 # (2026-02-24) to keep mcp at 1.x, which is worse than an error. # 2. An advisory lands against 1.x that 2.x fixes and 1.x does not # backport. Not the case today -- 1.x is still patched in lockstep with # 2.x, and `pip-audit` is clean above this floor. # 3. The MCP registry, or a client this server is documented against, # stops accepting a 1.x server. # The port is scoped, not open-ended: ~35 source lines across server.py and # __main__.py, `mypy --strict` clean, with the test fallout mechanical # (camelCase -> snake_case keyword renames). Two traps found by executing # it: `ruff format` fails on a snake_case line that crosses 88 chars, and # mcp 2.x requires pydantic>=2.12.0, so the floor below moves with it. # tests/test_mcp_protocol.py is the wire-level guard written for this port. # # The floor is the real minimum, not a guess. It started at 1.10.0, the # first release whose `FastMCP.tool()` accepts `title=`, which every tool # here passes; below that the failures are import-time and # resolver-invisible (<1.2 has no mcp.server.fastmcp at all, <1.7 no # ToolAnnotations). It is now set by security instead: 1.28.1 is the first # release clear of four high advisories the old `>=1.10.0` range admitted -- # GHSA-9h52-p55h-vw2f (DNS-rebinding protection off by default), # GHSA-jpw9-pfvf-9f58, GHSA-hvrp-rf83-w775 and GHSA-vj7q-gjh5-988w. # Only the last of those actually sets this number -- the rebinding one was # fixed in 1.23.0 and the middle two in 1.27.2, so 1.28.1 is # GHSA-vj7q-gjh5-988w's floor and the rest are already covered by it. # Rebinding still deserves the word, because which deployment it protects is # easy to read backwards: server.py builds FastMCP without # `transport_security`, so a plain `gopher-mcp --transport streamable-http` # inherits the SDK's own loopback allowlist and that default is the only # thing guarding it. The container is the exception rather than the example # -- its CMD passes `--host 0.0.0.0` with no `--allowed-host`, which # deliberately yields `enable_dns_rebinding_protection=False` rather than # guess the name clients will use for a wildcard bind. Pass `--allowed-host` # when exposing it. # A fresh `pip install` resolves the newest mcp and was always safe; this # floor is what stops the server running inside an agent venv that already # pins an old SDK -- the case the minimum-versions CI job exercises. # Keep all three components -- Dependabot derives the current version by # scraping \d+\.\d+\.\d+ from this string, and a two-part floor like # >=1.28 makes the `ignore` rule that holds the cap silently inert. "mcp>=1.28.1,<2", # Floor governed by mcp, not by anything this code needs: mcp 1.28.1 # requires pydantic>=2.11.0, so any lower number here is unreachable and # only pretends to be a tested minimum. Move it whenever the mcp floor # moves. "pydantic>=2.11.0", # config.py imports NoDecode, which landed in 2.7.0. The mcp cap gives no # cover here: mcp itself only asks for >=2.5.2, so 2.5.2-2.6.1 resolve # cleanly next to the newest permitted mcp and then fail at import. "pydantic-settings>=2.7.0", "structlog>=23.2.0", # No anyio here on purpose: nothing in src/ imports it (the code is stdlib # asyncio throughout), and mcp already requires anyio>=4.5, so declaring it # only widened the surface this project claims to own and audit while its # >=4.0.0 floor could never bind. # # Floor is a real CVE line, not a round number: a fresh `pip install` # resolves the newest cryptography anyway, but this refuses to run against # an older version an end user might already have pinned. 43.0.0 no longer # qualified -- it still admitted GHSA-537c-gmf6-5ccf, GHSA-r6ph-v2qm-q3c2, # GHSA-jwv3-5hgf-82ww and GHSA-g6cj-pr64-35w5. 50.0.0 is the first release # clear of all four (48.0.1 clears only the first two), so the promise this # comment makes is true again. If you lower it, the promise goes with it. "cryptography>=50.0.0", # Declared because src/ imports it directly: server.py's FastMCP subclass # serves the sse/streamable-http transports itself (with log_config # disabled, so uvicorn's records reach our handler instead of the ones its # own dictConfig would install). That is the same test the anyio note above # applies -- anyio stays undeclared precisely because nothing in src/ # imports it. Floor is governed by mcp, which # already requires uvicorn>=0.31.1 off emscripten, so anything lower is # unreachable and would only pretend to be a tested minimum; no ceiling, # since the two settings used here (host/port and log_config) are the # oldest, most stable part of uvicorn's API. "uvicorn>=0.31.1", ] # Tooling lives in PEP 735 [dependency-groups], NOT [project.optional-dependencies]. # Extras are published metadata: as extras these were a permanent, versioned # `pip install gopher-mcp[dev]` surface on PyPI that nothing in README.md, # CONTRIBUTING.md or docs/ ever asked anyone to use, and that we would then owe # support for. Groups are local to this repo and never reach the wheel. # # Consequence for anything that installs this project: it is `uv sync # --all-groups` (not `--all-extras`), `uv sync --no-default-groups --group # docs` (not `--extra docs`) and `uv sync --resolution lowest-direct # --no-default-groups --group test` (not `--extra test`). A named `--extra # docs` now errors out, but `--all-extras` does NOT -- it succeeds and # installs no tooling at all, so a workflow left on the old flag goes green # while running its checks against nothing. Grep for `--all-extras` before # assuming it is gone. # # `--no-default-groups` in those two is not decoration: `dev` is uv's default # group and now includes `test`, so a bare `--group docs` or `--group test` # installs every linter as well. The floors job in ci.yml would then resolve # ruff/mypy/twine to their oldest permitted releases and fail on tooling that # has nothing to do with the runtime floors it exists to test. [dependency-groups] # `dev` includes `test` rather than restating it: the two lists used to repeat # five entries verbatim, so a floor bumped in one quietly drifted from the other. dev = [ { include-group = "test" }, # Neither of these two carries a ceiling any more. Both used to, to keep a # `uv lock --upgrade` from turning the repo red on a day nobody changed any # code, and both were meant to be lifted by a deliberate commit that also # did the work the new release asked for. This is that commit. A linter # ceiling is not free: it pins CI to one rule set while the declared range # lets anyone installing from PyPI resolve a newer one, so the two disagree # about what "lint clean" means and the disagreement only surfaces the day # somebody bumps the lock. Answer a new rule when it lands instead. # # mypy: the 1.19 split that used to justify one -- `untyped-decorator` # reported separately from `misc`, which made the SDK-driven ignore on the # `@mcp.custom_route("/health")` decorator in server.py both unused and # insufficient at once -- is answered at that decorator instead, so the # suppression names the code the diagnostic actually carries. # # The floor moved with it, and is a real minimum rather than a guess: # server.py now spells that ignore `[untyped-decorator]`, a code no mypy # below 1.19 knows, so on 1.7-1.18 the decorator's `misc` error goes # unsuppressed and `mypy src` fails. Lower this only together with # widening that ignore back out. "mypy>=1.19.0", # ruff: the 0.15/0.16 rules that used to justify one have been answered # rather than deferred: RUF036 by reordering the unions in # config.py, UP042 by making GemtextLineType a StrEnum, PLR0917 by making # the tail of four long signatures keyword-only, and PLW0108 by a # file-local noqa on the one lambda that has to stay a lambda. 0.16 also # formats Python inside Markdown fences, and those fences are now # formatted, so `ruff format --check .` covers every .md in the repo. "ruff>=0.16.5", "pre-commit>=3.5.0", "bandit>=1.7.5", "pip-audit>=2.7.0", # 6.x caps core metadata at 2.4 (it monkeypatches packaging), which # rejects the 2.5 metadata hatchling >=1.32 emits. "twine>=7.0.0", "taskipy>=1.14.1", ] docs = [ "mkdocs>=1.5.0", "mkdocs-material>=9.4.0", "mkdocs-autorefs>=0.5.0", "mkdocstrings[python]>=0.24.0", "pymdown-extensions>=10.0.0", # Three pages were merged away (advanced-features, gemini-configuration, # task-runner) and their URLs are already published and linked from # outside the repo, so removing them without a redirect turns live links # into 404s. The `redirects` plugin entry that maps the old paths to the # surviving ones lives in mkdocs.yml; this only makes the plugin # installable. Retire a mapping only when the inbound traffic has. "mkdocs-redirects>=1.2.1", ] # Kept as its own group so the minimum-versions CI job can install exactly the # test tooling against the declared floors. test = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0", "pytest-mock>=3.11.0", # Required, not optional tooling: [tool.pytest.ini_options] sets `timeout`, # and --strict-config turns an unknown ini key into a hard error, so a # pytest run without this plugin does not start. "pytest-timeout>=2.3.0", "coverage[toml]>=7.3.0", "hypothesis>=6.100.0", ] [project.urls] Homepage = "https://github.com/cameronrye/gopher-mcp" Documentation = "https://cameronrye.github.io/gopher-mcp" Repository = "https://github.com/cameronrye/gopher-mcp" Issues = "https://github.com/cameronrye/gopher-mcp/issues" Changelog = "https://github.com/cameronrye/gopher-mcp/blob/main/CHANGELOG.md" [project.scripts] gopher-mcp = "gopher_mcp.server:main" [tool.hatch.build.targets.wheel] packages = ["src/gopher_mcp"] [tool.hatch.build.targets.sdist] include = ["/src", "/tests", "/docs", "/README.md", "/LICENSE", "/CHANGELOG.md"] # Ruff configuration [tool.ruff] target-version = "py311" line-length = 88 src = ["src", "tests"] [tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade "ARG", # flake8-unused-arguments "SIM", # flake8-simplify "TCH", # flake8-type-checking "PTH", # flake8-use-pathlib "ERA", # eradicate "PL", # pylint "RUF", # ruff-specific rules ] ignore = [ "E501", # line too long, handled by formatter # Opinionated pylint complexity/style rules we deliberately opt out of. "PLR0911", # too many return statements "PLR0912", # too many branches "PLR0913", # too many arguments "PLR0915", # too many statements "PLR2004", # magic value in comparison (noisy in protocol/status-code code) "PLW0603", # use of global statement (intentional module-level singletons) ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "S101", # assert is the point of a test "ARG", # unused fixture args are common "SIM117", # nested `with` is fine for stacked mock patches "PTH123", # tests open fixture files directly "PLC0415", # function-local imports keep a test's setup next to its assertions ] # Allow assert/magic values and pragmatic patterns in tests # Release scripts import lazily inside the step that needs the module. "scripts/**/*" = ["PLC0415"] [tool.ruff.lint.isort] known-first-party = ["gopher_mcp"] [tool.ruff.format] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" # MyPy configuration [tool.mypy] python_version = "3.11" strict = true warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true disallow_untyped_decorators = true no_implicit_optional = true warn_redundant_casts = true warn_unused_ignores = true warn_no_return = true warn_unreachable = true strict_equality = true show_error_codes = true [[tool.mypy.overrides]] module = ["mcp.*"] ignore_missing_imports = true # Pytest configuration [tool.pytest.ini_options] minversion = "7.4" addopts = [ "--strict-markers", "--strict-config", "--cov=src/gopher_mcp", "--cov-report=term-missing", "--cov-report=html", "--cov-report=xml", # Set just under what the suite actually measures (97%), not at a round # aspirational number. At 85 the gate had ~350 statements of slack: a # ten-point regression could land green. Raise this when the real figure # rises; lowering it to make a red build pass is how the slack came back. "--cov-fail-under=95", ] testpaths = ["tests"] # A per-test ceiling, on top of the per-job `timeout-minutes` in the workflows. # Those cap a hung job at 20 minutes but say nothing about WHICH test hung; this # names it. Note what it does not do: the "thread" method below cannot fail one # test and carry on -- it kills the process, so the run ends at the offending # test with a stack dump and no pytest summary, and the tests after it never # run. That is the right trade for a hang (a name and a stack beat a silent # 20-minute wall) but it means a timeout aborts the build rather than reporting # one failure among many. 60s is ~6x the slowest test in # the suite (the whole suite runs in well under two minutes), so it can only # fire on a genuine hang -- a socket read that stops honouring its deadline, a # TLS handshake stalled on a runner. Raise it if a legitimately slow test # appears; do not lower it towards the observed maximum, or ordinary # runner-side jitter starts failing builds. timeout = 60 # "thread" rather than "signal": the signal method needs SIGALRM, which does # not exist on the Windows matrix leg, and the thread method dumps every # thread's stack on expiry -- which is the whole point, since a hang here is # usually parked in asyncio or a socket read. timeout_method = "thread" asyncio_mode = "auto" markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", "unit: marks tests as unit tests", ] # Coverage configuration [tool.coverage.run] source = ["src/gopher_mcp"] branch = true # __main__.py is deliberately NOT omitted: tests/test_server.py imports and # calls its main(), so omitting it hid a file the suite already exercises -- # including the Ctrl-C shutdown path (KeyboardInterrupt -> cleanup()) that every # operator hits and that had no coverage signal at all. omit = ["*/tests/*", "*/test_*"] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "def __repr__", "if self.debug:", "if settings.DEBUG", "raise AssertionError", "raise NotImplementedError", "if 0:", "if __name__ == .__main__.:", "class .*\\bProtocol\\):", "@(abc\\.)?abstractmethod", ] # Task runner configuration. # # This is the ONLY task table. task.py used to hold a hand-maintained copy of # it that nothing compared, and the two had already drifted (the `ruff check .` # fix in e2d70fe had to be applied twice); composite tasks like `check` shelled # out to `uv run task`, so the copies were reachable only via `python task.py # ` and could behave differently from the same-named task here. task.py # is gone and the Makefile is a thin catch-all onto `uv run task`. Add a task # here and it is available from `uv run task ` and `make ` at once. [tool.taskipy.tasks] # `make` with no target lands here (Makefile .DEFAULT_GOAL), and it is also the # discovery command task.py's help screen used to be. help = "task --list" # Development setup dev-setup = "bash scripts/dev-setup.sh" # Windows has no bash; this is the counterpart of `clean`/`clean-win`. Ported # from task.py, which picked the platform at runtime -- taskipy cannot, so the # platform is in the task name. dev-setup-win = "scripts\\dev-setup.bat" install-hooks = "pre-commit install" # Code quality (whole-repo to match CI's `ruff check .`; scoping to src/tests # hides violations in scripts/ and other top-level Python that CI still fails on) lint = "ruff check ." format = "ruff format ." # Match CI's `mypy src` exactly: --ignore-missing-imports globally suppresses # missing-stub errors, so a new untyped import passes locally but fails CI. # The scoped [[tool.mypy.overrides]] for genuinely stub-less packages covers # the legitimate case. typecheck = "mypy src" quality = "uv run task lint && uv run task typecheck && uv run task test" # Testing test = "pytest tests/ -v" test-cov = "pytest tests/ -v --cov=src/gopher_mcp --cov-report=term-missing --cov-report=html" test-unit = "pytest tests/ -v -m 'not integration and not slow'" test-integration = "pytest tests/ -v -m integration" test-slow = "pytest tests/ -v -m slow" # Server operations serve = "python -m gopher_mcp" serve-http = "python -m gopher_mcp --transport streamable-http" serve-sse = "python -m gopher_mcp --transport sse" # Documentation docs-serve = "mkdocs serve" docs-build = "mkdocs build" # Maintenance clean = "rm -rf .pytest_cache .coverage htmlcov dist build *.egg-info .mypy_cache .ruff_cache" clean-win = "if exist .pytest_cache rmdir /s /q .pytest_cache && if exist .coverage del .coverage && if exist htmlcov rmdir /s /q htmlcov && if exist dist rmdir /s /q dist && if exist build rmdir /s /q build && for /d %%i in (*.egg-info) do rmdir /s /q \"%%i\" && if exist .mypy_cache rmdir /s /q .mypy_cache && if exist .ruff_cache rmdir /s /q .ruff_cache" # Combined workflows check = "uv run task lint && uv run task typecheck" ci = "uv run task check && uv run task test-cov" [tool.coverage.html] directory = "htmlcov" # Bandit security linting [tool.bandit] exclude_dirs = ["tests"] skips = ["B101"] # Skip assert_used test # Additional tool configurations can be added here as needed