MCP Protocol Versioning Explained
As of protocol revision 2026-07-28, MCP dropped the session-based initialize handshake for a fully stateless, per-request protocol. If your mental model of MCP still starts with “the client and server negotiate capabilities once, then talk for the rest of the session,” this guide covers what replaced that, and why.
What actually changed
Every MCP protocol version is named after the date its last backwards-incompatible change shipped. The current version, published under that scheme, is 2026-07-28. Compared to the previous revision (2025-11-25), it is not a small update. Three things moved together:
- No more handshake. The
initializerequest, theinitializednotification, and protocol-level sessions (including theMcp-Session-Idheader on HTTP) are gone. Every request now declares its own protocol version and capabilities in a_metafield, and a server answers each one without assuming anything about requests that came before it. - A new discovery method.
server/discoveris now mandatory for servers to implement. It is the closest thing to aninitializeresponse left, a single request that returns a server’s supported versions, capabilities, and identity. - No more server-initiated requests. A server that needs a sampling completion, an elicitation answer, or a client’s roots list can no longer send its own JSON-RPC request mid-flight. It uses a new pattern called Multi Round-Trip Requests (MRTR) instead.
None of this makes older servers or clients invalid. The spec calls the pre-2026-07-28 style legacy and the new style modern, and it spends a whole section on how the two interoperate. That section is worth understanding, because most MCP servers in the wild today were built for the legacy handshake, and that will remain true for a while.
Why MCP went stateless
The handshake model had a real cost: it made an MCP server a stateful thing. Once a client completed initialize, the server had to remember that client’s negotiated protocol version and capabilities for the life of the connection. For a stdio server running as a local subprocess, that is free. For a remote server sitting behind a load balancer with multiple instances, it is not, the load balancer has to keep pinning a given client to the same instance, or every instance has to share session state somewhere.
The specification now states this directly: MCP is a stateless protocol, and all the information needed to process a request is contained in the request itself. A server cannot infer capabilities, protocol version, or client identity from prior requests, even ones sent over the same connection. Two consequences follow that are worth internalizing:
- An open connection (a stdio process, an HTTP origin) is not a session or a conversation. A client is allowed to interleave unrelated requests on the same transport, and a server must not treat “same connection” as a proxy for “same conversation.”
- Anything that genuinely needs to span multiple requests (a long-running job, an application-level handle) has to be passed explicitly, by an identifier the client sends on every request that needs it, not held implicitly by the server.
server/discover replaces initialize
Since there is no handshake, there is no required first message either. A modern client is free to send any request straight away and handle an error if its protocol version is not supported. But calling server/discover first is useful, and on some transports it is the only reliable way to tell a modern server from a legacy one before you commit to a request shape.
Every request that follows carries its own protocol version and capabilities the same way, inside _meta, using reserved keys under the io.modelcontextprotocol/ prefix:
| _meta key | Required | Carries |
|---|---|---|
io.modelcontextprotocol/protocolVersion | Yes | The protocol version this request uses (e.g. "2026-07-28") |
io.modelcontextprotocol/clientCapabilities | Yes | The capabilities relevant to this specific request |
io.modelcontextprotocol/clientInfo | No, but should be sent | Client name and version |
io.modelcontextprotocol/logLevel | No | Minimum log level the server should emit for this request |
If a server does not support the requested version, it returns an UnsupportedProtocolVersionError (JSON-RPC code -32022) listing what it does support, and the client retries with a mutually agreeable version. If a request is missing a capability the server needs, the server returns MissingRequiredClientCapabilityError (-32021) instead of guessing.
Multi Round-Trip Requests
This is the change most likely to affect real server code. Under the legacy handshake, a server could pause mid-request and send the client its own request, asking for a sampling completion (sampling/createMessage), a piece of user input (elicitation/create), or the client’s workspace roots (roots/list). That only works if the connection is a stateful, two-way channel the server can write to whenever it wants. A stateless server behind a load balancer cannot assume that.
Multi Round-Trip Requests (MRTR) replaces it. Instead of the server sending a request, it answers the client’s original request with a special result:
The client gathers whatever the inputRequests map asks for (a sampling completion, an elicitation answer, a roots list, possibly more than one at once), then retries the same original request under a new JSON-RPC id, this time supplying an inputResponses map keyed the same way, plus the exact requestState value it was handed. The client never parses or modifies requestState, it is opaque to the client by design, the server encodes whatever it needs to resume processing into that string (base64 JSON, an encrypted token, anything), which is what lets the server stay stateless between the two requests. If a load balancer routes the retry to a completely different server instance, that instance can still resume correctly, because everything it needs travels with the request.
tools/call, resources/read, and prompts/get are the only requests a server is allowed to answer this way. A server also cannot send an inputRequests entry the client has not declared support for, if a client never declared the elicitation capability, the server cannot ask for one. And becauserequestState round-trips through the client, the spec requires servers to treat it as attacker-controlled input: if it influences authorization or business logic, it must be integrity-protected (HMAC or AEAD) and validated on every retry, including a short expiry and a binding to the original request, to prevent replay.
Try it yourself
The two tools below are built directly from the examples in the specification. The first steps through an MRTR exchange end to end, including what changes if the user declines or cancels instead of answering. The second reproduces the protocol’s own client/server compatibility matrix so you can check a specific pairing before you build a fallback path.
Walk through a Multi Round-Trip Request
The client calls a tool. It has no username to pass yet, so the request only carries what it already knows.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "open_github_issue",
"arguments": {
"title": "Docs typo on the versioning page"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {}
}
}
}
}Era compatibility checker
What is now deprecated
A few features did not survive this revision intact. They are still functional during the deprecation window (a minimum of twelve months under MCP’s feature lifecycle policy), but new implementations should not build on them:
- Roots, Sampling, and Logging are deprecated as client capabilities. The suggested replacements: pass directories or files as ordinary tool arguments, resource URIs, or server configuration instead of Roots; call your LLM provider’s API directly instead of routing completions through Sampling; write to
stderr(stdio) or emit OpenTelemetry instead of the Logging capability. All three still work under MRTR if you keep using them, they are simply not where new server design should start. - The HTTP+SSE transport (from protocol version 2024-11-05) is now formally Deprecated rather than just discouraged. New servers should build on Streamable HTTP.
- OAuth 2.0 Dynamic Client Registration is deprecated as a client registration mechanism in favor of Client ID Metadata Documents, though it remains available for authorization servers that do not yet support the newer approach.
- Experimental tasks moved out of the core protocol entirely and into an official extension (
io.modelcontextprotocol/tasks), with polling (tasks/get,tasks/update) replacing the old blockingtasks/resultcall.
Handling state without a session
If your server used to lean on the session for anything, an uploaded file handle, a paginated cursor, a partially built object, that state now has to travel explicitly. The pattern the spec points to is a server-minted handle passed back and forth as an ordinary tool argument: your server returns an opaque identifier from one call, and the client supplies it as a parameter on the next one. It is the same idea as requestState in MRTR, just used for your own application logic instead of elicitation or sampling.
List endpoints changed too. Since there is no session, tools/list, resources/list, and prompts/list no longer vary per connection, and their results (along with resources/read) now carry required ttlMs and cacheScope fields so clients know how long a response is safe to cache and whether shared intermediaries are allowed to cache it at all. Change notifications moved from a standalone SSE stream to a single opt-in subscriptions/listen request that stays open and tags each notification with a subscriptionId so the client can tell them apart.
Supporting both eras
The spec is precise about how a modern implementation should behave toward a legacy one, and vice versa, using the compatibility checker above as reference. A few rules matter most in practice:
- Detection is per-server, not per-request. Once your client figures out whether a given server is modern or legacy, cache that result for the life of the process (stdio) or origin (HTTP) rather than re-probing on every call.
- On stdio, probe by sending
server/discoverfirst. A realDiscoverResultmeans modern. A recognized modern error (likeUnsupportedProtocolVersionError) still means modern, just retry with a supported version. Anything else, an unrecognized-method error or a timeout, means legacy: fall back toinitialize. - On Streamable HTTP, attempt a modern request first and inspect a
400response body before falling back. A recognized modern JSON-RPC error in that body still means modern. An empty or unrecognized body means legacy, fall back toinitialize, and if that also fails, to the deprecated HTTP+SSE transport as a last resort. - Legacy clients cannot be rescued. If your client only knows how to send
initializeand the server only speaks the modern revision, there is no fall-forward path. The server will reject the handshake outright. The only fix is upgrading the client.
stdio & Streamable HTTP changes
The wire-level mechanics changed alongside the lifecycle. On stdio, framing is unchanged (newline-delimited JSON-RPC over stdin/stdout), but the server can no longer write a JSON-RPC request of its own to stdout, server-to-client interactions are carried entirely inside InputRequiredResult replies instead.
On Streamable HTTP, the standalone GET stream and the Mcp-Session-Id header are gone, along with SSE stream resumability via Last-Event-ID. Every request is its own POST, answered with either a single JSON object or an SSE stream scoped to that one request. Every POST must also carry an MCP-Protocol-Version header matching the _meta value in the body, plus Mcp-Method and (for tools/call, resources/read, prompts/get) an Mcp-Name header, so proxies and load balancers can route and log requests without parsing JSON bodies. A mismatch between a header and the body it describes is now its own error, HeaderMismatch (-32020), returned as a 400.
What to actually do this week
| If you maintain… | Do this |
|---|---|
| An MCP server on an official SDK | Check the SDK’s changelog for 2026-07-28 support before touching your own code. The SDK should absorb _meta, server/discover, and MRTR for you. |
| A hand-rolled server or client | Implement server/discover (mandatory for servers), add the per-request _meta fields, and rework any server-initiated sampling/elicitation/roots calls into the MRTR pattern. |
| A server still using Roots, Sampling, or Logging | Nothing breaks today, but plan the migration described above; the deprecation clock is already running. |
| A client meant to work broadly | Build the dual-era probe: server/discover on stdio, an inspected 400 on HTTP, cached per server for the life of the connection. |
| Anything on the deprecated HTTP+SSE transport | Start migrating to Streamable HTTP; HTTP+SSE is now formally Deprecated and eligible for future removal. |
Frequently Asked Questions
Next steps: new to MCP entirely? Start with What is an MCP? Building a server against these changes? See How to Build an MCP Server and Deploying Remote MCP Servers. Hitting connection errors while an implementation catches up? Check MCP Troubleshooting, or browse all MCP guides.