NewMCP Production is out: 57 lessons, 14 labsSee the course

Free guide · Model Context Protocol

MCP 2026-07-28 migration: what breaks in a 2025-11-25 server

The handshake is gone, protocol sessions are gone, and a server can no longer call the client back in the middle of a request. Here's each assumption that stops holding, what replaces it, and the first thing to look at in your own code.

The short version

  • There's no initialize step anymore. Every request carries its protocol version and client capabilities in params._meta, mirrored in the MCP-Protocol-Version header.
  • Protocol sessions are gone, so anything you kept in a session (a selected project, half-finished work) needs an explicit handle that any replica can resolve.
  • A server that needs input mid-call returns resultType: "input_required". The client retries the same call with inputResponses and the opaque requestState.
  • A 400 isn't a sign you've hit an old server. -32020, -32021 and -32022 are modern errors that need a fixed request, not a fallback to the old handshake.
  • Roots, Sampling, Logging and Dynamic Client Registration are deprecated, not removed. The registry gives an earliest eligibility, not a shutdown date.

What breaks, side by side#

Every row below is an assumption a server or client built for 2025-11-25 can reasonably hold. The middle column is how 2026-07-28 behaves instead. The last column is where to start looking in your code.

You assumed2026-07-28 does thisLook at first
Initialize once, and the server remembers your capabilitiesVersion and client capabilities travel on every requestHow each call builds _meta
The session ID carries what the user selectedProtocol sessions are removedAn explicit project or work handle, with access checks
The server sends its own approval request to the clientThe input request comes back inside the original resultRetry with inputResponses and the opaque requestState
Any response means the operation finishedA required resultType separates finished from interimBranch on complete and input_required
Any 400 means you reached an older serverModern failures use 400 as wellThe error code and the supported-version data
A GET stream or Last-Event-ID recovers lost messagesBoth are removed from the target Streamable HTTPResponse streams, subscriptions and operation recovery
Deprecated means it's already goneDeprecated features stay in the spec during their windowThe per-feature registry entry and its notice

Context on every request#

Under 2025-11-25, a client sent initialize, got the server's capabilities back, sent notifications/initialized, and only then made ordinary calls. The server remembered what was agreed. Behind a load balancer that memory lived in one process, which is exactly where the trouble started.

In 2026-07-28 each request is self-contained. The version and the client's capabilities go in params._meta, and the HTTP headers mirror the body so a gateway can route without parsing JSON. A server has to reject a request whose headers and body disagree.

A tools/call under 2026-07-28

POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: publish_note

{
  "jsonrpc": "2.0",
  "id": "request-a",
  "method": "tools/call",
  "params": {
    "name": "publish_note",
    "arguments": { "target": "team-board", "text": "Review the fixture" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
    }
  }
}

Three details catch people out:

  • clientInfo is optional. Version and capabilities are required. A rejection for missing metadata points at the required pair, not at every field you've seen in an example.
  • Mcp-Name is required on tools/call, prompts/get and resources/read, not on every method. Header names are case-insensitive; the method and name values aren't.
  • Servers have to implement server/discover, but clients don't have to call it before working. It answers on the first request, with no handshake in front of it.

Sessions and your state#

Deleting the session header doesn't move the state that was hiding behind it. If your server kept "the project this user selected" in connection memory, a request that now lands on a different replica has no idea what was selected. The request will still pass a schema check. It'll just do the wrong thing.

The fix is application design, not protocol plumbing: return an explicit handle when the user selects something, store it where every replica can read it, and check on each use that the caller owns it and that it hasn't expired. A request ID isn't that handle. It correlates a response with its request, and a retry gets a new one.

Watch for this: a retried call after a timeout has a new request ID but describes the same business operation. If the first attempt already wrote, only an operation key you store yourself can tell the server that.

Approval without a callback#

Before, a server that needed the user to confirm something sent its own elicitation/create request to the client over the open connection and waited. With no sessions, there's no connection to wait on. The business rule doesn't go away, though: the server still mustn't add the note until someone says yes.

Now the server answers the original call with an interim result, and the client calls again with the answer:

Server: the call needs input

{
  "resultType": "input_required",
  "requestState": "OPAQUE_STATE_FROM_RESULT",
  "inputRequests": {
    "approval": { "method": "elicitation/create", "params": { "mode": "form", … } }
  }
}

Client: same call, with the answer

"params": {
  "name": "publish_note",
  "arguments": { … unchanged … },
  "requestState": "OPAQUE_STATE_FROM_RESULT",
  "inputResponses": { "approval": { "action": "accept", "content": { "confirm": true } } },
  "_meta": { … version and capabilities, as before … }
}

