mcp 1.x to 2.0: reference notes
MCP is the protocol coding agents use to discover and call tools. The Python SDK, package mcp, shipped 2.0 on 2026-07-28. This covers every breaking change I hit porting a test toolkit across both majors, grouped by where it bites: imports, reads, protocol era, measurement. Each row says how the change announces itself and what to do.
Everything here was measured against installed 1.26.0 and 2.0.0 wheels or read from the SDK at tag v2.0.0. The official migration guide owns the how-to and is thorough. What this adds is the reasoning behind the removals, and a column saying which changes announce themselves and which do not.
Find your symptom¶
All 21 rows, grouped as the reference
Imports and names
All loud. A traceback names the symbol; you fix it in an afternoon.
ImportError: cannot import name 'FastMCP'- it is
MCPServernow, andmcp.serverexports it while the submodule no longer does ModuleNotFoundError: mcp.server.fastmcpmcp.server.mcpserverAttributeError: '…' object has no attribute '_mcp_server'_lowlevel_server, though both spellings are private, so stop reaching for itImportError: create_connected_server_and_client_session- deleted, replacement private; build it from
create_client_server_memory_streams+ClientSession ModuleNotFoundError: mcp.shared.versionmcp.types.version; import it at run time, not module scope, if you support both majorsImportError: cannot import name 'McpError'MCPError; alias it once behind your own nameAttributeErrorreading any multi-word model field- camelCase became snake_case; why it only broke reads
Reads and serialisation
All silent. This is the expensive group.
- a field reads
False/Nonethough the server set it - you read it with
getattr/hasattr/.getand a default; grep every string literal you pass to those three - peers reject data you serialised
model_dump(by_alias=True), since the bare call now emits snake_case keysmodel_copy(update={"oldName": …})silently does not apply- it takes field names, and an alias key is written onto the instance unvalidated, so fix writes before reads
- vendor extension fields vanish on round-trip
extra="allow"was dropped from the models; stop round-tripping unknown keys through themmcp-typesappears in your lockfile- nothing. Wire types are a separate distribution now, resolved automatically
Protocol era
Mixed. Some raise on the first call, some never announce themselves.
Method not foundonpingorlogging/setLevel- both removed at 2026-07-28. Here is why
NoBackChannelErrorfrom sampling, elicitation, or roots- the 2026 era cannot call you back; if you need these, pin
mode='legacy' - your protocol version never advances past
2025-11-25 ClientSession.initialize()can never reach 2026. Which era you getLATEST_PROTOCOL_VERSIONno longer means what it did- same name, new value; compare against
LATEST_HANDSHAKE_VERSION ValueError: not enough values to unpackat connectstreamable_http_clientyields 2 values now. Take the first two; never unpack a fixed arity- a timeout that raises within the first request
read_timeout_secondsis afloatrather than atimedelta. Convert it at one chokepoint; converting per call site is how one site gets missed- your server advertises version
"" version=is required in practice; pass it explicitly, and drop the kwarg on 1.xmypy --strictrejects a resourceuriyou just fixed- it is
stron 2.0 andAnyUrlon 1.x; one helper returning the right type per major
Measurement
Silent, and it invalidates numbers you already published.
- throughput and latency percentiles stop describing anything
- one
tools/callmay now be N round trips (InputRequiredResult). Record round trips per call, and never average across counts
mcp.server.fastmcp.server.MCPServer exists on 1.x and aliases the lowlevel server. Same token, opposite meaning. Import the high-level class from mcp.server and nowhere else.
That is the whole surface. The rest of this is why it looks like that, which matters if you have to decide which era to be on rather than just make the errors stop.
The decision the rest follows from¶
mcp 2.0 ships two protocol eras, and the second is not an extension of the first:
HANDSHAKE_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
MODERN_PROTOCOL_VERSIONS = ("2026-07-28",)They are reached differently. The handshake era is negotiated by initialize; the modern era is entered by probing server/discover and adopting the result. Which matters more than it sounds, because ClientSession.initialize() hard-codes the newest handshake version and raises on anything else. It can never reach 2026. Not misconfigured, not unlucky: unable.
The 2026 era has no back-channel. That is the design decision, and every removal above is a consequence of it. On a 2026 connection the server cannot initiate anything: sampling, elicitation, and roots requests raise NoBackChannelError, and client-to-server progress is deprecated. The protocol became request/response only.
That buys real things: no session affinity, no Mcp-Session-Id to route on, horizontal scaling behind an ordinary load balancer, responses that can be cached. The price is every feature that depended on the server talking first.
| removed at 2026-07-28 | why | replacement |
|---|---|---|
initialize | established session state | server/discover (idempotent, cacheable) |
ping | probed a channel that no longer exists | none |
logging/setLevel | set a level for a whole session, then pushed | per-request _meta opt-in |
resources/subscribe / unsubscribe | subscription implies server-initiated pushes | subscriptions/listen |
The SDK says the ping case plainly: “a ping is a liveness probe only on connections negotiated at 2025-11-25 or earlier.” On 2026 every server-initiated request fails anyway. A ping would be answering a question nobody can still ask.
mode=‘legacy’. The move from one era to the other is a trade, and is distinct from an upgrade.
Which era you get¶
Client defaults to mode="auto": probe discover, fall back to the handshake for legacy servers. You might expect the probe to be an HTTP-only trick; it is transport-independent, stdio included, so against a 2.0 server auto lands on 2026-07-28 everywhere.
But a lowlevel ClientSession you initialize() yourself always performs the pre-2026 handshake. If your code builds sessions directly, you are on the old protocol regardless of which SDK you installed, and nothing will tell you. Print session.protocol_version if you want to know.
The rename that only broke reads¶
Every multi-word protocol field went camelCase to snake_case. The wire format is unchanged. This is a Python-surface change implemented with a pydantic alias generator.
Pydantic validates aliases, so the old spelling keeps working as a constructor keyword. Attribute access has only ever used the field name. Two spellings go in; one comes out. Nothing fails at import, at build, or in any constructor call.
One field, six ways to read it. Two raise. Four hand you a value and let you carry on.
| how the code reads it | 1.x | 2.0 |
|---|---|---|
| 2 tell you immediately | ||
t.inputSchema | the value | AttributeError |
t.model_dump()["inputSchema"] | the value | KeyError |
| 4 hand you a value and let you carry on | ||
getattr(t, "inputSchema", d) | the value | d |
hasattr(t, "inputSchema") | True | False |
t.model_dump().get("inputSchema") | the value | None |
t.model_copy(update={"inputSchema": …}) a silent write, not a silent read | applied | stashed, not applied |
The migration guide’s wording for the serialisation case: “No error is raised; the output is silently in the wrong shape.”
The model_copy row is why writes go before reads. model_copy means write this value onto the instance under this field name, with no validation step, so an alias key lands on the object while the real field keeps its old value. Repair the read first and a previously-consistent line comes apart.
populate_by_name is pydantic’s current recommended configuration, and it is itself pending deprecation in favour of validate_by_name / validate_by_alias. Any library renaming wire fields per present pydantic guidance inherits the same asymmetry.
The pattern¶
Read the four groups above and they sort themselves.
The renames and moved modules fail loudly. A traceback names the symbol, it goes red on the first run, and the diagnosis is in the paste. Budget an afternoon.
What fails quietly is everything the compatibility layer was good at. Aliases keep your writes working. A timedelta is accepted and raises later, far from the line that chose it. A str where AnyUrl was expected runs fine and only a type checker objects. A defaulted read hands back a plausible value forever. LATEST_PROTOCOL_VERSION keeps the same name and means something else.
Those cost you confidence in results you already believed.
The maintainers saw the biggest one coming. The rename proposal is labelled P1 / breaking change / v2 and proposed two mitigations: aliases to keep the wire compatible, and deprecation warnings on camelCase access. Only half of that landed: the aliases are there, and no warning was ever wired up. FastMCP’s own upgrade path ships exactly that warning bridge. The SDK’s has no equivalent.
Five things worth running¶
- Print your negotiated version with
session.protocol_version. If it says2025-11-25on 2.0, you migrated the SDK and left the protocol alone. That may be what you want, given the back-channel trade, but decide it rather than inherit it. - Grep your defaults. Every string literal you hand to
getattr,hasattr, or.get()is an attribute read your type checker cannot see. Renames go quiet exactly there. - Add
by_alias=Trueto everymodel_dump()that crosses a wire, a snapshot, or a hash. - Bound your dependency:
mcp>=1.20,<3.0. By the SDK’s own count, “84% of the 10,000+ PyPI packages that depend on mcp declare no upper bound.” All of them resolved to a new major on release day. - Resolve without your lockfile, once. Your gates install what the lockfile names. Nobody installing your package has that lockfile. This is a named practice (not a local invention: The Design Space of Lockfiles Across Package Managers, arXiv 2505.04834, §5.1 recommends running the suite both with and without enforcing locked versions) precisely because library authors “do not encounter in-range breakages themselves, while the library users still may.”
What I would not do¶
Do not treat 2026-07-28 as strictly better. It is stateless and cacheable and it cannot call you back. If your server samples, elicits, or pushes, the handshake era is closer to the era with the features you use than to legacy debt, and the SDK’s own guidance is to pin it.