From 83af1ce3f01569faf79020d1db56df49df5c20c9 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 10 Sep 2026 19:47:32 -0400 Subject: [PATCH] deps: migrate to mcp 2.x (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`; the `<2` ceiling in 23c7a3d stopped the bleeding but left this server on the previous major. Ports to the 2.x API, following the same shape seed-mcp shipped. - requirements.txt: `mcp[fastmcp]>=1.0.0,<2` -> `mcp>=2,<3` (2.x has no [fastmcp] extra) - server.py: FastMCP -> mcp.server.mcpserver.MCPServer; `mcp.settings` is gone, so host/port/stateless_http/transport_security are now run() kwargs - stateless_http is gated to streamable-http. Under 1.x it was a constructor arg and so applied to sse too; sse is a dev-only path here, and this matches seed-mcp. - CI: both workflows now run `python -c "import docs_mcp.server"` against the built image before pushing it. This is the durable half — a green build can no longer ship a non-importing image. - CLAUDE.md / README.md: correct the FastMCP references. PLAN.md is left alone; it tracks the upstream template, not this repo. Verification (mcp 1.27.1 -> 2.2.0): - tools/list dumped in wire format is byte-for-byte identical, md5 cea0326f59b55abbbf47c9679252d38f both sides. All 5 tools (search_docs, get_page, list_versions, corpus_status, crop_chem_api_lessons) unchanged, so routing is unaffected. - streamable-http: `initialize` -> 200, no mcp-session-id header (stateless_http confirmed active); tools/list and a real tools/call over the wire both succeed. - stdio: initialize + tools/list both fine. - Image built; the new CI smoke command passes against it. corpus_status reads the baked indexes (4,164 labels / 216,467 chunks) and a live `search_docs` returns hits with mode=hybrid-rrf against Ollama on .0.125. No eval numbers: this touches transport and packaging only. The chunker, embedder, Chroma/BM25 stores, RRF and the reranker are all untouched, and the live search above confirms retrieval still runs. Note: `serverInfo.version` now reports "" instead of the SDK version (2.x behaviour for an unversioned server). Cosmetic, but visible to clients. httpx2 + opentelemetry-api come in as 2.x deps and coexist with the existing httpx 0.28.1 pin, as expected. Closes #4 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFBDnRWHispovmJVK9rXc9 --- .gitea/workflows/image-only.yml | 7 +++++++ .gitea/workflows/refresh.yml | 7 +++++++ CLAUDE.md | 21 ++++++++++++++------- README.md | 2 +- docs_mcp/server.py | 32 ++++++++++++++++++++++---------- requirements.txt | 8 +------- 6 files changed, 52 insertions(+), 25 deletions(-) diff --git a/.gitea/workflows/image-only.yml b/.gitea/workflows/image-only.yml index 9b2b4c8..acbcdca 100644 --- a/.gitea/workflows/image-only.yml +++ b/.gitea/workflows/image-only.yml @@ -92,6 +92,13 @@ jobs: -t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \ -t "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" \ . + # Smoke: the image must at least import its server module before we + # publish it. mcp 2.x renamed mcp.server.fastmcp -> mcp.server.mcpserver, + # so an unpinned dep produced a green build that crash-looped in prod. + # Import is side-effect-free here (lazy singletons), so this needs no + # Ollama/Chroma. + docker run --rm --entrypoint python \ + "${REGISTRY_PUSH}/${IMAGE}:latest" -c "import docs_mcp.server" docker push "${REGISTRY_PUSH}/${IMAGE}:latest" docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" diff --git a/.gitea/workflows/refresh.yml b/.gitea/workflows/refresh.yml index 7599a08..e3286ce 100644 --- a/.gitea/workflows/refresh.yml +++ b/.gitea/workflows/refresh.yml @@ -156,6 +156,13 @@ jobs: -t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \ -t "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" \ . + # Smoke: the image must at least import its server module before we + # publish it. mcp 2.x renamed mcp.server.fastmcp -> mcp.server.mcpserver, + # so an unpinned dep produced a green build that crash-looped in prod. + # Import is side-effect-free here (lazy singletons), so this needs no + # Ollama/Chroma. + docker run --rm --entrypoint python \ + "${REGISTRY_PUSH}/${IMAGE}:latest" -c "import docs_mcp.server" docker push "${REGISTRY_PUSH}/${IMAGE}:latest" docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" diff --git a/CLAUDE.md b/CLAUDE.md index d8bc415..90d88de 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 @@ -124,7 +124,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), + run with `stateless_http=True` - **Container deploy**: Watchtower auto-pull on `:latest`, rollback via `:` pin @@ -133,7 +134,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` @@ -200,10 +201,16 @@ 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 this is a `mcp.run()` kwarg, not a + constructor arg — see `main()` in `docs_mcp/server.py`. +- **mcp 2.x, not 1.x**: `mcp.server.fastmcp` was removed in 2.0.0. + This server uses `mcp.server.mcpserver.MCPServer`; `mcp.settings` + no longer exists (host/port/transport_security are `run()` + kwargs). PLAN.md still describes the 1.x API — it tracks the + upstream template, not this repo. - **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/README.md b/README.md index 4f865de..cf6e007 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ PRODUCT_NAME=crop_chem python -m docs_mcp.server --transport stdio │ ├── index.py # Chroma + BM25 builder │ └── bm25.py # FTS5 lexical index ├── docs_mcp/ -│ ├── server.py # FastMCP — hybrid+rerank +│ ├── server.py # MCPServer (mcp 2.x) — hybrid+rerank │ ├── lessons.md # Curated knowledge layer │ └── usage.py # TimedCall + JSONL telemetry ├── eval/ diff --git a/docs_mcp/server.py b/docs_mcp/server.py index 1cc2e6b..10c51fd 100644 --- a/docs_mcp/server.py +++ b/docs_mcp/server.py @@ -22,9 +22,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 @@ -61,9 +62,9 @@ RRF_K = int(os.environ.get("RRF_K", "60")) # --------------------------------------------------------------------------- -# FastMCP setup. +# MCPServer setup. # --------------------------------------------------------------------------- -mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True) +mcp = MCPServer(f"{PRODUCT_NAME}-docs") # --------------------------------------------------------------------------- @@ -719,12 +720,23 @@ def main() -> None: if args.transport == "stdio": mcp.run() - else: - mcp.settings.host = args.host - mcp.settings.port = args.port - 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) + 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 + # (without it, every Watchtower recreate is a 404 storm from clients + # holding stale session IDs). + run_kwargs["stateless_http"] = True + if os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"}: + # Deployed as `chem-mcp:8080` on Drawbar's internal docker network + # and never published to a host port, so the Host header the rebind + # check rejects is the only one we ever see. + 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 d6771ec..6ccfebe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,5 @@ # 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 +mcp>=2,<3 # 2.x: no [fastmcp] extra; FastMCP -> MCPServer pydantic>=2.0 httpx>=0.27