Migrate your MCP server from 2025-11-25 to 2026-07-28
Last verified: 8 August 2026 · spec 2026-07-28 · ruleset 1.4.2
MCP's 2026-07-28 revision removes protocol-level sessions and the initialize handshake, requires new headers on every request, adds required caching metadata, replaces server-initiated requests with a retry pattern, and retires several methods. This guide covers each change in order, with before/after code, so you can migrate a real server section by section.
Before you start: modern, legacy, dual-era
The spec uses three terms worth adopting verbatim, because they're what you'll search for and what the validator reports back: modern means a protocol version that conveys version, identity, and capabilities as per-request metadata (2026-07-28 and later); legacy means a version that establishes a session with an initialize handshake (2025-11-25 and earlier); dual-era means an implementation that supports both. Most of the work below is becoming modern. Step 10 covers dual-era support if you still need to serve legacy clients during the transition.
How long this takes, and what order to do it in
For most servers this is a set of mechanical edits rather than a rewrite. The honest estimate for a straightforward tool-only server is half a day: deleting the session store and handshake, adding three headers and validating them against the body, adding one field to every result, and adding two fields to five list responses. What genuinely takes longer is MRTR, and it only affects you if your server previously sent its own requests to the client for sampling, elicitation, or roots.
The order below is not the order the specification is written in. It is ordered by which failures mask others. A server that still requires initialize fails a modern client's very first request, so nothing else you fix is observable until statelessness is done — you cannot test your new headers against a server that refuses to answer at all. Headers come next because a rejected request produces no result to inspect, and result shape and caching metadata come after that because they are only visible once a request succeeds.
If you only have an hour, do steps 1, 2 and 4. Those three are what stand between a modern client and a working conversation with your server; everything after them improves conformance without being the difference between working and not.
How to tell which era your server is in right now
Before changing anything, establish where you are starting from. One request answers it, and it needs nothing but curl:
curl -i https://your-server.example.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'A tool list comes back and you are already at least partly modern — skip to step 2. An error about initialization or a missing session means you are legacy, and step 1 is the whole job. A 400 mentioning headers means the server understood the request and objected to something specific, which is the best possible starting position: it is already speaking the modern protocol and telling you exactly what it wants.
Note the Accept header listing both application/json and text/event-stream. It is required on every request by the Streamable HTTP transport, and omitting it is the most common reason a hand-written curl probe gets a 406 from a server that is behaving perfectly correctly.
“Make MCP stateless: remove the initialize/notifications/initialized handshake.”
This is the change everything else follows from. Legacy servers establish a session via initialize, then depend on the client re-sending an Mcp-Session-Id header on every subsequent request. A modern server processes every request independently — no state is inferred from previous requests, even ones on the same connection.
// 1. Client opens a session
POST /mcp HTTP/1.1
Content-Type: application/json
{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "ExampleClient", "version": "1.0.0" }
}
}
// Server responds with a session ID
HTTP/1.1 200 OK
Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000
{ "jsonrpc": "2.0", "id": 1, "result": { ... } }
// 2. Client sends notifications/initialized
// 3. Every later request replays the session ID
POST /mcp HTTP/1.1
Mcp-Session-Id: 550e8400-e29b-41d4-a716-446655440000
...// Every request is self-contained. No handshake,
// no session ID — ever.
POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}If your server needs to correlate state across calls — a long-running task, a paginated cursor — pass an explicit, server-minted handle as an ordinary tool argument instead. Don't reach for a session.
What breaks if you skip this: everything. A modern client opens with a real request and never sends a handshake, so a server that requires one refuses the very first thing it is asked. There is no partial degradation and no useful error the client can recover from — it simply cannot talk to you. This is also why the step is first: until it is done, none of your other changes are observable, because no request ever gets far enough to exercise them.
The subtler half is state your server infers from the connection rather than stores in a map. A cached “this socket is authenticated” flag, a protocol version held in a connection-scoped variable, a per-connection tool filter — each survives deleting the session store and each still violates the rule. The test is whether two identical requests arriving on different connections produce identical responses. If they can differ, something is still being remembered.
“Mcp-Method | method | All requests. These headers are REQUIRED for compliance.”
Three headers are now required on Streamable HTTP: MCP-Protocol-Version on every POST, Mcp-Method on every request (mirroring the JSON-RPC method field), and Mcp-Name on tools/call, resources/read, and prompts/get. They exist so a gateway can route and inspect a request without parsing the body.
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": 2, "method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}Servers must reject a request where a header value doesn't match the corresponding body value — with 400 Bad Request and JSON-RPC error code -32020 (HeaderMismatch).
Our recommendation, not a spec quote: run this check before dispatching the method. A server that returns -32601 instead of -32020 on a mismatched header has validated in the wrong order — the spec doesn't state this ordering explicitly, but it follows from the MUST-reject requirement above.
What breaks if you skip this: a modern client sends all three headers whether you read them or not, so nothing fails immediately — which is exactly what makes this dangerous. The headers exist so an intermediary can route and rate-limit without parsing the body. A server that never validates them is fine alone and becomes a policy hole the moment a gateway sits in front of it, because the gateway trusts a header the server never checked against the body.
Read headers case-insensitively. Node lowercases incoming field names for you, but an exact-case lookup against a rawHeaders object, or a framework that preserves the original spelling, will return undefined for a perfectly valid request — which your code then reports as a missing required header. It works against every client in your test suite until it meets one that spells things differently.
3. Implement server/discover
MCP-DSC-001“Servers MUST implement server/discover.”
Servers must implement server/discover — it advertises the protocol versions, capabilities, and identity your server supports, so a client can learn this up front instead of guessing and retrying. It's also the fastest way for a validator (ours included) to classify your server's era in a single request.
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{ "jsonrpc": "2.0", "id": 0, "method": "server/discover", "params": {} }
// →
{
"jsonrpc": "2.0", "id": 0,
"result": {
"resultType": "complete",
"supported": ["2026-07-28"],
"capabilities": { "tools": {} }
}
}The spec requires server/discover and describes what it must convey, but doesn't publish a worked request/response example the way it does for tools/call. The shape above is a reasonable construction from the described behavior, not a literal quote — check your SDK's exact response schema.
“The result MUST include a resultType field to indicate the type of the result.”
Every result must include a resultType field — "complete" for an ordinary result, "input_required" for a Multi Round-Trip Request interim result (Step 6). It's the single cheapest, most mechanical fix in this whole migration — and clients treat an absent field from an earlier-revision server as "complete", so this is purely additive.
What breaks if you skip this: a modern client treats a result it cannot classify as invalid and discards it. Your server looks like it is working — status 200, a well-formed body, no error anywhere in your logs — and the client shows nothing. It is the cheapest fix in this guide and one of the hardest to diagnose from the server side, because from where you are standing the request succeeded.
Add it in one place rather than per handler. A single helper that wraps every result is what stops a server being conformant on three endpoints and not the fourth — see the worked example below for the shape.
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ ... ] } }{
"jsonrpc": "2.0", "id": 1,
"result": { "resultType": "complete", "tools": [ ... ] }
}“Require ttlMs and cacheScope fields on results returned by tools/list, prompts/list, resources/list, resources/read, and resources/templates/list via a new CacheableResult interface.”
Five methods now require a CacheableResult shape: tools/list, prompts/list, resources/list, resources/read, and resources/templates/list. ttlMs is a freshness hint in milliseconds; cacheScope is "public" or "private" and controls whether a shared intermediary may cache the response.
{
"jsonrpc": "2.0", "id": 1,
"result": { "tools": [ ... ] }
}{
"jsonrpc": "2.0", "id": 1,
"result": {
"resultType": "complete",
"tools": [ ... ],
"ttlMs": 60000,
"cacheScope": "public"
}
}Our recommendation, not a spec quote: if a tools/list response differs per user (say, tools gated by permission), mark it cacheScope: "private". The spec defines what the field means but doesn't call this specific mistake out — we do, because marking a personalized result "public" is a real data-leak class.
What breaks if you skip this: nothing visible, which is why these two fields outlive every other omission on this list. Clients keep working; the validator reports two MUST-level failures. The cost is real but indirect — without a freshness hint a client re-fetches your list far more often than it needs to, and a stateless protocol already asks for those lists more than a session-based one did.
If you cannot justify a number, ttlMs: 0 with cacheScope: "private" is conformant and honest. Zero is a non-negative number, and it says “do not reuse this” explicitly rather than by omission. Reach for a real TTL once you can defend it.
6. Replace server-initiated requests with MRTR
The largest architectural change after statelessness. Servers can no longer send their own JSON-RPC requests to the client mid-flight (for sampling, elicitation, or reading roots). Instead, a server that needs input returns an InputRequiredResult, and the client retries the original request with the answer attached.
// Legacy: server sends its OWN request mid-flight
Client → Server: tools/call (id: 1)
Server → Client: elicitation/create (a NEW request)
Client → Server: elicitation response
Server → Client: final result for id: 1// Modern: the server replies to the SAME request
Client → Server: POST tools/call (id: 1)
Server → Client: {
"result": {
"resultType": "input_required",
"inputRequests": [{ "type": "elicitation/create", ... }]
}
}
// Client gathers the input, then retries the SAME call
Client → Server: POST tools/call (id: 2, same params + inputResponses)
Server → Client: { "result": { "resultType": "complete", ... } }What breaks if you skip this: a server still sending its own requests is speaking into a channel no modern client is listening on. The client sees a call that never returns, and eventually times out. Unlike the other changes here there is no error to read — the symptom is a hang, which is the hardest failure mode to attribute to a protocol revision.
The design consequence worth planning for is that your server no longer gets to remember what it was doing between the two legs. If the first leg did expensive work before discovering it needed input, that work is gone. Either ask for everything you might need before starting, or return an explicit handle the client passes back — but scope that handle to the caller, because a handle that outlives its authorization check is a worse problem than the one it solved.
“If the server does not implement the requested version, it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.”
JSON-RPC's -32000 to -32099 server-error range is now partitioned: -32000–-32019 is legacy and shouldn't be used for new codes; -32020–-32099 is reserved for the spec itself. Two renumberings to make directly:
- Resource-not-found:
-32002→-32602(Invalid params) - New codes:
-32020HeaderMismatch,-32021MissingRequiredClientCapability,-32022UnsupportedProtocolVersion
Do not emit -32002 or -32042 (URL elicitation, 2025-11-25 only) — both are retired codes under this revision.
What breaks if you skip this: very little, immediately — which is what lets retired codes survive a migration untouched. A client receiving -32002 knows something went wrong; it just has no definition for what. The real cost arrives later, when the specification defines a number you already picked for your own error inside the reserved range and your meaning and its meaning collide in the same field.
“Remove ping, logging/setLevel, and notifications/roots/list_changed.”
ping, logging/setLevel, and the HTTP GET stream endpoint plus resources/subscribe/unsubscribe are gone.
// Log level set once via a dedicated call
Client → Server: logging/setLevel { "level": "info" }
// Subscribe to a resource
Client → Server: resources/subscribe { "uri": "file:///config" }
Server → Client: notifications/resources/updated// Log level travels on the request itself
POST /mcp Mcp-Method: tools/call
{ "params": { "_meta": { "io.modelcontextprotocol/logLevel": "info" } } }
// One long-lived stream, opted into by type
POST /mcp Mcp-Method: subscriptions/listen
{ "params": { "resourceSubscriptions": true } }
// → SSE stream stays open, delivers
// notifications/resources/updated as they occurWhat breaks if you skip this: nothing on your side — a server that still implements ping simply answers a call nobody makes. The breakage is on the client side, and it is usually yours: a keepalive or health check written against the old protocol keeps calling a method a conformant server now refuses, and the resulting method-not-found reads like an outage rather than a removal.
Search your own codebase for these three before you search your server. The log level in particular tends to be set once at startup in code nobody has looked at since it was written.
“MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728).”
Two changes matter most for a server acting as an OAuth 2.1 resource server: you must now implement RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource, and your authorization server should return an iss parameter per RFC 9207 and advertise authorization_response_iss_parameter_supported: true in its metadata. The iss validation itself is a client-side obligation — your server can't implement it, only make it possible.
What breaks if you skip this: an unauthenticated server is unaffected, and a server with authorization but no Protected Resource Metadata leaves clients with a 401 and nowhere to go. That is the practical failure — not a rejected request, but a rejected request with no discoverable way to fix it. RFC 9728 is what turns “denied” into “denied, authenticate here”.
The field to get right is resource: it must be the canonical URI clients actually connect to, because it is both what they request a token for and what you validate the token's audience against. If those two disagree, every token you issue fails your own check.
10. If you still need to serve legacy clients
A dual-era server can support both at once. Which behavior it uses is a property of how the request opens: a request carrying modern per-request _meta is served statelessly per this guide; an initialize request selects legacy session semantics for that connection. If you only support modern versions, name your supported versions in the error you return to an initialize request — legacy clients have no fall-forward mechanism, and that message may be the only diagnostic they can show a user.
A complete server, before and after
The steps above are each shown in isolation. Here is one small but complete server carrying every change at once, so the shape of the finished thing is visible rather than assembled from ten fragments. It exposes a single tool and nothing else — deliberately, so the protocol work is not buried under application logic.
import express from "express"
const app = express()
app.use(express.json())
// Sessions: the thing that has to go.
const sessions = new Map()
app.post("/mcp", async (req, res) => {
const { id, method, params } = req.body
if (method === "initialize") {
const sessionId = crypto.randomUUID()
sessions.set(sessionId, { protocolVersion: params.protocolVersion })
res.setHeader("Mcp-Session-Id", sessionId)
return res.json({ jsonrpc: "2.0", id, result: {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "weather", version: "1.0.0" },
}})
}
// Everything else requires a session.
const session = sessions.get(req.header("Mcp-Session-Id"))
if (!session) {
return res.status(400).json({ jsonrpc: "2.0", id,
error: { code: -32002, message: "Server not initialized" } })
}
if (method === "tools/list") {
return res.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } })
}
if (method === "tools/call") {
return res.json({ jsonrpc: "2.0", id, result: {
content: [{ type: "text", text: await lookUp(params.arguments.location) }],
}})
}
res.json({ jsonrpc: "2.0", id,
error: { code: -32601, message: "Method not found" } })
})
// The standalone SSE stream, for server-initiated messages.
app.get("/mcp", (req, res) => { /* … */ })import express from "express"
const app = express()
app.use(express.json())
const PROTOCOL_VERSION = "2026-07-28"
const SERVER_INFO = { name: "weather", version: "1.0.0" }
const HEADER_MISMATCH = -32020
// No session store. Nothing to delete later, nothing to leak.
function headerError(req) {
const h = (n) => req.headers[n.toLowerCase()]
const { method, params } = req.body ?? {}
if (!h("mcp-protocol-version")) return "MCP-Protocol-Version is required"
if (!h("mcp-method")) return "Mcp-Method is required"
if (h("mcp-method") !== method) return "Mcp-Method does not match the body"
const meta = params?._meta ?? {}
const metaVersion = meta["io.modelcontextprotocol/protocolVersion"]
if (metaVersion && h("mcp-protocol-version") !== metaVersion) {
return "MCP-Protocol-Version does not match _meta"
}
if (["tools/call", "resources/read", "prompts/get"].includes(method)) {
const target = params?.name ?? params?.uri
if (!h("mcp-name")) return `Mcp-Name is required on ${method}`
if (h("mcp-name") !== target) return "Mcp-Name does not match the body"
}
return null
}
const ok = (payload) => ({
resultType: "complete",
...payload,
_meta: { "io.modelcontextprotocol/serverInfo": SERVER_INFO },
})
app.post("/mcp", async (req, res) => {
const { id, method, params } = req.body ?? {}
// Validate headers BEFORE dispatch, or a mismatch surfaces as -32601.
const problem = headerError(req)
if (problem) {
return res.status(400).json({ jsonrpc: "2.0", id,
error: { code: HEADER_MISMATCH, message: problem } })
}
switch (method) {
case "server/discover":
return res.json({ jsonrpc: "2.0", id, result: ok({
supportedVersions: [PROTOCOL_VERSION],
capabilities: { tools: {} },
})})
case "tools/list":
return res.json({ jsonrpc: "2.0", id, result: ok({
tools: TOOLS, // a stable array — deterministic order
ttlMs: 3600000,
cacheScope: "public", // no per-user filtering, so this is honest
})})
case "tools/call": {
const text = await lookUp(params.arguments.location)
return res.json({ jsonrpc: "2.0", id, result: ok({
content: [{ type: "text", text }],
})})
}
default:
// 404 AND -32601 — the spec names both.
return res.status(404).json({ jsonrpc: "2.0", id,
error: { code: -32601, message: `Method not found: ${method}` } })
}
})
// Nothing to act on without sessions.
app.get("/mcp", (_req, res) => res.status(405).end())
app.delete("/mcp", (_req, res) => res.status(405).end())Two things are worth noticing about the result. The first is that it is shorter than what it replaced, despite doing more validation — the session bookkeeping was carrying more weight than it looked. The second is that every remaining line is a pure function of the current request. There is no state that can be stale, no cleanup to schedule, and no reason a request must reach the same instance twice.
resultType and serverInfo impossible to forget. Those two fields are required on every result, and hand-adding them per handler is how a server ends up conformant on three endpoints and not the fourth.Verifying the migration yourself
Four requests establish whether the migration landed. Run them against your own server before you point a client at it — each one isolates a different step above, so a failure tells you which change did not take.
curl -s -o /dev/null -w "%{http_code}\n" https://your-server/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
# want: 200# Mcp-Method says list; the body says call.
curl -s https://your-server/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" \
-H "Mcp-Method: tools/list" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x"}}'
# want: HTTP 400 and JSON-RPC code -32020
# a -32601 here means you validated AFTER dispatchcurl -sI https://your-server/mcp -X POST \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: probe" \
... | grep -i mcp-session-id
# want: no output at all.
# The header must be neither minted NOR echoed — and a request
# carrying one must be IGNORED, not rejected.curl -s -o /dev/null -w "GET %{http_code}\n" https://your-server/mcp
curl -s -o /dev/null -w "DELETE %{http_code}\n" -X DELETE https://your-server/mcp
# want: 405 for bothThese four cover the changes that break clients. They do not cover caching metadata, error-code renumbering, schema constraints, or the authorization chain — those fail quietly, which is precisely why they survive a manual check and need a validator.
Five mistakes that survive a migration
1. Deleting the session store but keeping the handshake
The most common half-migration. The server no longer mints an Mcp-Session-Id, but still refuses to answer until it has seen an initialize. These are two separate rules — MCP-STL-001 and MCP-STL-003 — and passing one says nothing about the other.
2. Rejecting a client-supplied Mcp-Session-Id instead of ignoring it
Stricter is not more conformant here. Older clients will keep sending the header for a long time, and the spec says to ignore it, not to refuse the request. Returning a 400 to a client that is otherwise speaking correctly turns a harmless leftover into an outage.
3. Marking a per-user tool list cacheScope: "public"
The one caching mistake with a security consequence rather than a performance one. If two callers can get different bytes from tools/list, it is private — even if the difference looks harmless today, because the shared cache acting on that field has no way to re-check your judgement later.
4. Validating headers after dispatching the method
Produces -32601 where -32020 belongs. It looks like a rejection either way, which is what lets it survive testing — but it means a gateway relying on the header for routing can be told one thing while the body does another.
5. Matching header names case-sensitively
Works against every client in your test suite and fails against the one that sends mcp-method in different case, or an HTTP/2 stack that lowercases field names. RFC 9110 makes field names case-insensitive and the spec restates it; an exact-case lookup returning undefined then reads as a missing required header.
Why the specification made these changes
Every change above follows from one decision: removing protocol-level sessions. Understanding that makes the rest predictable rather than arbitrary, and it is worth the two minutes because it tells you what future revisions are likely to do as well.
A session is state the transport holds between requests. It is also, in practice, the thing that makes a protocol hard to operate. A session pins a client to one server instance, so a load balancer needs sticky routing. It has to be created, stored, expired and cleaned up, so the server needs a store and a garbage-collection story. It cannot survive a process restart, so a deploy drops every conversation in flight. And it makes serverless awkward to the point of being a workaround rather than a deployment target, because a function that may not exist between two requests cannot hold anything between them.
Removing sessions makes every one of those problems disappear, and creates exactly one new one: the context the handshake used to establish once now has to travel on every request. That is the _meta block — protocol version, client identity, capabilities — repeated per call. It is more bytes, and that is the whole cost.
The rest of the revision pays that cost back. Caching metadata exists because a stateless client re-asks for lists it used to learn once, and ttlMs lets it stop. The required headers exist because a stateless request is self-contained, which means an intermediary can finally route it without parsing the body — something a session-bearing request could never allow. MRTR exists because a server that cannot hold state also cannot hold a half-finished conversation, so “I need more input” had to become a result rather than a callback. And server/discover exists because something still has to answer “what do you support?” once the handshake that used to answer it is gone.
Read that way, the revision is not six unrelated breaking changes. It is one change and five consequences — which is also why doing them in the order above works, and why doing statelessness last would mean redoing several of the others.
If you maintain a client rather than a server
Most of this guide is server work, but clients carry obligations of their own in this revision, and two of them are security-relevant.
Send the metadata, on every request. A client that used to send initialize once now attaches protocolVersion, clientCapabilities and ideally clientInfo to every call, plus the three headers. A server is required to reject a request whose headers disagree with its body, so build both from the same values at the point you construct the request — deriving the header in a shared wrapper that does not know the final method is the usual way this goes wrong.
Validate the iss parameter. RFC 9207 is a client obligation, not a server one. When an authorization response comes back, compare its iss against the issuer you expected before redeeming the code. Skipping this leaves the authorization-server mix-up attack open, and no amount of correctness on the server side closes it for you. A server can only make it possible by choosing an authorization server that sends the parameter.
Handle input_required. Under MRTR a result may say the server needs something before it can finish. A client that treats any non-complete result as an error will break against a conformant server doing exactly what the spec asks. Gather the requested input and re-send the original call with the answers attached.
Stop sending removed calls. ping keepalives are the common one — there is no connection to keep alive any more, and a conformant server answers them with a method-not-found. Check for one hiding in a health check you forgot you wrote.
Rolling this out without breaking the clients you already have
The changes above are breaking in both directions, so the order you deploy them in matters as much as the order you write them in. Three approaches, in descending order of how often they are the right one.
Go modern-only, if you can see your clients. Internal servers, or servers with a known set of consumers, should simply migrate. Dual-era support is real work and real ongoing complexity, and carrying it for clients that do not exist is a cost with no beneficiary. Name your supported versions in the error you return to initialize so anything you missed gets a diagnostic rather than silence.
Go dual-era, if your consumers are public. A published server with unknown clients should implement both behaviours for a period — step 10 covers the mechanics. The branch point is cheap: a request carrying modern _meta is served statelessly, an initialize selects legacy semantics. What is not cheap is keeping two code paths honest indefinitely, so set an end date when you start rather than discovering in a year that the legacy path is untested.
Do not run two endpoints. The tempting third option — a /mcp/v2 alongside the old one — looks like the safe move and is usually the worst of the three. It doubles your surface area, splits your observability, and gives every client a migration decision to make instead of making it once yourself. The protocol already carries its own version on every request; adding a second versioning mechanism in the URL means two things can disagree.
One thing to check before you start: your SDK
This guide shows the protocol work directly, in plain Express, rather than through a framework. That is not a stylistic preference — it is a consequence of where the TypeScript tooling currently sits, and it is worth checking before you plan the work.
As of this page's last verification, the official TypeScript SDK published its supported protocol versions as a list topping out at 2025-11-25. You can check the current state yourself in one command, without installing anything:
curl -sL https://cdn.jsdelivr.net/npm/@modelcontextprotocol/sdk@latest/dist/esm/types.js \
| grep -E "LATEST_PROTOCOL_VERSION|SUPPORTED_PROTOCOL_VERSIONS"If 2026-07-28 appears in that list, use the SDK and let it handle the transport. If it does not, an SDK-based server cannot serve a modern client no matter what you write on top of it, and the plain-handler approach above is the shorter path. The good news is that statelessness removed most of what the SDK's transport layer existed to manage — session bookkeeping and handshake negotiation — so what is left is JSON-RPC dispatch and header validation, which is what the worked example above is.
The same check applies to any framework you are considering: a framework that depends on the SDK inherits its protocol ceiling, whatever its own documentation says about being current.
Test your migration
Run your server through the validator to check all 79 rules at once, each linked to the exact spec sentence it comes from.
Validate a server →Frequently asked
What changed in MCP 2026-07-28?
The largest revision since MCP launched: protocol-level sessions and the initialize handshake are removed in favor of a fully stateless core, Mcp-Method and Mcp-Name become required headers on Streamable HTTP, tools/list and related results must carry ttlMs and cacheScope, server-initiated requests are replaced by the MRTR pattern, and several methods (ping, logging/setLevel, resources/subscribe) are removed in favor of server/discover and subscriptions/listen.
Why is my MCP server failing after the spec update?
Most failures trace back to one of three things: the server still expects an initialize handshake and a session, it doesn't return the Mcp-Method/Mcp-Name headers a modern client sends, or its tools/list response is missing resultType, ttlMs, or cacheScope. Run it through the validator to get the exact rule ID and spec sentence for each failure.
What is the Mcp-Method header?
A required HTTP header on every Streamable HTTP POST request that mirrors the JSON-RPC method field, so gateways and load balancers can route requests without parsing the body. Servers must reject a request where the header doesn't match the body's method with a 400 and a HeaderMismatch (-32020) error.
Do I need to support both 2025-11-25 and 2026-07-28 at once?
Only if you need to interoperate with clients you don't control yet. A dual-era server answers a request carrying modern per-request _meta statelessly, and falls back to session-based handling only when it receives an initialize request. See Step 10 below.
What is RFC 9207 iss validation in MCP?
RFC 9207 defines an iss parameter authorization servers can return in the OAuth redirect, which the client validates against the expected issuer before redeeming the authorization code — closing an authorization-server mix-up attack. It's a client and authorization-server obligation; an MCP server itself can't be validated against this rule directly, only checked for whether its authorization server advertises support for it.
Is the HTTP+SSE transport still supported?
It's now formally Deprecated under MCP's feature lifecycle policy, with a minimum 12-month deprecation window before removal. New implementations shouldn't adopt it; migrate to Streamable HTTP.