feat(mcp): migrate to mcp 2.x (MCPServer)

Lift the mcp<2 pin landed in de2560c. Three mechanical changes from
the seed-mcp#23 port: MCPServer instead of FastMCP, transport options
moved onto run(), and CI import smoke so a green build cannot ship a
non-importing image.

tools/list is byte-for-byte identical between 1.30.0 and 2.2.0 (10
tools). initialize over streamable-http returns 200.

Closes #9
This commit is contained in:
2026-09-10 20:47:43 -04:00
parent b63b623514
commit 819eb5349b
4 changed files with 50 additions and 20 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
@@ -92,6 +92,9 @@ jobs:
# out of requirements.txt so they never bloat the server image. # out of requirements.txt so they never bloat the server image.
python -m pip install -q -r requirements-vision.txt python -m pip install -q -r requirements-vision.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
@@ -221,6 +224,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
# 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.
if: steps.commit.outputs.changed == 'true' || inputs.force_build == true
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
+19 -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")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1140,14 +1140,20 @@ 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. Required in prod — see CLAUDE.md.
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