feat(mcp): migrate to mcp 2.x (MCPServer), lift the mcp<2 pin #13

Merged
justin merged 1 commits from claude/issue-12 into main 2026-09-11 12:20:34 -04:00
Owner

Closes #12

The mcp<2 pin already landed here in 6723829, so this PR is the second half: the port that lets the pin be lifted. Same mechanical change as justin/seed-mcp#23 and justin/morpheus-docs#10.

What changed

before after
import from mcp.server.fastmcp import FastMCP from mcp.server.mcpserver import MCPServer (+ TransportSecuritySettings)
constructor FastMCP(name, stateless_http=True) MCPServer(name) — no transport options
transport config mcp.settings.host/.port/.transport_security… run() kwargs; mcp.settings no longer exists
requirement mcp[fastmcp]>=1.0.0,<2 mcp>=2,<3 (2.x has no [fastmcp] extra)

@mcp.tool() decorators and every handler signature are unchanged. This server never called mcp.get_context(), so nothing there to port.

CI gains an import docs_mcp.server smoke in both workflows — once after pip install -r requirements.txt, once inside the built image (docker run --rm --entrypoint python …). That's the guard that would have caught the seed-mcp and zerto-docs outages: a green build can no longer ship a container that doesn't import.

CLAUDE.md was corrected where it told future sessions to put stateless_http=True on the constructor.

Verification

All on python:3.12-slim, matching the production image.

  1. tools/list diff — byte-for-byte identical between mcp 1.30.0 and 2.2.0, all 10 tools, dumped in wire format (model_dump(by_alias=True, mode="json", exclude_none=True), sorted). The tool-routing risk did not materialize.
  2. Booted for real on streamable-http: initialize → HTTP 200, from localhost and with a container-DNS Host: hvm-docs-mcp:8000 header. Passing host= to run() keeps DNS-rebinding protection off, so the 421 trap that bit git-mcp's self-hosted-ASGI port doesn't apply here.
  3. stateless_http confirmed in effect: no mcp-session-id response header.
  4. stdio still works: initialize + tools/list → 10 tools.
  5. Production image builds and imports under mcp 2.2.0; httpx 0.28.1 and httpx2 2.12.0 coexist, opentelemetry-api 1.44.0 pulled in as a transitive dep.

Retrieval is untouched, so there are no eval numbers to report.

One visible change to clients

serverInfo.version now comes back as "" instead of the SDK version — this server passes no version= to the constructor. Cosmetic, and it matches what morpheus-docs and seed-mcp report; flagging it since clients can see it.

Deploy note

Merging alone doesn't ship this — run Image rebuild (skip scrape) (image-only.yml) to build and push, then Watchtower picks it up. The image smoke runs inside that job, so a broken image can't reach the registry tag unnoticed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QBx2P1VhKQEzJWoZ96QcnH

