Migrating Your MCP Server to the 2026-07-28 Spec: What Breaks
Table of Contents
On July 28, 2026, the Model Context Protocol shipped its most consequential revision yet: the protocol core goes stateless. The initialize handshake, the Mcp-Session-Id header, and the GET stream are gone; mandatory headers arrive that the server must validate against the request body.
With nearly 9,650 servers in the official registry, there’s a long migration queue ahead. This is the detail, checked against the specification itself rather than against third-party summaries.
Expiry warning: this article is useful during the deprecation window, which is twelve months minimum. After that, it stops mattering.
What changed, in one screen
| Through 2025-11-25 | From 2026-07-28 | |
|---|---|---|
| Session | initialize + Mcp-Session-Id | Stateless. No handshake |
| Metadata | In the session | On every request, in _meta |
| HTTP headers | Optional | MCP-Protocol-Version, Mcp-Method, Mcp-Name required |
| Server stream | Standalone GET | subscriptions/listen |
| Server → client requests | JSON-RPC over SSE | MRTR: InputRequiredResult |
| Stream resumption | Last-Event-ID | Not supported |
| Lists | Re-fetched every time | Cacheable with ttlMs |
| Client registration | DCR | CIMD (DCR deprecated) |
| Roots, Sampling, Logging | Current | Deprecated, 12 months |
| HTTP+SSE (2024-11-05) | Deprecated | Still deprecated, now removable |
The underlying motivation is operational: with no session, any request can land on any instance behind a load balancer with no shared storage. MCP becomes an ordinary HTTP workload.
What disappears
The initialize handshake
Gone. Previously the client opened the conversation with initialize, the server replied with capabilities, and the client confirmed with initialized. That exchange pinned a client to one instance.
Now every request travels complete: it carries its protocol version, client info, and capabilities in the body itself.
Mcp-Session-Id
Gone, along with the DELETE that terminated the session. If your server kept per-session state, you have to move it out of the protocol: the spec suggests passing it explicitly as handles in tool arguments.
The GET stream
Clients used to open a GET on the endpoint to receive server-initiated messages. That endpoint no longer exists; its replacement is subscriptions/listen, covered below.
Last-Event-ID
Streams are no longer resumable. If your implementation relied on resuming after a disconnect, that part needs redesigning.
The mandatory headers (and the trap)
This is the part that breaks silently, so here’s the detail.
Every POST to the MCP endpoint must carry:
| Header | Source | Required for |
|---|---|---|
MCP-Protocol-Version | — | All requests |
Mcp-Method | method | All requests |
Mcp-Name | params.name or params.uri | tools/call, resources/read, prompts/get |
A real request, exactly as the specification defines it:
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "ExampleClient",
"version": "1.0.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
The purpose is letting a load balancer or rate limiter route and meter without parsing the JSON. Very sensible.
The trap: headers must match the body
This is where half the migrations fall over. The server must reject any request where a header doesn’t match its counterpart in the body:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
}
}
400 Bad Request with code -32020 (HeaderMismatch). And it isn’t optional: it’s a security requirement. If the balancer routes on the header while the server executes on the body, an attacker who desyncs them gets a request routed one way and executed as something else.
Conditions that force rejection:
- A required header is missing.
- A header value doesn’t match the body value.
- A header value contains invalid characters.
One detail that’s easy to miss: integer values must be compared numerically, not as strings — 42 and 42.0 are equal.
To debug this, the practical move is firing the request by hand and seeing what your server answers before wiring up any client:
When the value doesn’t fit in a header
HTTP headers only accept visible ASCII. If a tool name or URI contains accents, newlines, or leading whitespace, it has to be encoded with a sentinel format:
Mcp-Name: =?base64?SGVsbG8sIOS4lueVjA==?=
The =?base64? prefix and ?= suffix are case-sensitive and must appear exactly like that. The server has to decode before comparing against the body.
And there’s an obscure case worth knowing: if a plain ASCII value happens to start with =?base64? and end with ?=, it must be encoded anyway so it isn’t mistaken for the sentinel.
In Spanish, Portuguese, and any non-Latin script this isn’t a rare case: any resource URI with an accent goes through here.
MRTR: how you ask the user for something now
Previously, if a tool needed a decision from the user mid-execution, the server issued its own JSON-RPC request over the SSE stream. That’s no longer allowed: servers don’t initiate requests.
The replacement is Multi Round-Trip Requests. The server returns a special result and the client calls again:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST tools/call (id: 1)
Note over S: Needs user input
S-->>C: InputRequiredResult<br/>(inputRequests)
Note over C: Gathers what was asked for
C->>S: POST tools/call (id: 2)<br/>original params + inputResponses
S-->>C: Final result
In practice: the server responds with resultType: "input_required" and inputRequests, and the client retries the original request adding inputResponses.
This affects sampling, elicitation, and roots, which used to be server requests and are now fields inside a result. If your server used any of the three, it’s the biggest rewrite in the migration — and note that the server now has to resume work from a second call, not from an open connection.
subscriptions/listen for notifications
Change notifications (notifications/tools/list_changed, notifications/resources/updated) no longer arrive on a GET stream. The client opens a long-lived stream with a request:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST subscriptions/listen<br/>(notification filter)
S-->>C: SSE: subscriptions/acknowledged
Note over C,S: Stream stays open
S-->>C: SSE: tools/list_changed
S-->>C: SSE: resources/updated
Two things the spec makes explicit and that are easy to conflate:
- Request-scoped notifications (
notifications/progress,notifications/message) travel on that request’s stream, not on the listen stream. - On long streams you should periodically emit an SSE comment (a line starting with a colon,
:\r\n) as a keep-alive, so no intermediary closes the connection during quiet periods.
And if you serve SSE behind nginx, the header that stops the proxy from buffering events:
X-Accel-Buffering: no
Cacheable lists
tools/list, prompts/list, resources/list, and resources/read can now declare how long their answer is worth:
ttlMs— how long it may be cachedcacheScope— at what scope
It’s the cheapest improvement to adopt and the one that saves the most traffic: the tool catalog was being re-fetched on every client start for no reason.
Authorization: from DCR to CIMD
Dynamic Client Registration (DCR) is deprecated in favor of Client ID Metadata Documents (CIMD). DCR keeps working through the twelve-month window.
Three hardening measures come with it:
- Servers must return the
issparameter per RFC 9207, and clients must validate it before redeeming the authorization code. - Client credentials are bound to the authorization server that issued them, preventing reuse against another.
- Clients declare
application_typeat registration, which finally resolveslocalhostredirect errors for desktop and CLI applications.
Deprecated with a twelve-month clock
Three features enter countdown:
- Roots
- Sampling
- Logging
Plus the HTTP+SSE transport from 2024-11-05, already deprecated since 2025-03-26, which enters its final ramp: the spec now declares it eligible for removal in a future revision.
If your server still exposes the old transport’s SSE + POST endpoint pair, that’s the work you can’t defer much longer.
Serving clients that haven’t migrated
A server speaking only the new revision that receives old traffic should behave like this:
| Receives | Responds |
|---|---|
GET or DELETE on the MCP endpoint | 405 Method Not Allowed |
Mcp-Session-Id header | Ignore it. Don’t mint or echo session IDs |
Last-Event-ID header | Ignore it; streams aren’t resumable |
| A method it doesn’t implement | 404 + JSON-RPC -32601 |
| Unsupported protocol version | 400 + UnsupportedProtocolVersionError listing supported versions |
That 404 with a JSON-RPC body exists for a specific reason: it distinguishes “I don’t implement that method” from a 404 returned by a legacy HTTP+SSE server that doesn’t host the modern endpoint at all.
Detection works in reverse on the client: try modern first and, on a 400, inspect the body before giving up. If it carries a recognized JSON-RPC error, the server is modern and the request needs correcting; if it’s empty or unrecognizable, then falling back to initialize is correct.
Security, still as neglected as ever
The spec insists on three things that aren’t new but keep being ignored:
- Validate the
Originheader on every incoming connection, returning403 Forbiddenwhen invalid. Without it, any website can DNS-rebind against your local MCP server. - Bind only to
127.0.0.1when running locally, never0.0.0.0. - Authenticate every connection.
The first is the most skipped, and it’s what turns a development MCP server into an open door from the browser of whoever visits the wrong page.
SDK status, and what about PHP
The official Tier 1 SDKs already support 2026-07-28: TypeScript, Python, Go, and C#. Rust is in beta.
PHP isn’t on that list, neither in Tier 1 nor with announced support. If you run an MCP server on Laravel with the laravel/mcp package — like the one in how to build an AI agent with Laravel and MCP — the good news is that the package abstracts the protocol, so most of this change doesn’t touch your tool code.
The bad news is that you depend on the package updating to speak the new revision, and on your project still using the SSE transport meanwhile. Worth pinning the version and watching the repository before clients start requiring the new spec.
Migration checklist
-
initialize/initializedhandshake removed -
Mcp-Session-Idand the session-endingDELETEremoved - Session state converted into explicit handles in arguments
- GET endpoint removed;
GET/DELETEreturn405 -
MCP-Protocol-Version,Mcp-Method,Mcp-Nameread and validated against the body -
-32020error implemented with400 -
=?base64?…?=sentinel decoded before comparing - Sampling, elicitation, and roots migrated to MRTR
-
subscriptions/listenimplemented for change notifications -
X-Accel-Buffering: noand keep-alive on long streams -
ttlMsandcacheScopedeclared on list responses -
Originvalidation with403 - Plan for DCR → CIMD before the window closes
- HTTP+SSE transport given a retirement date on the calendar
Conclusion
This revision doesn’t add features: it removes the ones that prevented scaling. A stateful protocol pins each client to one instance; without it, an MCP server sits behind a round-robin balancer and that’s the end of it.
The cost of that simplification is paid in three places: session state has to be rebuilt as explicit handles, sampling and roots have to be redone with MRTR, and headers have to be validated against the body — the new requirement most people will implement wrong or skip entirely.
On urgency: the deprecation window is twelve months minimum, so nothing is on fire. But if your server still lives on the 2024 HTTP+SSE transport, that one has been deprecated across two revisions and is now eligible for removal.