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