Closes #12 The `mcp<2` pin already landed here in 6723829, so this PR is the second half: the port that lets the pin be lifted. Same mechanical change as justin/seed-mcp#23 and justin/morpheus-docs#10. ## What changed | | before | after | |---|---|---| | import | `from mcp.server.fastmcp import FastMCP` | `from mcp.server.mcpserver import MCPServer` (+ `TransportSecuritySettings`) | | constructor | `FastMCP(name, stateless_http=True)` | `MCPServer(name)` — no transport options | | transport config | `mcp.settings.host/.port/.transport_security…` | `run()` kwargs; `mcp.settings` no longer exists | | requirement | `mcp[fastmcp]>=1.0.0,<2` | `mcp>=2,<3` (2.x has no `[fastmcp]` extra) | `@mcp.tool()` decorators and every handler signature are **unchanged**. This server never called `mcp.get_context()`, so nothing there to port. CI gains an `import docs_mcp.server` smoke in **both** workflows — once after `pip install -r requirements.txt`, once inside the built image (`docker run --rm --entrypoint python …`). That's the guard that would have caught the seed-mcp and zerto-docs outages: a green build can no longer ship a container that doesn't import. `CLAUDE.md` was corrected where it told future sessions to put `stateless_http=True` on the constructor. ## Verification All on `python:3.12-slim`, matching the production image. 1. **`tools/list` diff — byte-for-byte identical** between mcp 1.30.0 and 2.2.0, all 10 tools, dumped in wire format (`model_dump(by_alias=True, mode="json", exclude_none=True)`, sorted). The tool-routing risk did not materialize. 2. **Booted for real on streamable-http**: `initialize` → HTTP 200, from localhost *and* with a container-DNS `Host: hvm-docs-mcp:8000` header. Passing `host=` to `run()` keeps DNS-rebinding protection off, so the 421 trap that bit git-mcp's self-hosted-ASGI port doesn't apply here. 3. **`stateless_http` confirmed in effect**: no `mcp-session-id` response header. 4. **stdio still works**: `initialize` + `tools/list` → 10 tools. 5. **Production image builds and imports** under mcp 2.2.0; `httpx` 0.28.1 and `httpx2` 2.12.0 coexist, `opentelemetry-api` 1.44.0 pulled in as a transitive dep. Retrieval is untouched, so there are no eval numbers to report. ## One visible change to clients `serverInfo.version` now comes back as `""` instead of the SDK version — this server passes no `version=` to the constructor. Cosmetic, and it matches what morpheus-docs and seed-mcp report; flagging it since clients can see it. ## Deploy note Merging alone doesn't ship this — run **Image rebuild (skip scrape)** (`image-only.yml`) to build and push, then Watchtower picks it up. The image smoke runs inside that job, so a broken image can't reach the registry tag unnoticed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QBx2P1VhKQEzJWoZ96QcnH
justin added 1 commit 2026-09-10 22:36:19 -04:00
mcp 2.0.0 removed `mcp.server.fastmcp`. This repo was pinned to <2
(6723829) to stop a rebuild from crash-looping the container the way
seed-mcp (2026-09-01) and zerto-docs (2026-08-11) did. This lifts the
pin by doing the port — the same mechanical change as
justin/seed-mcp#23 and justin/morpheus-docs#10.

- import: FastMCP -> MCPServer, plus TransportSecuritySettings
- constructor takes no transport options: MCPServer(f"{PRODUCT_NAME}-docs")
- host/port/stateless_http/transport_security are run() kwargs;
  `mcp.settings` no longer exists
- requirement becomes plain `mcp>=2,<3` (2.x has no [fastmcp] extra)

@mcp.tool() decorators and all handler signatures are unchanged.

CI now smokes `import docs_mcp.server` twice in both workflows — once
after pip install, once inside the built image — so a green build can
never ship a non-importing container again.

Verified on python:3.12-slim (matches the image):
- tools/list dumped in wire format is BYTE-FOR-BYTE IDENTICAL between
  mcp 1.30.0 and 2.2.0, all 10 tools
- streamable-http boots; initialize returns HTTP 200, both from
  localhost and with a container-DNS `Host: hvm-docs-mcp:8000` header
  (passing host= to run() keeps DNS-rebinding protection off)
- no mcp-session-id response header, i.e. stateless_http is in effect
- stdio boots; initialize + tools/list return 10 tools
- production image builds and imports under mcp 2.2.0; httpx 0.28.1 and
  httpx2 2.12.0 coexist as expected

Retrieval is untouched, so no eval numbers are included.

Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QBx2P1VhKQEzJWoZ96QcnH
justin changed title from feat(mcp): migrate to mcp 2.x (MCPServer), lift the &lt;2 pin to feat(mcp): migrate to mcp 2.x (MCPServer), lift the mcp<2 pin 2026-09-10 22:37:31 -04:00
justin merged commit 8e2b678464 into main 2026-09-11 12:20:34 -04:00
Sign in to join this conversation.