MCP's 2026-07-28 Spec: The Stateless Rewrite, and What Your Server Needs to Change
The July 2026 MCP spec removes sessions and the initialize handshake. Verified changes, real requests against a C# server, gateway routing, and a migration checklist.
By Ajith joseph · · Updated · 10 min read · intermediate
The 2026-07-28 revision of the Model Context Protocol is the biggest change to its HTTP transport since Streamable HTTP replaced HTTP+SSE, and it breaks an assumption a lot of servers quietly rely on: that a connection has a session, and that the session remembers things.
The 2026-07-28 revision removes protocol-level sessions and the initialize handshake. Every request now stands alone and carries everything the server needs. That is a genuine simplification for anyone running MCP behind a load balancer, and a migration task for anyone who stored state against Mcp-Session-Id.
This post goes through what changed using the specification's own changelog, then checks the important parts against a running server: the official C# SDK, a small stateless server, and real requests and responses. The responses below are captured output, reformatted only for readability.
What Changed
The spec's changelog lists nine major changes. The ones that affect most servers:
- No sessions. The
Mcp-Session-Idheader is gone from Streamable HTTP, and list endpoints (tools/list,resources/list,prompts/list) no longer vary per connection. Servers that need state across calls use "explicit, server-minted handles passed as ordinary tool arguments." - No handshake. The
initializeandnotifications/initializedexchange is removed. Every request carries its protocol version and client capabilities in_meta. server/discover. Servers must implement this RPC to advertise supported versions, capabilities and identity. Clients may call it first, but do not have to.subscriptions/listen. One long-lived POST-response stream replaces the standalone GET endpoint andresources/subscribe. Clients opt in to the notification types they want.- Multi Round-Trip Requests. Servers no longer send their own requests to the client (elicitation, sampling, roots). They return an
InputRequiredResult, and the client retries the original request with the answers attached. - Tasks are an extension. Long-running tasks move out of the core into the official
io.modelcontextprotocol/tasksextension, with polling throughtasks/getand a newtasks/update. - No stream resumption.
Last-Event-IDand SSE event IDs are gone. A broken response stream loses the in-flight request, and the client must re-issue it with a new request ID.
The smaller changes matter in production too. List results now carry required ttlMs and cacheScope fields so clients can cache them. Servers should return tools/list in a deterministic order, which the spec says helps client caching and improves LLM prompt cache hit rates. And error codes were reorganised: -32020 to -32099 are now reserved for the specification.
What Is Deprecated
Deprecated features keep working for a minimum of twelve months under the new lifecycle policy, but new code should not adopt them:
- Roots, Sampling and Logging. The suggested replacements are passing directories or files as tool parameters or resource URIs, calling your LLM provider's API directly, and logging to stderr or using OpenTelemetry.
- The old HTTP+SSE transport. Migrate to Streamable HTTP.
- Dynamic Client Registration (RFC 7591) as a registration mechanism, in favour of Client ID Metadata Documents. It remains available for authorization servers that do not support the newer approach.
On the Wire: Real Requests Against a Real Server
Reading a changelog is not the same as seeing the bytes. I built a minimal stateless server with the official C# SDK (ModelContextProtocol.AspNetCore, which resolved to version 2.2.0), ran it locally and sent it requests in the new format.
First, server/discover with the required per-request metadata and headers:
curl -s http://localhost:5299/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-check","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
The server answered:
{
"result": {
"supportedVersions": ["2026-07-28"],
"capabilities": { "logging": {}, "tools": {} },
"ttlMs": 0,
"cacheScope": "private",
"resultType": "complete",
"_meta": { "io.modelcontextprotocol/serverInfo": { "name": "mcpcart", "version": "1.0.0.0" } }
},
"id": 1,
"jsonrpc": "2.0"
}
You can see several spec changes in one response: supportedVersions, the required resultType, the cacheability fields, and the server identifying itself in _meta. There was no handshake before it and there is no session after it.
Now a tools/call with the routing headers. Mcp-Method mirrors the JSON-RPC method, and Mcp-Name mirrors params.name (or params.uri for resources):
curl -s http://localhost:5299/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: create_cart' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"create_cart","arguments":{},
"_meta":{ "io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-check","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{} }}}'
It returned HTTP 200 with the tool result. Then the same call with a deliberately wrong header, Mcp-Name: add_item while the body still says create_cart:
{
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'add_item' does not match body value 'create_cart'."
},
"id": 3,
"jsonrpc": "2.0"
}
HTTP status 400. This is the spec's server validation rule working as written: a server that reads the body must reject a request whose headers disagree with it. The reason is security, not tidiness. If a gateway routes or rate-limits on the header while the server executes based on the body, a client could send a cheap header with an expensive body. Validating that they match closes that gap.
One more observation. The same server also answered an old-style tools/list with no _meta and no headers, the way the previous revision's clients send it. In stateless mode this SDK version appears to serve both eras on one endpoint. I only tested this one server, so verify your own SDK version before you assume the same.
Replacing Sessions With Handles
If your server never stored anything against a session, most of this is free. If it did, the spec's answer is the handle pattern: a tool creates state and returns an identifier, and later calls pass it back as an ordinary argument.
Here is a small cart server that works statelessly. I compiled it against the SDK and ran the calls in the previous section against it.
using System.ComponentModel;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddMcpServer()
.WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless)
.WithTools<CartTools>();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();
[McpServerToolType]
public sealed class CartTools(IMemoryCache cache)
{
private static readonly TimeSpan Lifetime = TimeSpan.FromHours(1);
[McpServerTool(Name = "create_cart")]
[Description("Create an empty cart and return its cartId. Pass that cartId to every other cart tool.")]
public string CreateCart()
{
var cartId = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
cache.Set(cartId, new List<string>(), Lifetime);
return cartId;
}
[McpServerTool(Name = "add_item")]
[Description("Add one SKU to the cart identified by cartId. Returns the cart contents.")]
public string AddItem(
[Description("The cartId returned by create_cart.")] string cartId,
[Description("The SKU to add.")] string sku)
{
if (!cache.TryGetValue(cartId, out List<string>? items) || items is null)
return "Unknown or expired cartId. Call create_cart to start a new cart.";
lock (items) { items.Add(sku); }
cache.Set(cartId, items, Lifetime);
return quot;Cart {cartId} now holds: {string.Join(", ", items)}";
}
}
I ran create_cart, then called add_item with the returned handle in a separate request with no session, and it worked. A bogus handle returned the corrective message instead of throwing, which matters because the consumer is a model that can act on a sentence.
Three things before you copy this into production:
- Use a shared store.
IMemoryCacheworks on one instance. Behind a load balancer, which is the whole point of going stateless, useIDistributedCacheor a database, or every second request lands on a node that has never heard of the cart. - Bind the handle to the caller. A random handle is unguessable, but if a handle leaks it should not be usable by someone else. Store the authenticated principal alongside it and check it on every call.
- Treat the handle as a capability with a lifetime. Expire it, and return a clear message when it has expired so the model knows to start again.
Gateways Get Simpler, With One Caveat
The header requirement exists so intermediaries can route and meter without parsing JSON. An nginx rule that throttles one expensive tool looks like this. I have not run this configuration, so treat it as a starting point and test it in your own environment.
# Only trust the mirrored headers for revisions that require header/body validation.
map $http_mcp_protocol_version $mcp_trusted {
default 0;
"2026-07-28" 1;
}
map "$mcp_trusted:$http_mcp_method:$http_mcp_name" $heavy_key {
default "";
"1:tools/call:export_report" $binary_remote_addr;
}
# An empty key means "do not count this request".
limit_req_zone $heavy_key zone=mcp_heavy:10m rate=5r/m;
server {
location /mcp {
limit_req zone=mcp_heavy burst=2 nodelay;
proxy_pass http://mcp_backend;
proxy_buffering off; # SSE responses must not be buffered
}
}
The caveat is in the spec, and I have paraphrased it in the first map: intermediaries that enforce policy on the mirrored headers should check that MCP-Protocol-Version indicates a version requiring header and body validation, and reject the request if it is older or absent, rather than trusting header values nobody validated. A legacy client can send any header it likes. Also note the spec recommends servers send X-Accel-Buffering: no on SSE responses so proxies do not buffer the stream.
For tools you want to meter by argument, the spec adds x-mcp-header, which lets a server mirror a designated tool parameter into an Mcp-Param-{Name} header. Only primitive parameters qualify, and values that are not plain ASCII are Base64-encoded with a documented sentinel format.
Authorization Changes
The authorization changes are modest but worth reading if you run your own authorization server:
- Clients must validate the
issparameter in authorization responses against the recorded issuer before redeeming the code (RFC 9207). - Client credentials are bound to the authorization server that issued them. Clients must key stored credentials by issuer, must not reuse them elsewhere, and must re-register when the authorization server changes.
- Clients must specify an appropriate
application_typeduring Dynamic Client Registration. - Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents.
What About Copilot Studio?
If you connect MCP servers to Copilot Studio, note what Microsoft's documentation says and does not say. The current page states Copilot Studio supports the Streamable transport, and that SSE has not been supported since August 2025. It does not say which specification revision Copilot Studio speaks. So do not remove support for the previous revision from a server that Copilot Studio agents call until you have tested a real agent against it. The spec's own backward compatibility guidance helps here: a server that supports only the new revision should ignore an Mcp-Session-Id header and return 405 for GET or DELETE from old clients, and a server can serve both eras.
If you read my earlier post on wiring a .NET MCP server to an order system, the tools/list curl in it uses the previous revision's request shape. It still worked against this SDK version, and the stateless mode it uses is the direction the spec moved.
Migration Checklist
- Upgrade the SDK and read its release notes. The official SDKs (TypeScript, Python, Go, C# and a beta Rust one, according to the MCP blog) have been updated, but check your version
- Search for
Mcp-Session-Id,initializeand any per-connection caches. Each is state you must move into a handle or a shared store - Replace Roots, Sampling and Logging use with tool parameters, direct provider calls and OpenTelemetry. You have at least twelve months
- Move server-initiated requests (elicitation) to the multi round-trip pattern
- Return
tools/listin a deterministic order, and set sensiblettlMsandcacheScope - Add gateway rules on
Mcp-MethodandMcp-Name, gated on the protocol-version header - If you use Dynamic Client Registration, plan the move to Client ID Metadata Documents
- Test with the real clients that matter to you before you retire anything
The direction is clear. MCP is being reshaped from a protocol that assumed a long-lived local process into one that looks like the rest of your HTTP infrastructure: stateless, cacheable, routable, and honest about which requests it rejects. The migration cost is real for stateful servers, and small for everyone else.