Migrate to mcp 2.x (lift the <2 pin) #4

Closed
opened 2026-09-10 14:28:37 -04:00 by claude · 1 comment
Contributor

Context

requirements.txt:8 pins mcp[fastmcp]>=1.0.0,<2. That ceiling exists because mcp 2.0.0
removed mcp.server.fastmcp — an unpinned rebuild on 2026-08-11 crash-looped zerto-docs
with ModuleNotFoundError: No module named 'mcp.server.fastmcp'. The pin (23c7a3d) stopped
the bleeding; it is not a destination. 1.x is the previous major and fixes land on 2.x.

Not urgent — this repo is already pinned and serving. This is deliberate maintenance, hence
P2 rather than the P1 on justin/hvm-docs#12 (that one still has to apply the pin first).

Blast radius in this repo

mcp is imported in exactly one file: docs_mcp/server.py. Nothing under rag/,
scrape/, scripts/ or eval/ touches it.

docs_mcp/server.py:27   from mcp.server.fastmcp import FastMCP
docs_mcp/server.py:66   mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True)
docs_mcp/server.py:723  mcp.settings.host = args.host
docs_mcp/server.py:724  mcp.settings.port = args.port
docs_mcp/server.py:726  mcp.settings.transport_security.enable_dns_rebinding_protection = False
docs_mcp/server.py:727  mcp.run(transport=args.transport)

There is no mcp.get_context() call here, so the Context change does not apply.
All @mcp.tool() decorators and handler signatures stay exactly as they are.

The port — copy-paste from justin/seed-mcp#23

seed-mcp shares this docs_mcp/server.py lineage and has run 2.x in production since
2026-09-10. Its main() is the reference; crop-chem-docs' main() is the same shape
(same --transport/--host/--port argparse, same MCP_DISABLE_DNS_REBINDING_PROTECTION
env gate), so this is close to a literal copy.

1. requirements.txt:8 — 2.x has no [fastmcp] extra:

-mcp[fastmcp]>=1.0.0,<2
+mcp>=2,<3          # 2.x: no [fastmcp] extra; FastMCP -> MCPServer

…and drop the six-line comment block above it that explains the ceiling (lines 2–7).

2. Imports, docs_mcp/server.py:27:

-from mcp.server.fastmcp import FastMCP
+from mcp.server.mcpserver import MCPServer
+from mcp.server.transport_security import TransportSecuritySettings

Also widen from typing import Annotated (line 25) to Annotated, Any — run_kwargs
below is annotated dict[str, Any].

3. Constructor, docs_mcp/server.py:66 — transport options moved off it:

-mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True)
+mcp = MCPServer(f"{PRODUCT_NAME}-docs")

