From 66ba683ee1deb9f63975c4e528246017fa0287e9 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 10 Sep 2026 20:51:43 -0400 Subject: [PATCH] feat(mcp): migrate to mcp 2.x (MCPServer) (#10) Co-authored-by: claude --- .gitea/workflows/image-only.yml | 14 ++++++++++++++ .gitea/workflows/refresh.yml | 15 +++++++++++++++ docs_mcp/server.py | 32 +++++++++++++++++++------------- requirements.txt | 9 ++------- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/.gitea/workflows/image-only.yml b/.gitea/workflows/image-only.yml index e33d711..61e6ab0 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 01b0383..3442a40 100644 --- a/.gitea/workflows/refresh.yml +++ b/.gitea/workflows/refresh.yml @@ -92,6 +92,9 @@ jobs: # out of requirements.txt so they never bloat the server image. python -m pip install -q -r requirements-vision.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 @@ -221,6 +224,18 @@ 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. + 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 # Idempotent linkage so the package shows under the repo's # Packages tab. Gitea's auto-link from the source label is diff --git a/docs_mcp/server.py b/docs_mcp/server.py index df79356..0371f43 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") # --------------------------------------------------------------------------- @@ -1140,14 +1140,20 @@ 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. 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 # 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