thepragmaticquant.com

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 MCPServer now, and mcp.server exports it while the submodule no longer does
ModuleNotFoundError: mcp.server.fastmcp
mcp.server.mcpserver
AttributeError: '…' object has no attribute '_mcp_server'
_lowlevel_server, though both spellings are private, so stop reaching for it
ImportError: create_connected_server_and_client_session
deleted, replacement private; build it from create_client_server_memory_streams + ClientSession
ModuleNotFoundError: mcp.shared.version
mcp.types.version; import it at run time, not module scope, if you support both majors
ImportError: cannot import name 'McpError'
MCPError; alias it once behind your own name
AttributeError reading 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 / None though the server set it
you read it with getattr/hasattr/.get and 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 keys
model_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 them
mcp-types appears 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 found on ping or logging/setLevel
both removed at 2026-07-28. Here is why
NoBackChannelError from 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 get
LATEST_PROTOCOL_VERSION no longer means what it did
same name, new value; compare against LATEST_HANDSHAKE_VERSION
ValueError: not enough values to unpack at connect
streamable_http_client yields 2 values now. Take the first two; never unpack a fixed arity
a timeout that raises within the first request
read_timeout_seconds is a float rather than a timedelta. 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.x
mypy --strict rejects a resource uri you just fixed
it is str on 2.0 and AnyUrl on 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/call may now be N round trips (InputRequiredResult). Record round trips per call, and never average across counts
A 1.x-only trap the table cannot show you, because it is not an error you hit: 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:

python
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-28whyreplacement
initializeestablished session stateserver/discover (idempotent, cacheable)
pingprobed a channel that no longer existsnone
logging/setLevelset a level for a whole session, then pushedper-request _meta opt-in
resources/subscribe / unsubscribesubscription implies server-initiated pushessubscriptions/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.

Weigh this part before you reach for the new era. If your server uses sampling, which is asking the client’s model to complete something mid-tool-call, that feature does not exist at 2026-07-28. The SDK’s own advice is to pin 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.

This is not an mcp quirk. 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

  1. Print your negotiated version with session.protocol_version. If it says 2025-11-25 on 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.
  2. 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.
  3. Add by_alias=True to every model_dump() that crosses a wire, a snapshot, or a hash.
  4. 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.
  5. 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.