deps: migrate to mcp 2.x (lift the <2 pin)

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FFBDnRWHispovmJVK9rXc9
This commit is contained in:
2026-09-10 19:47:32 -04:00
co-authored by Claude Opus 5
parent 98842d1ed6
commit 83af1ce3f0
6 changed files with 52 additions and 25 deletions
+7
View File
@@ -92,6 +92,13 @@ jobs:
-t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \ -t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \
-t "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_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}:latest"
docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}"
docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}"
+7
View File
@@ -156,6 +156,13 @@ jobs:
-t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \ -t "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" \
-t "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_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}:latest"
docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${SHA_TAG}"
docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}" docker push "${REGISTRY_PUSH}/${IMAGE}:${CORPUS_TAG}"
+14 -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
@@ -124,7 +124,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),
run with `stateless_http=True`
- **Container deploy**: Watchtower auto-pull on `:latest`, rollback - **Container deploy**: Watchtower auto-pull on `:latest`, rollback
via `:<sha12>` pin via `:<sha12>` pin
@@ -133,7 +134,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`
@@ -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 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 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 - **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-
+1 -1
View File
@@ -109,7 +109,7 @@ PRODUCT_NAME=crop_chem python -m docs_mcp.server --transport stdio
│ ├── index.py # Chroma + BM25 builder │ ├── index.py # Chroma + BM25 builder
│ └── bm25.py # FTS5 lexical index │ └── bm25.py # FTS5 lexical index
├── docs_mcp/ ├── docs_mcp/
│ ├── server.py # FastMCP — hybrid+rerank │ ├── server.py # MCPServer (mcp 2.x) — hybrid+rerank
│ ├── lessons.md # Curated knowledge layer │ ├── lessons.md # Curated knowledge layer
│ └── usage.py # TimedCall + JSONL telemetry │ └── usage.py # TimedCall + JSONL telemetry
├── eval/ ├── eval/
+21 -9
View File
@@ -22,9 +22,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
@@ -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": 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
# (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"}: if os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"}:
mcp.settings.transport_security.enable_dns_rebinding_protection = False # Deployed as `chem-mcp:8080` on Drawbar's internal docker network
mcp.run(transport=args.transport) # 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__": if __name__ == "__main__":
+1 -7
View File
@@ -1,11 +1,5 @@
# MCP server # MCP server
# Pinned below 2.0: mcp 2.0.0 removed `mcp.server.fastmcp`, which this mcp>=2,<3 # 2.x: no [fastmcp] extra; FastMCP -> MCPServer
# 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
pydantic>=2.0 pydantic>=2.0
httpx>=0.27 httpx>=0.27