Echo requestState exactly as you got it. Don't decode it, change it or reuse it for another operation. And inputResponses sits next to arguments under params; it's not a new argument in the tool's schema.

The flip side is on the client: a response that arrived isn't necessarily a finished operation. Branch on resultType. When the result comes from an earlier server that doesn't send the field at all, the compatibility rule says treat it as complete, so don't flag it as malformed.

Reading a 400#

The tempting shortcut during a migration is "400 means old server, fall back to initialize". In 2026-07-28 that's wrong, because the new revision uses 400 for its own errors. Falling back won't fix a header that disagrees with its body.

You seeIt meansDo this
HTTP 400, -32020A header is missing, malformed or disagrees with the bodyCompare the exact headers with the body fields they mirror
HTTP 400, -32021The call needs a client capability you didn't declareRead data.requiredCapabilities
HTTP 400, -32022The server doesn't support the version you asked forPick a version from error.data.supported that you also support
HTTP 404, -32601Unknown methodCheck what the server supports
HTTP 404, no MCP bodyWrong endpoint, or something in between answeredDestination and routing logs
result.isError: trueThe tool ran and failedThe tool result and your application's own evidence

A timeout tells you nothing about whether a write happened. Don't infer "committed" from a status code, and don't infer "nothing happened" from silence.

What happened to SSE#

Server-sent events didn't disappear, which is worth saying because "delete all the streaming code" is a common misreading. Three different things changed in three different ways:

  • Response streams stay. A server can still answer a POST with an SSE stream, and subscriptions/listen has its own SSE response stream.
  • The standalone GET stream and Last-Event-ID resumability are removed from the target Streamable HTTP. Recovering work now means request-level and application-level outcomes, not replaying a header the server no longer reads.
  • The historical HTTP+SSE transport (the one before Streamable HTTP) is a separate compatibility path with its own endpoint discovery and its own transition rule. Changing a version string doesn't implement it.

In a migration inventory, "this client uses streaming" is too vague to act on. "This client opens a standalone GET stream" is a task.

Deprecated isn't removed#

Four features are deprecated in 2026-07-28 and are still in the spec. Two related methods were removed outright, which is easy to mix up with the deprecations.

FeatureStatusMove towardEarliest removal
RootsDeprecatedExplicit tool arguments, resource URIs or server configFirst Current revision on or after 2027-07-28
SamplingDeprecatedA direct provider integration, with its own tested policyFirst Current revision on or after 2027-07-28
LoggingDeprecatedstderr for stdio, or your observability stackFirst Current revision on or after 2027-07-28
Dynamic Client RegistrationDeprecated, compatibility keptClient ID Metadata Documents where supportedFirst Current revision on or after 2027-07-28
includeContext thisServer / allServersDeprecatedOmit it, or use noneFollows Sampling
Historical HTTP+SSEUnder transition provisionsStreamable HTTPThree months after SEP-2596 reaches Final

Already removed: logging/setLevel and notifications/roots/list_changed. The log level is now set per request, and protocol log messages need that opt-in.

Not a shutdown date: "first Current revision on or after 2027-07-28" is the earliest a feature becomes eligible for removal. It isn't an announcement that it will be removed then. The lifecycle policy also allows an approved expedited removal with at least ninety days' notice, and your SDK may drop support on its own schedule. Keep a dated record per feature and recheck it before you set a deadline.

A staged rollout#

The protocol change is the easy part. The risk is in the work that's already running when you switch. A rollout that holds up has a record and a stop condition at each stage:

StageKeep a record ofStop if
InventoryReal client builds, their transport and revision, and how you knowAny compatibility assumption is unknown
BaselineWhat's accepted and refused today, and the exact effectsYou can't reproduce current behavior
ConversionExplicit handles, input retry, identity bindingA new call depends on old process memory
CandidateAcceptance per client cohort, and the state it leavesA result mismatch or an unauthorized effect
Pending workHow many old callbacks are live, and what happens to eachOld operations are unresolved
RollbackThe earlier reader recovers what the candidate wroteA repeated effect, unreadable state or lost work
ApprovalA named operator, a change window, a tested restoreNo owner, or a restore nobody has run

One thing worth remembering when it goes wrong: rolling back code isn't rolling back a transaction. If a note was committed before a bad response went out, the old adapter will find that note. Check what was written before you repeat an operation whose answer got lost.

Sources#

Everything above was checked against these pages on 10 September 2026. The spec moves; if something here disagrees with them, they win.


Go further

Module 9 of MCP Production is this guide, with a lab.

Compatibility fixtures for both revisions, a session-to-handle conversion with regression tests, and a promotion gate that refuses a rollout until the evidence is there.