feat(mcp): migrate to mcp 2.x (MCPServer), lift the <2 pin

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QBx2P1VhKQEzJWoZ96QcnH
This commit is contained in:
2026-09-10 22:34:52 -04:00
co-authored by Claude Opus 5
parent e9fc4e841b
commit 841e87c57b
5 changed files with 62 additions and 27 deletions
+14
View File
@@ -59,6 +59,9 @@ jobs:
python -m pip install -q --upgrade pip python -m pip install -q --upgrade pip
python -m pip install -q -r requirements.txt python -m pip install -q -r requirements.txt
- name: Smoke — server module imports
run: python -c "import docs_mcp.server"
- name: Refresh digest history - name: Refresh digest history
# Cheap (few seconds). Without this step, a code-only deploy # Cheap (few seconds). Without this step, a code-only deploy
# would ship an increasingly-stale digest history. # would ship an increasingly-stale digest history.
@@ -124,6 +127,17 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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 - name: Link container package to this repo
env: env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+15
View File
@@ -77,6 +77,9 @@ jobs:
python -m pip install -q --upgrade pip python -m pip install -q --upgrade pip
python -m pip install -q -r requirements.txt python -m pip install -q -r requirements.txt
- name: Smoke — server module imports
run: python -c "import docs_mcp.server"
# ---- Phase 1: scrape --------------------------------------- # ---- Phase 1: scrape ---------------------------------------
- name: Refresh bundle catalog - name: Refresh bundle catalog
run: python -m scrape.bundles run: python -m scrape.bundles
@@ -206,6 +209,18 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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 - name: Link container package to this repo
# Idempotent linkage so the package shows under the repo's # Idempotent linkage so the package shows under the repo's
# Packages tab. Gitea's auto-link from the source label is # Packages tab. Gitea's auto-link from the source label is
+10 -7
View File
@@ -68,7 +68,7 @@ Cursor, etc.).
│ ├── index.py # Builds Chroma + BM25 │ ├── index.py # Builds Chroma + BM25
│ └── bm25.py # SQLite FTS5 lexical index │ └── bm25.py # SQLite FTS5 lexical index
├── docs_mcp/ # Phase 3+ — MCP server ├── docs_mcp/ # Phase 3+ — MCP server
│ ├── server.py # FastMCP + tool definitions │ ├── server.py # MCPServer + tool definitions
│ └── usage.py # TimedCall telemetry │ └── usage.py # TimedCall telemetry
├── eval/ # Phase 7 — golden-query harness ├── eval/ # Phase 7 — golden-query harness
│ ├── queries.jsonl.example │ ├── queries.jsonl.example
@@ -139,7 +139,8 @@ need:
- **Lexical store**: SQLite FTS5 (stdlib) - **Lexical store**: SQLite FTS5 (stdlib)
- **Fusion**: Reciprocal Rank Fusion with k=60 - **Fusion**: Reciprocal Rank Fusion with k=60
- **Transport**: streamable-HTTP in prod, stdio for local dev - **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 - **Container deploy**: Watchtower auto-pull on `:latest`, rollback
via `:<sha12>` pin via `:<sha12>` pin
@@ -148,7 +149,7 @@ need:
The template uses `PRODUCT_NAME` env var (defaults to `"myproduct"`) The template uses `PRODUCT_NAME` env var (defaults to `"myproduct"`)
throughout. Set it on first build. References show up in: 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 (`<product>_docs`) - Collection name (`<product>_docs`)
- BM25 db filename - BM25 db filename
- Tool names that include the product name (e.g., the `_api_lessons` - 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 ENTIRE batch if any doc exceeds `n_ctx_train=1024`. Truncate docs
to ~2000 chars before sending to rerank. Full chunk text still to ~2000 chars before sending to rerank. Full chunk text still
goes back to the user; truncation is reranking-only. goes back to the user; truncation is reranking-only.
- **FastMCP `stateless_http=True`**: critical for production - **`stateless_http=True`**: critical for production hosting
hosting behind Watchtower auto-updates. Without it, every behind Watchtower auto-updates. Without it, every container
container recreate produces a 404 storm from clients with recreate produces a 404 storm from clients with stale session
stale session IDs. 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 - **Runner shell is `/bin/sh` (dash)**: no `${VAR::N}` substring
expansion in workflow scripts. Use `cut`/`awk`/`printf`. expansion in workflow scripts. Use `cut`/`awk`/`printf`.
- **Cloudflare 100 MB body cap**: if pushing through a Cloudflare- - **Cloudflare 100 MB body cap**: if pushing through a Cloudflare-
+21 -13
View File
@@ -25,9 +25,10 @@ import logging
import os import os
import re import re
from pathlib import Path 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 pydantic import Field
from .usage import TimedCall 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 # mcp 2.x moved every transport option (stateless_http, host, port,
# discards it on return. Critical for production: clients don't get # transport_security, ...) off the constructor and onto run() — see main().
# 404 storms when the container is recreated by Watchtower.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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": if args.transport == "stdio":
mcp.run() mcp.run()
else: return
mcp.settings.host = args.host
mcp.settings.port = args.port # 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 # DNS-rebinding protection defaults to localhost-only — disable for
# container-network DNS hostnames. See PLAN.md "Hosting" notes. # container-network DNS hostnames. See PLAN.md "Hosting" notes.
if os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"}: run_kwargs["transport_security"] = TransportSecuritySettings(
mcp.settings.transport_security.enable_dns_rebinding_protection = False enable_dns_rebinding_protection=False,
mcp.run(transport=args.transport) )
mcp.run(transport=args.transport, **run_kwargs)
if __name__ == "__main__": if __name__ == "__main__":
+2 -7
View File
@@ -1,11 +1,6 @@
# MCP server # MCP server
# Pinned below 2.0: mcp 2.0.0 removed `mcp.server.fastmcp`, which this # 2.x: no [fastmcp] extra; FastMCP -> MCPServer (see docs_mcp/server.py).
# server imports. The unpinned floor pulled 2.0.0 into a rebuild on mcp>=2,<3
# 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
pydantic>=2.0 pydantic>=2.0
httpx>=0.27 httpx>=0.27