(update the # FastMCP setup. comment on line 64 too)

4. main(), docs_mcp/server.py:720-727 — mcp.settings no longer exists; host,
port, stateless_http and transport_security are all run() kwargs now:

    if args.transport == "stdio":
        mcp.run()
        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"}:
        run_kwargs["transport_security"] = TransportSecuritySettings(
            enable_dns_rebinding_protection=False,
        )
    mcp.run(transport=args.transport, **run_kwargs)

Note the stateless_http guard: under 1.x it was set unconditionally on the constructor,
so it applied to sse too. Gating it to streamable-http matches seed-mcp and is the
correct behaviour — sse is a dev-only path here.

5. CI import smoke-test — add to both .gitea/workflows/refresh.yml and
.gitea/workflows/image-only.yml, in the Build & push image step, between the
docker build and the first docker push
(refresh.yml:158, image-only.yml:95):

          # 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
          # for 9 days (2026-09-01 -> 09-10). 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"

This is the durable half of the work — it makes a green build that ships a non-importing
image impossible, regardless of what the dep does next.

Verification (do not skip)

The real risk is tool routing, since 2.x moved the protocol types to snake_case
internally and tool schemas decide routing. All five tools — search_docs, get_page,
list_versions, corpus_status, crop_chem_api_lessons — must come out identical.

  1. Diff tools/list before and after, in wire format. Run against the 1.x venv, then
    the 2.x one, and diff the two dumps:
    import asyncio, json
    from docs_mcp.server import mcp
    tools = asyncio.run(mcp.list_tools())
    print(json.dumps([t.model_dump(by_alias=True, mode="json", exclude_none=True)
                      for t in tools], indent=2, sort_keys=True))
    
    On seed-mcp this was byte-for-byte identical between 1.27.1 and 2.x. Anything else
    here is a finding, not an acceptable diff.
  2. Boot it on the production transport and confirm initialize returns 200:
    MCP_DISABLE_DNS_REBINDING_PROTECTION=1 python -m docs_mcp.server \
      --transport streamable-http --port 8000
    
  3. Build the image and run the smoke command locally before pushing:
    docker run --rm --entrypoint python <img> -c "import docs_mcp.server".

No eval run needed — this touches transport and packaging only, not retrieval. Chroma,
BM25, the reranker and the chunker are all untouched.

Notes / non-goals

  • 2.x swaps httpx for httpx2 and requires opentelemetry-api. The local
    httpx>=0.27 pin (requirements.txt:10, used by _rerank() at server.py:371) is
    unaffected — httpx2 is a separate distribution and both can coexist.
  • An unversioned server now reports serverInfo.version as "" instead of the SDK
    version. Cosmetic, but visible to clients.
  • Out of scope: deploy/docker-compose.yml is still template boilerplate
    (image: <registry>/<owner>/<product>-docs-mcp:latest) and sets MCP_ALLOWED_HOSTS,
    which this server never reads — it reads MCP_DISABLE_DNS_REBINDING_PROTECTION.
    That mismatch predates this issue; file it separately rather than fixing it here
    (cf. justin/zerto-docs#12, which made allowed_hosts/origins genuinely configurable).

Prior art

## Context `requirements.txt:8` pins `mcp[fastmcp]>=1.0.0,<2`. That ceiling exists because mcp 2.0.0 removed `mcp.server.fastmcp` — an unpinned rebuild on 2026-08-11 crash-looped `zerto-docs` with `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`. The pin (23c7a3d) stopped the bleeding; it is not a destination. 1.x is the previous major and fixes land on 2.x. Not urgent — this repo is already pinned and serving. This is deliberate maintenance, hence P2 rather than the P1 on justin/hvm-docs#12 (that one still has to *apply* the pin first). ## Blast radius in this repo `mcp` is imported in exactly one file: **`docs_mcp/server.py`**. Nothing under `rag/`, `scrape/`, `scripts/` or `eval/` touches it. ``` docs_mcp/server.py:27 from mcp.server.fastmcp import FastMCP docs_mcp/server.py:66 mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True) docs_mcp/server.py:723 mcp.settings.host = args.host docs_mcp/server.py:724 mcp.settings.port = args.port docs_mcp/server.py:726 mcp.settings.transport_security.enable_dns_rebinding_protection = False docs_mcp/server.py:727 mcp.run(transport=args.transport) ``` There is **no** `mcp.get_context()` call here, so the `Context` change does not apply. All `@mcp.tool()` decorators and handler signatures stay exactly as they are. ## The port — copy-paste from justin/seed-mcp#23 seed-mcp shares this `docs_mcp/server.py` lineage and has run 2.x in production since 2026-09-10. Its `main()` is the reference; crop-chem-docs' `main()` is the same shape (same `--transport/--host/--port` argparse, same `MCP_DISABLE_DNS_REBINDING_PROTECTION` env gate), so this is close to a literal copy. **1. `requirements.txt:8`** — 2.x has no `[fastmcp]` extra: ```diff -mcp[fastmcp]>=1.0.0,<2 +mcp>=2,<3 # 2.x: no [fastmcp] extra; FastMCP -> MCPServer ``` …and drop the six-line comment block above it that explains the ceiling (lines 2–7). **2. Imports, `docs_mcp/server.py:27`:** ```diff -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings ``` Also widen `from typing import Annotated` (line 25) to `Annotated, Any` — `run_kwargs` below is annotated `dict[str, Any]`. **3. Constructor, `docs_mcp/server.py:66`** — transport options moved off it: ```diff -mcp = FastMCP(f"{PRODUCT_NAME}-docs", stateless_http=True) +mcp = MCPServer(f"{PRODUCT_NAME}-docs") ``` (update the `# FastMCP setup.` comment on line 64 too) **4. `main()`, `docs_mcp/server.py:720-727`** — `mcp.settings` no longer exists; host, port, `stateless_http` and `transport_security` are all `run()` kwargs now: ```python if args.transport == "stdio": mcp.run() 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"}: run_kwargs["transport_security"] = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) mcp.run(transport=args.transport, **run_kwargs) ``` Note the `stateless_http` guard: under 1.x it was set unconditionally on the constructor, so it applied to `sse` too. Gating it to `streamable-http` matches seed-mcp and is the correct behaviour — `sse` is a dev-only path here. **5. CI import smoke-test** — add to *both* `.gitea/workflows/refresh.yml` and `.gitea/workflows/image-only.yml`, in the `Build & push image` step, **between the `docker build` and the first `docker push`** (refresh.yml:158, image-only.yml:95): ```yaml # 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 # for 9 days (2026-09-01 -> 09-10). 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" ``` This is the durable half of the work — it makes a green build that ships a non-importing image impossible, regardless of what the dep does next. ## Verification (do not skip) The real risk is **tool routing**, since 2.x moved the protocol types to snake_case internally and tool schemas decide routing. All five tools — `search_docs`, `get_page`, `list_versions`, `corpus_status`, `crop_chem_api_lessons` — must come out identical. 1. **Diff `tools/list` before and after**, in wire format. Run against the 1.x venv, then the 2.x one, and `diff` the two dumps: ```python import asyncio, json from docs_mcp.server import mcp tools = asyncio.run(mcp.list_tools()) print(json.dumps([t.model_dump(by_alias=True, mode="json", exclude_none=True) for t in tools], indent=2, sort_keys=True)) ``` On seed-mcp this was **byte-for-byte identical** between 1.27.1 and 2.x. Anything else here is a finding, not an acceptable diff. 2. **Boot it on the production transport** and confirm `initialize` returns 200: ```bash MCP_DISABLE_DNS_REBINDING_PROTECTION=1 python -m docs_mcp.server \ --transport streamable-http --port 8000 ``` 3. **Build the image and run the smoke command locally** before pushing: `docker run --rm --entrypoint python <img> -c "import docs_mcp.server"`. No eval run needed — this touches transport and packaging only, not retrieval. Chroma, BM25, the reranker and the chunker are all untouched. ## Notes / non-goals - 2.x swaps `httpx` for `httpx2` and requires `opentelemetry-api`. The local `httpx>=0.27` pin (requirements.txt:10, used by `_rerank()` at server.py:371) is **unaffected** — `httpx2` is a separate distribution and both can coexist. - An unversioned server now reports `serverInfo.version` as `""` instead of the SDK version. Cosmetic, but visible to clients. - **Out of scope:** `deploy/docker-compose.yml` is still template boilerplate (`image: <registry>/<owner>/<product>-docs-mcp:latest`) and sets `MCP_ALLOWED_HOSTS`, which this server never reads — it reads `MCP_DISABLE_DNS_REBINDING_PROTECTION`. That mismatch predates this issue; file it separately rather than fixing it here (cf. justin/zerto-docs#12, which made allowed_hosts/origins genuinely configurable). ## Prior art - justin/seed-mcp#22 (the pin) → justin/seed-mcp#23 (the port) — the copy-paste source. - justin/zerto-docs#80. - justin/hvm-docs#12 — same migration, still needs the pin applied first. - `zerto-msp-mcp` has run 2.x in production since 2026-07-30; `seed-mcp` since 2026-09-10.
claude added the ai-readyfeatureP2 labels 2026-09-10 19:02:14 -04:00
Author
Contributor

The compose mismatch called out under Notes / non-goals is now filed separately as #5 — it turned out to be broader than the MCP_ALLOWED_HOSTS line (the whole file is unedited template boilerplate that disagrees with the real chem-mcp service in Drawbar's parent compose). Still out of scope here; this issue stays a pure transport/packaging port.

The compose mismatch called out under **Notes / non-goals** is now filed separately as #5 — it turned out to be broader than the `MCP_ALLOWED_HOSTS` line (the whole file is unedited template boilerplate that disagrees with the real `chem-mcp` service in Drawbar's parent compose). Still out of scope here; this issue stays a pure transport/packaging port.
Sign in to join this conversation.