This is going to be a long article, primarily because I’ve tried to be as thorough as possible. I recommend reading this in more than one sitting.
As of the publish date of this article (September 16, 2026), the current Model Context Protocol specification is MCP 2026-07-28. It is the largest breaking revision since MCP was introduced, and its most important change is simple:
MCP is no longer a stateful session protocol. It is now a stateless request/response protocol.
What does the word “version” mean? To be clear, there is no official protocol named MCP 1.0, and the new release is not officially called MCP 2.0.
MCP versions are dates. The project now uses these terms:
Legacy MCP means November 2025 and earlier. These revisions begin with an initialize handshake and establish a session.
Modern MCP means July 2026 and later. Every request carries the information needed to process it independently.
Dual-era means a client or server supports both wire protocols.
So when this article compares “version 1” with the new protocol, version 1 means the legacy MCP architecture, represented by its final revision, 2025-11-25. The very first published MCP revision was in late 2024, but the protocol evolved several times before the session-based design was replaced.
I’d like to be very clear about this versioning bit. The TypeScript SDK has its own v1 and v2 releases, the Go SDK has different package versions, and none of those numbers are MCP protocol versions.
With the naming settled, here is the short version of what changed:
This article explains why MCP made that change, how the modern protocol works on the wire, what happened to bidirectional interactions, and what a real migration requires.
What MCP is and what did not change
I wrote an article on MCP about a year ago but here is a quick revision. MCP is a standard way for an AI application to connect to external capabilities and context. An MCP server can expose three main primitives:
Tools are actions the model can invoke, such as querying a database or creating an issue.
Resources are readable pieces of context, such as files, schemas, or records.
Prompts are reusable prompt templates that users or applications can select.
The architecture has three participants:
The host is the application the user interacts with. It creates an MCP client for each server, decides what context and tools the model can access, and remains responsible for consent and security boundaries.
That architecture is still intact. MCP still uses JSON-RPC 2.0. Tools, resources, and prompts still exist. Stdio and Streamable HTTP are still the main transports.
The new revision does not replace MCP with a different protocol. It changes where MCP keeps the information required to understand a request.
How legacy MCP worked
Legacy MCP treated a connection as a conversation with a lifecycle.
Before a client could list tools or read a resource, it sent an initialize request containing:
the protocol version it wanted to use
its capabilities
its name and version.
The server replied with its selected version, capabilities, identity, and optional instructions. The client then sent notifications/initialized, after which normal operation began.
On Streamable HTTP, the server could assign an Mcp-Session-Id. The client returned that header on later calls, allowing the server to associate several requests with the same session.
This design is easy to understand when one client talks to one long-lived server process. The handshake establishes shared assumptions once, and later messages rely on them.
It has some tradeoffs when the server runs behind production infrastructure.
The hidden cost of attaching state to a session
Imagine an MCP server running on three replicas:
After the handshake reaches Server A, what happens when the next request lands on Server B?
The deployment has three common options:
Use sticky sessions. Route that client back to Server A.
Share session state. Put negotiated capabilities and application state in a database accessible to every replica.
Fail unpredictably. Server B receives a request without the assumptions established on Server A.
Sticky routing complicates load balancing and recovery. Shared state adds latency and another dependency. A restarted process can lose its session even though the client still holds the session ID.
The protocol also allowed the server to initiate JSON-RPC requests back to the client—for elicitation, sampling, or listing roots—while the original call was still in progress. That required a bidirectional channel, correlation logic, and infrastructure willing to keep streams open in both directions.
None of these choices is inherently wrong. They simply made MCP harder to run reliably at scale than a normal stateless HTTP service.
Modern MCP moves context from the session to the request
MCP version from July 2026 removes the protocol-level session.
There is no initialize request. There is no notifications/initialized. There is no Mcp-Session-Id.
Instead, each request includes _meta fields describing the protocol context:
{
"jsonrpc": "2.0",
"id": "call-42",
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "compute-desktop", # Client Info
"version": "1.4.0"
},
"io.modelcontextprotocol/clientCapabilities": {} # Client Capabilities
},
"name": "search",
"arguments": {
"query": "modern MCP"
}
}
}
The protocol version and client capabilities are present on every request.
The client should also identify itself on every request, and the server should include its identity in result metadata.
That request can land on Server A, B, or C. Any replica can understand it without reading facts negotiated over an earlier connection.
This is what stateless means in the new specification: the protocol does not depend on hidden state from a previous request.
It does not mean every MCP application must forget everything.
A database transaction, a long-running export, a shopping cart, or an OAuth flow may still need durable state. Modern MCP makes that state explicit. A server creates a handle—such as a transaction ID or task ID—and the client passes it in later tool arguments.
Compare the two models:
Legacy: "Use whatever transaction belongs to this MCP session."
Modern: "Use transaction txn_8f31."
The second form is visible, routable, loggable, and testable. It can survive a connection change because the connection is no longer the identity of the work.
Discovery replaces negotiation, but it is optional
Removing the handshake raises an obvious question: how does a client know which protocol versions and capabilities the server supports?
Modern servers must implement server/discover.
{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover", # Notice this method
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "compute-desktop",
"version": "1.4.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
The result advertises supported protocol versions, server capabilities, identity, optional instructions, and caching information.
The important difference is that discovery does not create a session. It is a normal, independent request.
A client does not even have to call it first. It can optimistically make a tool request using its preferred version. If the server does not support that version, the server returns:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": ["2026-07-28", "2025-11-25"], # Server returns a list of supported versions
"requested": "1900-01-01"
}
}
}
The client selects a mutually supported version and retries.
This is per-request version declaration, not a once-per-session negotiation.
Capability negotiation changed shape
Legacy MCP exchanged capabilities once during initialize. Modern MCP puts client capabilities in every request. This matters because capabilities can now be evaluated alongside the request that needs them.
The server can advertise its capabilities through discovery, while optional protocol extensions live under an extensions capability map:
{
"extensions": {
"io.modelcontextprotocol/tasks": {},
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
This formal extension mechanism is important. MCP can add substantial features such as Tasks or MCP Apps without forcing every feature into the core protocol or inventing an unstructured experimental field.
If only one side supports an extension, it must fall back to core behaviour or reject the operation clearly. An extension is never permission to silently change the meaning of a core request.
Streamable HTTP became ordinary HTTP again
The transport changes make the stateless design concrete.
Legacy Streamable HTTP used one MCP endpoint for:
POST requests
an optional GET-based SSE stream
server-initiated requests
client responses to those server requests
session IDs
resumability through SSE event IDs and Last-Event-ID.
Modern Streamable HTTP is much narrower:
The client sends one JSON-RPC request in one HTTP POST.
The server returns either one JSON response or an SSE stream scoped to that request.
The server does not initiate independent JSON-RPC requests on the channel.
There is no general GET stream and no session header.
An HTTP tool call now also carries routing information in headers:
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
The full request still lives in the JSON-RPC body. The headers expose the parts infrastructure commonly needs:
MCP-Protocol-Version says which wire revision is being used.
Mcp-Method identifies the RPC method.
Mcp-Name identifies the selected tool, resource, or prompt where the method has such a target.
This lets a gateway route tools/call differently from resources/read, apply a rate limit to a specific tool, or enforce policy without parsing the JSON body. This could be very useful.
The header and body cannot disagree. A mismatch is a protocol error. The specification reserves -32020 for HeaderMismatch.
Tool schemas can also annotate selected primitive arguments with x-mcp-header, allowing those values to be mirrored into Mcp-Param-* headers for routing. That should be used carefully: passwords, API keys, tokens, and personal data do not belong in headers that may be visible to proxies and logs.
What happened to SSE?
SSE has not disappeared.
A server can still stream progress and the final result as the response to a particular POST. A subscription can also keep a response stream open for notifications.
What disappeared is the general GET stream that acted as a persistent server-to-client channel. Modern MCP uses explicit request-scoped streams instead.
The old Last-Event-ID resumability mechanism is also gone. If a request’s SSE response breaks, the in-flight request is lost. The client must issue a new request with a new JSON-RPC ID.
That rule makes retries easier to reason about at the protocol level, but it puts a real responsibility on tool authors: operations that may be retried should be idempotent or accept an idempotency key. Otherwise, a network break can turn “create one issue” into “create two issues.”
MRTR replaces server-initiated requests
The removal of bidirectional server requests is the hardest change to understand because MCP still needs multi-step interactions.
Consider a book_flight tool. The client calls it, but the server needs the user to approve a £420 fare before completing the purchase.
In legacy MCP, the server could keep the tool call open and send an elicitation/create request back to the client:
That is elegant on a genuinely bidirectional connection. It is awkward through gateways, short-lived workers, and stateless replicas.
Modern MCP uses Multi Round-Trip Requests, or MRTR:
The server returns a normal result whose resultType is "input_required":
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required", # notice the resultType property here
"inputRequests": {
"approval": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Approve the £420 fare?",
"requestedSchema": {
"type": "object",
"properties": {
"confirmed": {"type": "boolean"}
},
"required": ["confirmed"]
}
}
}
},
"requestState": "opaque-integrity-protected-value"
}
}
The first request is over. The client gathers the requested input and retries the original method with a new JSON-RPC ID:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {"form": {}}
}
},
"name": "book_flight",
"arguments": {
"flightId": "BA281"
},
"inputResponses": {
"approval": {
"action": "accept",
"content": {
"confirmed": true
}
}
},
"requestState": "opaque-integrity-protected-value"
}
}
The server can now complete the operation. The retry may reach a different replica because everything it needs is carried in the request.
MRTR is broader than user approval
An input_required result can request:
elicitation: ask the user for structured input or send them through an external URL flow;
sampling: ask the client to obtain a model-generated response;
roots: ask which filesystem roots the client exposes.
Only tools/call, resources/read, and prompts/get can use this pattern. The server cannot return an input request the client did not declare support for.
Every ordinary modern result also carries:
{
"resultType": "complete"
}
This makes result state explicit. Clients talking to older servers must treat a missing resultType as "complete".
requestState is not a signed session cookie by default
The server may include an opaque requestState value so it can reconstruct work during the retry. The client must return it unchanged and must not interpret it.
The server, however, must treat it as attacker-controlled because it passed through the client.
If changing requestState could alter authorization, resource access, price, or business logic, the server must protect its integrity with something such as an HMAC or authenticated encryption. It should bind the state to:
the authenticated user;
the method and important arguments;
a short expiry;
the originating operation.
Those checks reduce replay risk but do not guarantee single use. A one-time action still needs server-side storage to record that the state has already been consumed.
Stateless protocols do not eliminate security state. They force you to be honest about where it lives.
Subscriptions replace the general notification channel
Some server events are not responses to one short request. A client may want to know when:
the tool list changes;
a prompt is added;
the resource catalog changes;
one specific resource is updated;
a long-running task changes status.
Legacy MCP used resource subscription methods and, over HTTP, the general GET stream.
Modern MCP introduces subscriptions/listen. The client sends an explicit filter:
{
"jsonrpc": "2.0",
"id": 10,
"method": "subscriptions/listen",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "compute-desktop",
"version": "1.4.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
},
"notifications": {
"toolsListChanged": true,
"resourceSubscriptions": [
"file:///project/config.json"
]
}
}
}
The first server message acknowledges which filters it accepted. Every later notification carries a subscription ID equal to the JSON-RPC ID that opened the stream.
This creates a useful rule:
The server sends only the notification categories the client explicitly requested.
Request-specific progress and log messages do not go through this subscription stream. They stay on the response stream of the request they belong to.
If the connection drops, the client recreates its subscriptions. The server does not retain subscription state across reconnections.
Tool, resource, and prompt lists are now cacheable
MCP clients frequently ask for the same tool catalog. Re-fetching and re-serialising that list wastes work, and even harmless ordering differences can damage an LLM provider’s prompt-cache hit rate (more on this in a separate article soon).
Modern list and resource-read results include:
ttlMs: how long the result should be considered fresh;
cacheScope: whether the response is public or private.
Servers should also return tools in deterministic order.
{
"resultType": "complete",
"tools": [
{
"name": "get_customer",
"description": "Look up a customer by ID",
"inputSchema": {
"type": "object",
"properties": {
"customerId": {"type": "string"}
},
"required": ["customerId"]
}
}
],
"ttlMs": 300000,
"cacheScope": "private"
}This does not make catalogs immutable. A client can subscribe to list-change notifications and invalidate the cache early.
The subtle rule is that a list must not change merely because a request arrived on a different connection. It may still change with authorization: two users can legitimately see different tools. That is why private cache scope matters.
This is a small protocol addition with a large operational effect. Stable tool descriptions improve:
client latency;
server load;
upstream prompt caching;
reproducibility when the same user reconnects.
Tasks are now a proper extension
Long-running work does not fit a request that must stay open until completion. A video render, repository analysis, or large export may take minutes or hours.
Legacy MCP introduced Tasks experimentally inside the core protocol. Modern MCP moves them into the official io.modelcontextprotocol/tasks extension and simplifies the flow.
A server may respond to tools/call with a task handle:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "task", # Result type is task
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"statusMessage": "Analysing repository",
"createdAt": "2026-09-15T04:00:00Z",
"lastUpdatedAt": "2026-09-15T04:00:00Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000
}
}The client polls with tasks/get. If the task needs user or model input, the client supplies it through tasks/update. Cancellation uses tasks/cancel and is cooperative—the server may need time to stop underlying work.
Tasks demonstrate an important boundary in the new architecture:
The protocol core is stateless, but a task is intentionally stateful.
The difference is that task state has an explicit ID and defined lifecycle. It can survive reconnects and be routed deliberately. It is not hidden inside an MCP session.
Authorization is stricter, not completely new
OAuth support did not first appear in 2026-07-28 version of MCP.
MCP added an OAuth 2.1-based authorization framework around March of 2025, then strengthened it through later revisions with Protected Resource Metadata, resource indicators, OpenID Connect discovery, incremental scopes, and Client ID Metadata Documents.
The modern release adds more hardening:
Issuer validation. If the authorization response contains
iss, the client must compare it with the issuer from validated server metadata before exchanging the code. This helps prevent authorization-server mix-up attacks.Credentials are issuer-bound. A client must store credentials by the authorization server that created them and must not reuse them with another issuer.
DCR clients declare application type. Dynamic Client Registration must distinguish native/CLI applications from web applications so localhost redirect rules are applied correctly.
Dynamic Client Registration is deprecated. Client ID Metadata Documents are the preferred registration mechanism, while DCR remains for compatibility.
Bearer credentials still travel on every HTTP request, which naturally fits the stateless protocol.
MCP authorization remains optional. A local stdio server will commonly receive credentials through its environment instead of running an OAuth flow. A remote HTTP server should follow the MCP authorization specification if it requires authorization.
The protocol still does not make an MCP server trustworthy. Clients must validate URLs, protect tokens, respect user consent, and treat server identity fields as self-reported metadata, not proof of identity.
Roots, sampling, and logging are deprecated
Three features remain in the modern schema but are now formally deprecated:
Roots let a client tell a server which filesystem areas it may operate on.
Sampling lets a server request an LLM completion through the client.
Logging lets a server send protocol-level log messages and previously change log levels through logging/setLevel.
They still work during the deprecation window. Deprecated does not mean deleted.
The recommended replacements are:
The new feature-lifecycle policy guarantees at least a twelve-month deprecation period for newly deprecated features. It also maintains a public registry, making removals more predictable.
Deprecating sampling is particularly revealing. Early MCP tried to make the client the gateway to model inference. In practice, servers often need direct, provider-specific control over models, while MRTR makes the indirect route more cumbersome.
Moving model access out of the core keeps MCP focused on interoperability between hosts and external capabilities.
What modern MCP improves
The new architecture is primarily an infrastructure improvement.
Horizontal scaling becomes ordinary: Any self-contained request can land on any compatible replica. Servers no longer require sticky routing merely because MCP negotiated capabilities on a previous connection.
Gateways can understand traffic cheaply: Mcp-Method and Mcp-Name expose enough information for routing, policy, and metering without inspecting arbitrary JSON bodies.
Failures have clearer boundaries: An MRTR response ends one request. A retry is a new request. A task has a task ID. A subscription has a subscription ID. State is named instead of being implied by a connection.
Catalogs stop changing accidentally: Deterministic ordering and cache hints reduce needless list calls and preserve prompt-cache stability.
Extensions have a defined home: Tasks, MCP Apps, and future additions can negotiate support without bloating the protocol core.
Compatibility becomes explicit: The specification defines modern, legacy, and dual-era behaviour rather than assuming every endpoint upgrades at once.
What modern MCP makes harder
Statelessness moves complexity; it does not erase it.
Simple local servers send more metadata: A stdio server running as one child process did not suffer from load-balancer problems, yet it still adopts per-request metadata and modern result types. The wire format is more repetitive for the sake of one consistent protocol.
Multi-step interactions require retries: MRTR is easier for infrastructure but more work for client and server authors. They must preserve arguments, use new request IDs, handle repeated input_required responses, and protect requestState.
Applications must design explicit state: Session variables can no longer quietly carry a selected project, transaction, or workflow. Tools need handles and those handles need authorization, expiry, and cleanup policies.
Broken streams cannot resume in place: Removing SSE replay simplifies semantics, but clients must retry work. Tools with side effects need idempotency protection.
The ecosystem will be mixed for a while: Modern-only clients cannot speak directly to legacy-only servers, and the reverse is also true. SDK support helps, but the wire protocols are not automatically compatible.
Backward compatibility
A dual-era client tries modern behaviour and falls back to legacy behaviour only when the response identifies a legacy server.
The broad flow is:
The details differ by transport:
On stdio, a dual-era client probes with server/discover. A recognised modern result or modern error confirms a modern server; another error or a timeout triggers legacy initialize.
On HTTP, the client attempts a modern request and examines a failed request’s status and JSON-RPC body before falling back.
An UnsupportedProtocolVersionError does not mean “try legacy.” It means the server is modern but does not support the requested modern revision. The client should choose one of the server’s advertised versions.
Supporting two eras means implementing two lifecycles, not adding one version string to a header.
The useful mental model
The difference between legacy and modern MCP can be reduced to one question:
If this request reaches a fresh server replica, does it carry enough information to be understood?
In legacy MCP, the answer could be no. The protocol version, capabilities, session identity, or pending server-to-client exchange might live on the connection or behind a session ID.
In modern MCP, the answer should be yes:
version and client capabilities travel with the request;
routing information travels in headers;
required user or model input returns as an explicit intermediate result;
long work returns an explicit task handle;
durable application state returns an explicit handle;
notifications travel through an explicit subscription;
optional features live in negotiated extensions.
This is not the kind of update that gives an MCP server a flashy new tool. It is the kind that makes the same tools easier to operate behind load balancers, gateways, authentication systems, and failure-prone networks.
The new MCP is not simply “version 1 with fewer sessions.” It is the same tools, resources, and prompts rebuilt around a different unit of understanding: the request instead of the connection.
Next up, I have been spending a lot of time with agent observability as well as LLM inference. I spoke to some folks recently who work in an Inference scaling team at a huge cloud provider, and that got me interested in this topic. If this sounds useful, subscribe to the compute blog using the button below so you do not miss it or share it with someone who might find these posts useful.
Thank you so much for reading, and I’ll see you in the next one. Have a wonderful day!












