The official mcp
Python SDK went to 2.0.0 and deleted mcp.server.fastmcp
outright. No shim, no deprecation window that downstream packages got to see. yantrikdb-mcp is a thin MCP server over YantrikDB, my persistent cognitive memory substrate for AI agents, and at 04:08 on 2026-08-04 I did the defensive thing: v0.11.0 (d823b82
) pinned mcp[cli]
to <2.0.0
.
A ceiling like that doesn't stop anyone from installing the package. pip resolves mcp 1.x and moves on. It bites later, in an environment where something else wants mcp>=2.0.0
, because then the resolver has to pick a loser and one of the two packages isn't getting installed. That's an acceptable trade for a few hours. It's a bad thing to leave sitting in a published pyproject.toml
, so I spent the morning taking it back out.
Scale matters here, because it changes how much a five-hour window costs. yantrikdb-mcp is the fastest-moving package in my download numbers at the moment: 1,688 a month when I logged it on 2026-08-24, 2,137 on 2026-08-31. Up 26.6% in a week, mostly a cluster of registry listings landing close together rather than any single event.
v0.12.0 (0ca6092
, PR #19) shipped at 10:39, six and a half hours after the pin. One file, src/yantrikdb_mcp/_compat.py
, became the single import site for every MCP SDK symbol the rest of the package touches, and it resolved them by probing rather than by reading a version string and branching on it:
try:
from mcp.server.mcpserver import Context, MCPServer as Server
from mcp.server.mcpserver.exceptions import ToolError
MCP_MAJOR = 2
except ImportError:
from mcp.server.fastmcp import Context, FastMCP as Server
...
MCP_MAJOR = 1
What actually moved, found by running the suite against 2.0.0 rather than by reading a changelog: FastMCP
became MCPServer
, Context
moved under mcp.server.mcpserver
, and ToolError
went one level deeper than that, to mcp.server.mcpserver.exceptions
. mcp.types.ToolAnnotations
stayed exactly where it was, which I would not have guessed given everything around it had shifted. The lifespan dict, request_context.lifespan_context
, annotations passthrough, .run(transport=...)
: all identical across the major bump. Most of a breaking release wasn't breaking for this codebase, and the pin had been treating the entire API surface as suspect when only a slice of it had moved.
One divergence surfaced only by running things. mcp 2.x refuses to register a static resource whose handler declares a Context
parameter, and it refuses at import time, which takes the server down before it answers a single request. yantrikdb-mcp had two of those, yantrikdb://stats
and yantrikdb://health
. Both now reach the engine through a process-singleton and don't ask for a Context
at all.
CI got a new axis, mcp-line
, with values mcp1
and mcp2
, applied across the unit and e2e suites. Twelve legs. Each leg installs its SDK pin after the package installs and then asserts the resolved major, specifically so a compat layer that quietly resolved the wrong way couldn't pass by accident and turn one leg into a duplicate of the other. 214 tests passed on mcp 1.x. 214 on mcp 2.x. The pyproject.toml
ceiling went from <2.0.0
to <3.0.0
, and --version
started reporting the active line: yantrikdb-mcp 0.12.0 (mcp 2.x)
. I tagged it.
v0.12.1 went out five hours later: "fix SSE / streamable-http startup on mcp 2.x (v0.12.0 regression)."
The e2e cases, on both legs, all drove stdio. Most of the 214 are unit tests that don't exercise a transport at all. So nothing in the suite touched SSE or streamable-http, which are the two transports yantrikdb-mcp uses for shared network deployments, and v0.12.0 had broken both of them on mcp 2.x.
mcp 1.x configures a network server by mutating shared state (mcp.settings.host
, mcp.settings.transport_security.enable_dns_rebinding_protection
) and then reading those values back inside server.sse_app()
. mcp 2.x's Settings
object has no such fields. The old code kept mutating, and on 2.x it raised:
ValueError: "Settings" object has no field "host"
On 2.x those values are arguments to the app factory instead. _compat.build_network_app(server, transport, host, port)
is now the only place in the package that knows the difference, and _run_network()
delegates to it rather than poking settings.host
itself:
if MCP_MAJOR == 1:
server.settings.host = host
server.settings.transport_security.enable_dns_rebinding_protection = False
return server.sse_app()
return server.sse_app(host=host, transport_security=security)
The streamable-http factory goes through the same branch with a different call. Worth being precise about the failure mode: the server didn't come up misconfigured, it didn't come up. The process died before uvicorn bound a port. A stdio suite is structurally incapable of noticing, because stdio never reaches sse_app()
.
I found it because I went to upgrade the live SSE box I run, which sits behind bearer-token auth and depends entirely on that path, and I checked it before rolling it forward. Twelve green legs did not catch this bug. A pre-deploy check did, which is the least impressive way there is to find one.
Past the unit tests, I started a real SSE server on mcp 2.x with YANTRIKDB_API_KEY
set: uvicorn bound, GET /sse
returned 401 with no token and 200 with one. A new file, tests/test_network_transport_compat.py
, builds the actual ASGI app for both sse
and streamable-http
against whichever SDK line is installed, so both legs finally walk the network path. It also carries a source-level guard against _run_network
ever reverting to direct settings.host
mutation, plus a tripwire assertion that the host
field really is absent from Settings
on 2.x, so a future 2.x release that quietly restores it fails the suite instead of letting the shim do the wrong thing on both majors at once. Counts after the fix: 218 on mcp 1.x, 219 on mcp 2.x. The asymmetric one is that tripwire, which has nothing to assert on the 1.x leg.
Twelve felt like coverage because twelve is a bigger number than three. What I had was two dependency pins multiplied against one transport. Multiplying an axis against the same assertions doesn't buy a code path, it buys a longer CI run and a wider grid of checkmarks, and I think a great many matrices in a great many repos are built exactly that way: wide where widening is cheap, thin where the interesting failures live. Mine was, until August. Now, before I add an axis, I want to be able to name the line of code it forces the suite through. If I can't name one, the axis is decoration.
A different gate on this repo caps how large the tools/list
JSON schema is allowed to get, kept deliberately tight so it doesn't eat into a model's context budget. PR #37 merged with four red CI checks. The gate had passed locally on Python 3.13 and failed in CI on 3.10 and 3.12, because 3.10 renders a hint like str | None
into a noticeably fatter anyOf
block than 3.13 or 3.14 do: roughly 1,950 extra characters from identical source, against a budget with 197 characters of headroom. PR #38 clawed back 204 characters by trimming a docstring, raised the ceiling from 48,600 to 48,800, and wrote the reasoning into the test file instead of bumping the number silently. There was already a comment in that file warning about this exact interpreter quirk, left there the first time it happened.
Same shape as the SSE regression, one size down: green in the configuration I looked at, red in the one I didn't.
On 2026-08-25 I had to fix the probe itself (0e170a9
, PR #35). mcp 2.1.1 reintroduced mcp.server.fastmcp
alongside the canonical mcp.server.mcpserver
tree. Read the code block above again: its logic is "if fastmcp
imports, we're on 1.x," and on 2.1.x both trees import cleanly, so it confidently answered 1. The fix reads importlib.metadata.version('mcp')
directly instead of inferring the line from which modules happen to resolve, adds a second check tying the canonical 2.x tree to MCP_MAJOR
, and pins the both-trees-importable shape to 2.x in a regression test. Validated against mcp 1.29.1, 2.0.0 and 2.1.1.
So probing beat parsing right up to the point where upstream did something stranger than I'd allowed for, and the version string I'd deliberately avoided ended up being part of the answer after all.