Your MCP Upgrade Silently Broke the Tools That Still Worked
The 2026-07-28 MCP spec moved version negotiation to every request and quietly removed methods your agent was built on. When your client and your servers speak different revisions, some calls fail loudly and some fail silently.
A team I was helping kept a dozen MCP servers wired into a production agent: some they wrote, some from vendors, one or two pulled off the public registry. Their client library auto-updated during a routine dependency bump, and the new release defaulted to the 2026-07-28 protocol revision. Most things kept working, which is exactly why it took two days to notice that one did not. A background job that relied on server-pushed log messages went quiet. No error, no failed call, no alert. The tool calls still returned, the agent still finished, and a feature that had worked for months was simply gone because the server on the other end had removed the method the client no longer sent.
That is the shape of the problem the newest MCP spec introduces. The 2026-07-28 revision is the largest change the protocol has had, and the headline is that it went stateless. The less-discussed consequence is that version negotiation moved to every request, several methods were removed outright, and three whole feature areas were deprecated. When your client speaks one revision and a server speaks another, you now live in a world of partial compatibility where some calls fail with a clean error and some fail by returning nothing at all.
The business problem: you approved a protocol, then it drifted under you
Under the old model, an MCP client and server shook hands once. The client called initialize, the two sides negotiated a protocol version and exchanged capabilities, and everything after that assumed the result of that one handshake. It was a contract signed at connection time and trusted for the life of the session. You built your agent against the capabilities that handshake reported, and you were right to, because they did not change while you held the connection.
The 2026-07-28 revision removes the handshake. There is no single moment where the two sides agree on a version and a feature set. Instead, every request declares the protocol version it is using and the server accepts or rejects it on its own. That is the right design for a stateless protocol that can run behind a plain load balancer, and I wrote separately about what that statelessness demands of a server that still holds sessions. But it changes the failure surface for the client. The thing you used to settle once, you now settle continuously, and a fleet of servers at mixed revisions means the answer is not the same from one call to the next.
This is a version-skew problem, and skew is the normal state, not the exception. Servers update on their own schedules. Vendors ship on theirs. The public registry is full of servers pinned to revisions from a year ago. Your client cannot assume the other side moved when it did. An agent that treats the protocol as a fixed thing it negotiated once will quietly do the wrong thing against any server that did not move in lockstep, and it will not tell you, because a missing capability is not an exception, it is an absence.
How negotiation actually works now
The mechanics are worth getting exactly right, because the whole defense depends on them. Every request carries its protocol version in a namespaced _meta key, io.modelcontextprotocol/protocolVersion. On Streamable HTTP the same value also rides in the MCP-Protocol-Version header so a gateway can read it without parsing the body. The server checks the version on each request independently and either serves it or refuses it.
When a server cannot serve the requested version, it does not silently downgrade. It returns UnsupportedProtocolVersionError, error code -32022 in the renumbered range, and the error lists the versions the server does support. That list is the negotiation. Your client reads it, picks the highest version you both support, and retries. If there is no overlap, that is a real incompatibility you surface rather than paper over.
If you would rather choose a version before you send anything real, the spec adds server/discover, a mandatory RPC every server must implement. It returns the server's supported protocol versions, its capabilities, and its identity in a single round trip. Calling it is optional, so you can either discover up front or fire a request and handle a version error if one comes back. For a long-lived agent connection, discovering once and caching the result is the pattern that pays off, because it turns every later "does this server support X" question into a local lookup instead of a failed call. The spec is explicit that clients and servers may support several protocol versions at once, which is what makes a gradual fleet migration possible instead of a flag day.
Where the skew bites
The errors you can catch are the easy half. The hard half is everything that changed shape without raising anything.
The revision removed ping, logging/setLevel, and notifications/roots/list_changed outright. Log level is now set per request through a _meta key, and a server must not emit log messages for a request that did not ask for them. So the background job I opened with did not break loudly. The client stopped sending the old logging setup call, the server had nothing to push logs in response to, and the feature evaporated. If your agent used ping as a liveness check, that check now fails against a new server and you might read the failure as the server being down.
The revision also deprecated three feature areas: Roots, Sampling, and Logging. Deprecated is not removed, but it is a countdown, and the spec attached real dates to it. The new feature lifecycle policy sets a minimum twelve-month window measured from the release of the revision that marks a feature deprecated, with a ninety-day expedited path when there is a security risk. An agent that leans on server-side Sampling to have the server call an LLM on its behalf has roughly a year, from a known start, to move to calling the provider API directly. Treating deprecated features as a background concern is how you turn a planned migration into an incident when a server finally drops support.
Then there is the quietest one of all. Every result now carries a required resultType field, either complete or input_required. A client talking to an older server gets results with no resultType at all, and the spec says to treat those as complete. That default is correct for compatibility and dangerous for a specific case: a multi round-trip call that an old server cannot express still looks finished. You read a partial answer as a whole one. This is the same class of bug as an agent that fails silently and keeps going, and it needs the same instinct, which is to make absence visible rather than letting a default swallow it.
Feature-detect, do not assume
The fix is a discipline, not a library: never assume a capability, always detect it. Discover once per connection, cache what the server reported, and gate every optional or deprecated feature on that cached table instead of on your own hopes about what the other side supports.
# Discover once, cache the capability table, and negotiate a version both sides
# support. Everything downstream reads this table instead of assuming a feature.
async def connect(session, preferred: str, floor: str) -> "ServerProfile":
disc = await session.discover() # server/discover: versions + capabilities
versions = disc["protocolVersions"]
# Pick the highest version we both support that is at or above our floor.
ours = ["2026-07-28", "2025-11-25"]
common = [v for v in ours if v in versions and v >= floor]
if not common:
# No overlap at or above the floor is a real incompatibility, not a retry.
raise IncompatibleServer(server=disc["serverInfo"], offered=versions)
return ServerProfile(
version=max(common),
caps=set(disc.get("capabilities", {})), # e.g. {"tools", "resources"}
server=disc["serverInfo"],
)
With that profile in hand, the optional features become a lookup. A tool call that wants server-side Sampling checks "sampling" in profile.caps first and takes a different path when it is absent, rather than issuing a request that a stateless server simply will not answer. The point is that the branch is explicit and testable. You can write an eval that connects to a server advertising only the core capabilities and assert that your agent degrades instead of hanging, which is the kind of check that belongs in the same suite as your tool-call validation and recovery tests.
Pin a floor while you are at it. floor in the code above is the oldest revision you are willing to serve. Below it, you refuse rather than guess, because a server three revisions behind is more likely to misbehave in ways your code has never seen than to work by luck. A floor turns "mysterious partial behavior against ancient servers" into "a clear, logged refusal with the versions printed," which is a far better thing to find in a trace.
Handle the version error as a path, not a crash
Even with discovery, you will meet servers you did not discover against, because the spec lets you skip discovery and fire a request directly. So the version error needs a real handler, not a stack trace. The shape is: catch UnsupportedProtocolVersionError, read the versions it offers, intersect with yours, retry on the best common one, and give up cleanly when there is no overlap.
// Treat a version mismatch as negotiation, not failure. The error carries the
// versions the server supports; pick the best one we share and retry once.
const OURS = ["2026-07-28", "2025-11-25"]; // newest first
async function callWithNegotiation(session: Session, req: McpRequest) {
try {
return await session.send(req); // declares OURS[0] in _meta by default
} catch (err) {
if (!(err instanceof UnsupportedProtocolVersionError)) throw err;
const shared = OURS.filter((v) => err.supportedVersions.includes(v));
if (shared.length === 0) {
// No common version. Surface it with both sides named, do not swallow it.
throw new IncompatibleServer(session.serverInfo, err.supportedVersions);
}
// Retry once, pinned to the best version we both speak. One retry, not a loop.
return await session.send({ ...req, protocolVersion: shared[0] });
}
}
Two rules keep this safe. Retry exactly once on the negotiated version, never in a loop, because a server that keeps rejecting a version it just offered is misbehaving and you want that visible, not hidden behind retries. And carry the negotiated version forward on that connection so you are not re-learning it on every call. When the retried call runs at an older version, remember that older versions have a different feature set, so the same capability gate from the previous section still applies. Negotiating the version down does not negotiate the features back up.
One more case hides in the long-running tools. The old blocking tasks/result is gone, tasks moved to an extension, and a server that supports the Tasks pattern of returning a handle you poll is advertising an extension your older client may not understand. Check for the extension in the discovery result before you try to poll a handle, or you will sit waiting on a task protocol the server never agreed to speak.
The parts that bite
Silent degradation is the failure you will actually ship. The loud version error is a gift, because you can catch it. The removed method and the deprecated feature give you nothing to catch. The only defense is to make absence loud yourself: log when a capability you expected is missing, alert when a feature you depend on is deprecated on a server, and never let a complete default stand in for a real completion check on a multi round-trip call.
Version strings compare as dates, not as semver. The format is YYYY-MM-DD, and the spec does not bump it for backward-compatible changes, only for breaking ones. String comparison works because the format sorts chronologically, but do not reach for a semver library out of habit. 2026-07-28 > 2025-11-25 is a plain string comparison and that is intentional.
Discovery results go stale the same way tool definitions do. You cache the capability table per connection, which is correct, but a reconnection can land you on a server at a different revision, especially behind a load balancer spreading you across replicas. Re-discover on reconnect rather than trusting a table from a connection that is gone. This is the same hazard as a tool definition changing between fetches: a thing you verified once is not verified forever, and the gap between check and use is where the surprise lives.
Deprecation windows are a planning input, not a warning to dismiss. Twelve months sounds like forever until you have a dozen servers, each on its own schedule, and a feature you use deprecated across all of them on different revisions. Track which of your servers still depend on Roots, Sampling, or Logging the way you track any other end-of-life dependency, because the day a server drops the feature is not a day you get to choose. The spec writing down the window is a courtesy, and the courtesy only helps if you read the calendar.
Mixed revisions make traces confusing before they make them useful. When half your servers speak the new revision and half the old, the same logical call looks different in your telemetry depending on which server served it. Record the negotiated protocol version as an attribute on every span so a trace tells you which contract was in force, which is a close cousin of the problem of convention drift across telemetry versions. Without that attribute you will debug two different protocols thinking they are one.
The takeaway
MCP moving version negotiation to every request is the right call for a stateless protocol, and it quietly hands the client a job it did not have before: deciding, continuously, what the server on the other end can actually do. The old handshake let you assume a fixed contract. The new model does not, and a fleet of servers at mixed revisions means the honest answer to "does this work" is "against which server, running which revision." The loud failures you can catch and retry. The silent ones, a removed method, a deprecated feature, a missing resultType read as complete, are the ones that cost you two days and a quiet feature nobody noticed was gone.
So detect instead of assume. Discover a server's versions and capabilities, cache the table, pin a floor you refuse to go below, gate every optional feature on what the server actually reported, and treat a version mismatch as a path you handle rather than an exception you let escape. If you have a set of MCP servers at mixed revisions and you are not sure which of your agent's features are quietly degrading against the ones that did not upgrade with you, book a consultation call and we can map your protocol skew before it maps itself in an incident.
Questions this essay answers
01How does MCP protocol version negotiation work in the 2026-07-28 spec?
Negotiation is now per request instead of once at connect time. Every request declares its protocol version in a namespaced _meta key, and on Streamable HTTP the same value rides in the MCP-Protocol-Version header. The server accepts or rejects each request independently, and a request it cannot serve comes back as UnsupportedProtocolVersionError listing the versions the server does support. A client that wants to choose up front can call server/discover, a mandatory RPC that returns the server's supported versions, capabilities, and identity in one round trip, but calling it is optional.
02What breaks when my MCP client and server run different spec revisions?
The loud failures are easy: a version the server rejects returns an error you can catch and retry. The dangerous ones are silent. The 2026-07-28 revision removed ping, logging/setLevel, and SSE stream resumability, and deprecated Roots, Sampling, and Logging. If your agent assumed one of those exists, it does not get an error, it gets nothing, and the run continues as if the missing capability simply returned empty. Results from older servers that omit the new resultType field are treated as complete, which hides a half-finished multi round-trip call.
03Should I always call server/discover before using an MCP tool?
Not on every call, but you should discover a server's capabilities once per connection and cache the result rather than assuming any feature is present. server/discover is the cheapest way to learn which protocol versions a server supports and which capabilities it advertises, so you can pick a mutually supported version and feature-detect before you depend on anything the spec marked optional or deprecated. Treat the discovery result like a capability table, refresh it when the connection resets, and gate optional features on what it actually reports.
04How long do deprecated MCP features keep working?
The 2026-07-28 revision adopted a formal feature lifecycle with a minimum twelve-month deprecation window measured from the release of the revision that first marks a feature deprecated. Roots, Sampling, and Logging are deprecated now, remain fully functional during the window, and become eligible for removal in the first revision released on or after twelve months pass. A security risk can shorten that to ninety days under the expedited-removal exception. The practical lesson is that deprecated is not a synonym for safe to ignore, it is a countdown.
