From 841e87c57bbff60bd8fc1b14aed25ce6f00ed324 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Thu, 10 Sep 2026 22:34:52 -0400 Subject: [PATCH] feat(mcp): migrate to mcp 2.x (MCPServer), lift the <2 pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp 2.0.0 removed `mcp.server.fastmcp`. This repo was pinned to <2 (6723829) to stop a rebuild from crash-looping the container the way seed-mcp (2026-09-01) and zerto-docs (2026-08-11) did. This lifts the pin by doing the port — the same mechanical change as justin/seed-mcp#23 and justin/morpheus-docs#10. - import: FastMCP -> MCPServer, plus TransportSecuritySettings - constructor takes no transport options: MCPServer(f"{PRODUCT_NAME}-docs") - host/port/stateless_http/transport_security are run() kwargs; `mcp.settings` no longer exists - requirement becomes plain `mcp>=2,<3` (2.x has no [fastmcp] extra) @mcp.tool() decorators and all handler signatures are unchanged. CI now smokes `import docs_mcp.server` twice in both workflows — once after pip install, once inside the built image — so a green build can never ship a non-importing container again. Verified on python:3.12-slim (matches the image): - tools/list dumped in wire format is BYTE-FOR-BYTE IDENTICAL between mcp 1.30.0 and 2.2.0, all 10 tools - streamable-http boots; initialize returns HTTP 200, both from localhost and with a container-DNS `Host: hvm-docs-mcp:8000` header (passing host= to run() keeps DNS-rebinding protection off) - no mcp-session-id response header, i.e. stateless_http is in effect - stdio boots; initialize + tools/list return 10 tools - production image builds and imports under mcp 2.2.0; httpx 0.28.1 and httpx2 2.12.0 coexist as expected Retrieval is untouched, so no eval numbers are included. Closes #12 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QBx2P1VhKQEzJWoZ96QcnH --- .gitea/workflows/image-only.yml | 14 ++++++++++++++ .gitea/workflows/refresh.yml | 15 +++++++++++++++ CLAUDE.md | 17 ++++++++++------- docs_mcp/server.py | 34 ++++++++++++++++++++------------- requirements.txt | 9 ++------- 5 files changed, 62 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/image-only.yml b/.gitea/workflows/image-only.yml index ff253eb..576fb8d 100644 --- a/.gitea/workflows/image-only.yml +++ b/.gitea/workflows/image-only.yml @@ -59,6 +59,9 @@ jobs: python -m pip install -q --upgrade pip python -m pip install -q -r requirements.txt + - name: Smoke — server module imports + run: python -c "import docs_mcp.server" + - name: Refresh digest history # Cheap (few seconds). Without this step, a code-only deploy # would ship an increasingly-stale digest history. @@ -124,6 +127,17 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + - name: Smoke — image must import docs_mcp.server + # mcp 2.x renamed mcp.server.fastmcp -> mcp.server.mcpserver, so an + # unpinned dep produced a green build that crash-looped in prod + # (seed-mcp 2026-09-01, zerto-docs 2026-08-11). Import is + # side-effect-free here (lazy singletons), so this needs no + # Ollama/Chroma. + run: | + IMAGE="${{ steps.repo.outputs.owner }}/${{ steps.repo.outputs.name }}" + docker run --rm --entrypoint python \ + "${REGISTRY_PUSH}/${IMAGE}:latest" -c "import docs_mcp.server" + - name: Link container package to this repo env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/.gitea/workflows/refresh.yml b/.gitea/workflows/refresh.yml index 58e99e6..37333bf 100644 --- a/.gitea/workflows/refresh.yml +++ b/.gitea/workflows/refresh.yml @@ -77,6 +77,9 @@ jobs: python -m pip install -q --upgrade pip python -m pip install -q -r requirements.txt + - name: Smoke — server module imports + run: python -c "import docs_mcp.server" + # ---- Phase 1: scrape --------------------------------------- - name: Refresh bundle catalog run: python -m scrape.bundles @@ -206,6 +209,18 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + - name: Smoke — image must import docs_mcp.server + if: steps.commit.outputs.changed == 'true' || inputs.force_build == true + # mcp 2.x renamed mcp.server.fastmcp -> mcp.server.mcpserver, so an + # unpinned dep produced a green build that crash-looped in prod + # (seed-mcp 2026-09-01, zerto-docs 2026-08-11). Import is + # side-effect-free here (lazy singletons), so this needs no + # Ollama/Chroma. + run: | + IMAGE="${{ steps.repo.outputs.owner }}/${{ steps.repo.outputs.name }}" + docker run --rm --entrypoint python \ + "${REGISTRY_PUSH}/${IMAGE}:latest" -c "import docs_mcp.server" + - name: Link container package to this repo # Idempotent linkage so the package shows under the repo's # Packages tab. Gitea's auto-link from the source label is diff --git a/CLAUDE.md b/CLAUDE.md index 30a8b46..a49c207 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Cursor, etc.). │ ├── index.py # Builds Chroma + BM25 │ └── bm25.py # SQLite FTS5 lexical index ├── docs_mcp/ # Phase 3+ — MCP server -│ ├── server.py # FastMCP + tool definitions +│ ├── server.py # MCPServer + tool definitions │ └── usage.py # TimedCall telemetry ├── eval/ # Phase 7 — golden-query harness │ ├── queries.jsonl.example @@ -139,7 +139,8 @@ need: - **Lexical store**: SQLite FTS5 (stdlib) - **Fusion**: Reciprocal Rank Fusion with k=60 - **Transport**: streamable-HTTP in prod, stdio for local dev -- **MCP framework**: FastMCP with `stateless_http=True` +- **MCP framework**: `mcp.server.mcpserver.MCPServer` (mcp 2.x), + with `stateless_http=True` passed to `run()` - **Container deploy**: Watchtower auto-pull on `:latest`, rollback via `:` pin @@ -148,7 +149,7 @@ need: The template uses `PRODUCT_NAME` env var (defaults to `"myproduct"`) throughout. Set it on first build. References show up in: -- `docs_mcp/server.py` — `FastMCP(f"{PRODUCT_NAME}-docs", ...)` +- `docs_mcp/server.py` — `MCPServer(f"{PRODUCT_NAME}-docs")` - Collection name (`_docs`) - BM25 db filename - Tool names that include the product name (e.g., the `_api_lessons` @@ -211,10 +212,12 @@ python -m scrape.changelog --history-out corpus/.digest/history.jsonl --history- ENTIRE batch if any doc exceeds `n_ctx_train=1024`. Truncate docs to ~2000 chars before sending to rerank. Full chunk text still goes back to the user; truncation is reranking-only. -- **FastMCP `stateless_http=True`**: critical for production - hosting behind Watchtower auto-updates. Without it, every - container recreate produces a 404 storm from clients with - stale session IDs. +- **`stateless_http=True`**: critical for production hosting + behind Watchtower auto-updates. Without it, every container + recreate produces a 404 storm from clients with stale session + IDs. Under mcp 2.x it is a `mcp.run()` kwarg, not a constructor + arg — along with `host`, `port` and `transport_security`. + `mcp.settings` no longer exists. - **Runner shell is `/bin/sh` (dash)**: no `${VAR::N}` substring expansion in workflow scripts. Use `cut`/`awk`/`printf`. - **Cloudflare 100 MB body cap**: if pushing through a Cloudflare- diff --git a/docs_mcp/server.py b/docs_mcp/server.py index c7f6dc9..ec4613c 100644 --- a/docs_mcp/server.py +++ b/docs_mcp/server.py @@ -25,9 +25,10 @@ import logging import os import re from pathlib import Path -from typing import Annotated +from typing import Annotated, Any -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings from pydantic import Field from .usage import TimedCall @@ -65,13 +66,12 @@ RRF_K = int(os.environ.get("RRF_K", "60")) # --------------------------------------------------------------------------- -# FastMCP setup. +# MCPServer setup. # -# stateless_http=True — every request creates an ephemeral session and -# discards it on return. Critical for production: clients don't get -# 404 storms when the container is recreated by Watchtower. +# mcp 2.x moved every transport option (stateless_http, host, port, +# transport_security, ...) off the constructor and onto run() — see main(). # --------------------------------------------------------------------------- -mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True) +mcp = MCPServer(f"{PRODUCT_NAME}-docs") # --------------------------------------------------------------------------- @@ -1141,14 +1141,22 @@ def main() -> None: if args.transport == "stdio": mcp.run() - else: - mcp.settings.host = args.host - mcp.settings.port = args.port + return + + # mcp 2.x: transport options are run() kwargs, and `mcp.settings` is gone. + run_kwargs: dict[str, Any] = {"host": args.host, "port": args.port} + if args.transport == "streamable-http": + # Was a constructor arg under 1.x. Every request creates an ephemeral + # session and discards it on return, so clients don't get 404 storms + # when Watchtower recreates the container. + run_kwargs["stateless_http"] = True + if os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"}: # DNS-rebinding protection defaults to localhost-only — disable for # container-network DNS hostnames. See PLAN.md "Hosting" notes. - if os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"}: - mcp.settings.transport_security.enable_dns_rebinding_protection = False - mcp.run(transport=args.transport) + run_kwargs["transport_security"] = TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ) + mcp.run(transport=args.transport, **run_kwargs) if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 05ca8ed..daf5112 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,6 @@ # MCP server -# Pinned below 2.0: mcp 2.0.0 removed `mcp.server.fastmcp`, which this -# server imports. The unpinned floor pulled 2.0.0 into a rebuild on -# 2026-08-11 and crash-looped zerto-docs with -# "ModuleNotFoundError: No module named 'mcp.server.fastmcp'". -# 2.0 also drops the [fastmcp] extra. Migrating to the 2.x API is a -# deliberate piece of work — do not lift this pin without it. -mcp[fastmcp]>=1.0.0,<2 +# 2.x: no [fastmcp] extra; FastMCP -> MCPServer (see docs_mcp/server.py). +mcp>=2,<3 pydantic>=2.0 httpx>=0.27