Twelve Green CI Legs, One Dead SSE Server A developer's MCP server package, yantrikdb-mcp, was updated to support the MCP Python SDK 2.0.0, which removed the fastmcp module. The update involved creating a compatibility layer that probes for the new API and adding CI tests for both SDK versions. The developer also fixed a regression in SSE and streamable-http transports that occurred in the initial update. 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: compat.py, trimmed to the shape of the probe 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: build network app , condensed to the branch that matters if MCP MAJOR == 1: 1.x reads its configuration back off the server object server.settings.host = host server.settings.transport security.enable dns rebinding protection = False return server.sse app 2.x: the fields don't exist, the factory takes them 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.