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.
There is nomcp.get_context() call here, so the Context change does not apply.
All @mcp.tool() decorators and handler signatures stay exactly as they are.
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).
(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:
ifargs.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}ifargs.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"]=Trueifos.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.
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:
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).
zerto-msp-mcp has run 2.x in production since 2026-07-30; seed-mcp since 2026-09-10.
## 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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
requirements.txt:8pinsmcp[fastmcp]>=1.0.0,<2. That ceiling exists because mcp 2.0.0removed
mcp.server.fastmcp— an unpinned rebuild on 2026-08-11 crash-loopedzerto-docswith
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. The pin (23c7a3d) stoppedthe 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
mcpis imported in exactly one file:docs_mcp/server.py. Nothing underrag/,scrape/,scripts/oreval/touches it.There is no
mcp.get_context()call here, so theContextchange 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.pylineage and has run 2.x in production since2026-09-10. Its
main()is the reference; crop-chem-docs'main()is the same shape(same
--transport/--host/--portargparse, sameMCP_DISABLE_DNS_REBINDING_PROTECTIONenv gate), so this is close to a literal copy.
1.
requirements.txt:8— 2.x has no[fastmcp]extra:…and drop the six-line comment block above it that explains the ceiling (lines 2–7).
2. Imports,
docs_mcp/server.py:27:Also widen
from typing import Annotated(line 25) toAnnotated, Any—run_kwargsbelow is annotated
dict[str, Any].3. Constructor,
docs_mcp/server.py:66— transport options moved off it:(update the
# FastMCP setup.comment on line 64 too)4.
main(),docs_mcp/server.py:720-727—mcp.settingsno longer exists; host,port,
stateless_httpandtransport_securityare allrun()kwargs now:Note the
stateless_httpguard: under 1.x it was set unconditionally on the constructor,so it applied to
ssetoo. Gating it tostreamable-httpmatches seed-mcp and is thecorrect behaviour —
sseis a dev-only path here.5. CI import smoke-test — add to both
.gitea/workflows/refresh.ymland.gitea/workflows/image-only.yml, in theBuild & push imagestep, between thedocker buildand the firstdocker push(refresh.yml:158, image-only.yml:95):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.tools/listbefore and after, in wire format. Run against the 1.x venv, thenthe 2.x one, and
diffthe two dumps:here is a finding, not an acceptable diff.
initializereturns 200: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
httpxforhttpx2and requiresopentelemetry-api. The localhttpx>=0.27pin (requirements.txt:10, used by_rerank()at server.py:371) isunaffected —
httpx2is a separate distribution and both can coexist.serverInfo.versionas""instead of the SDKversion. Cosmetic, but visible to clients.
deploy/docker-compose.ymlis still template boilerplate(
image: <registry>/<owner>/<product>-docs-mcp:latest) and setsMCP_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
zerto-msp-mcphas run 2.x in production since 2026-07-30;seed-mcpsince 2026-09-10.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_HOSTSline (the whole file is unedited template boilerplate that disagrees with the realchem-mcpservice in Drawbar's parent compose). Still out of scope here; this issue stays a pure transport/packaging port.