If your agent's custom MCP servers were built against the 2025-11-25 revision, the 2026-07-28 MCP specification update changes how every request declares its version, which fields every result must carry, and which methods still exist. This guide gives you a server-side checklist, a dual-version gate you can adapt, and a keep, upgrade or pin decision for each server you run.
Why the 2026-07-28 MCP specification update needs a server migration plan
MCP uses date-based version identifiers in the format YYYY-MM-DD, and the date marks "the last date backwards incompatible changes were made", according to the versioning page. That page names 2026-07-28 as the current protocol version. The GitHub releases list shows the stable 2026-07-28 release published on 28 Jul 2026, after a release candidate.
A new date means at least one change breaks compatibility, and the 2026-07-28 changelog lists several that land directly on server code:
| Area | 2025-11-25 | 2026-07-28 |
|---|---|---|
| Version negotiation | Once, in the initialize handshake |
Per request, in _meta |
| Connection state | Protocol-level sessions, Mcp-Session-Id |
Stateless; sessions and Mcp-Session-Id removed |
| Discovery | Capabilities returned by initialize |
server/discover RPC, mandatory for servers |
| Liveness and logging | ping, logging/setLevel |
Both removed; log level set per request |
| Server-to-client requests | roots/list, sampling/createMessage, elicitation/create |
Multi Round-Trip Requests (input_required results) |
The versioning page also points to a separate Backward Compatibility page for interoperating with handshake-based revisions (2025-11-25 and earlier). Read it before you ship any dual-version design, including the one below.
For background, see MCP Turned My AI From Chatbot to Real Agent.
From initialize handshake to per-request version checks
Under 2025-11-25, the lifecycle page fixes the version once. The client sends a version it supports in initialize; if the server supports it, the server "MUST respond with the same version", otherwise it responds with another version it supports. After a successful response the client sends:
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
Two ordering rules matter for any legacy code path you keep. The client SHOULD NOT send requests other than pings before the server has answered initialize, and the server SHOULD NOT send requests other than pings and logging before receiving notifications/initialized. Over HTTP, the client MUST include MCP-Protocol-Version: <protocol-version> on all subsequent requests. Those later legacy requests carry the version only in the header.
The 2026-07-28 changelog removes the initialize/notifications/initialized handshake entirely. Every request now carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities in _meta. Clients SHOULD send io.modelcontextprotocol/clientInfo, and servers SHOULD return io.modelcontextprotocol/serverInfo in each result's _meta. On Streamable HTTP, the versioning page says the same version value "is also carried in the MCP-Protocol-Version header", and the server "accepts or rejects each request independently."
An illustrative 2026-07-28 request (the _meta keys come from the changelog; the method, tool name and values are placeholders):
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "search_tickets",
"arguments": { "query": "refund" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "example-agent", "version": "1.0.0" }
}
}
}
If the server does not support the requested version, it returns an UnsupportedProtocolVersionError listing the versions it does support, and the client can retry with a mutually supported one. Servers MUST implement server/discover, which returns supported versions, capabilities and identity in one call. Clients MAY call it up front or skip it and handle a version error.
The changelog also renumbers the error codes introduced during the draft: HeaderMismatch moves from -32001 to -32020, MissingRequiredClientCapability from -32003 to -32021, and UnsupportedProtocolVersion from -32004 to -32022. If your SDK or test fixtures were written against a release candidate, grep for the old numbers.
The tradeoff is straightforward. Every request now pays for a version and capability check, but you no longer need sticky routing or session storage to remember what a client negotiated. Any replica can serve any request, which simplifies horizontal scaling behind a load balancer.

Server checklist part 1: a dual-version gate
This section is a design recommendation, not a documented API. Treat it as unverified until you compare it with the official Backward Compatibility rules; where they disagree, follow the spec.
The gate below serves handshake-era clients and 2026-07-28 clients from one server. Checks run in this order:
- Any
initializerequest goes to your existing 2025-11-25 handshake code. On stdio, that connection is then pinned to the negotiated version, requests without_metaare accepted, and onlypingis allowed before theinitializeresponse has been sent. - An HTTP request with no
_metaversion but a handshake-eraMCP-Protocol-Versionheader is served as legacy. Leave legacyMcp-Session-Idissuance and validation to your existing 2025-11-25 transport code. - If
_metaand the header are both present and differ, reject with -32020. The mismatch check applies only when both exist. - An unsupported
_metaversion is rejected with -32022 and a list of the versions this server accepts in_meta(only 2026-07-28 here). Handshake-era versions are left out of that list so a client retrying from it is not rejected again; those clients must useinitializeinstead. The error data shape here is a placeholder; take the real one from the schema. - A request with no version at all is rejected.
CURRENT = {"2026-07-28"}
LEGACY = {"2025-11-25"}
def gate(req, conn):
if req.method == "initialize":
return legacy_handshake(req, conn) # pins stdio conn
if conn.is_stdio and conn.pinned_version:
if not conn.init_answered and req.method != "ping":
return reject_out_of_order(req)
return serve_legacy(req, conn.pinned_version)
meta_v = req.meta.get("io.modelcontextprotocol/protocolVersion")
header_v = req.headers.get("MCP-Protocol-Version") # None on stdio
if meta_v is None and header_v in LEGACY:
return serve_legacy(req, header_v) # legacy session code
if meta_v and header_v and meta_v != header_v:
return error(-32020, "HeaderMismatch")
if meta_v and meta_v not in CURRENT:
return error(-32022, "UnsupportedProtocolVersion",
data={"supported": sorted(CURRENT)}) # placeholder shape
if meta_v is None:
return reject_missing_version(req)
return serve_current(req, meta_v)
Two failure cases to test explicitly. First, a legacy HTTP client that drops the header after initialize falls through to the missing-version rejection. That is this design's choice, based on the 2025-11-25 lifecycle rule that clients MUST send the header; confirm the expected server-side fallback for a missing header in the 2025-11-25 transport and Backward Compatibility docs before relying on it. Second, a 2026-07-28 client behind a proxy that rewrites headers will trip -32020; log both values so you can tell a misbehaving client from a misbehaving intermediary.
Implement server/discover alongside the gate. Its exact response schema belongs to the spec's schema files, so build it from there rather than from the field names in this article.

Server checklist part 2: results, headers and auth
Once a request reaches serve_current, these changelog items apply to every 2026-07-28 response path:
| Check | Requirement |
|---|---|
resultType |
Required on all results: "complete" for ordinary results, "input_required" for interim multi round-trip results |
ttlMs, cacheScope |
Required on tools/list, prompts/list, resources/list, resources/read, resources/templates/list; cacheScope is "public" or "private" |
| Request headers | Mcp-Method and Mcp-Name required on Streamable HTTP POST requests |
| Sessions | Mcp-Session-Id removed; pass server-minted handles as ordinary tool arguments for cross-call state |
| Removed methods | ping, logging/setLevel, notifications/roots/list_changed |
| Logging | Level set per request via io.modelcontextprotocol/logLevel; servers MUST NOT emit notifications/message without it |
| Subscriptions | HTTP GET endpoint and resources/subscribe/unsubscribe replaced by subscriptions/listen |
| Resumability | Last-Event-ID and SSE event IDs removed; a broken stream loses the in-flight request |
| Errors | Resource not found changes from -32002 to -32602 |
| Ordering | Servers SHOULD return tools/list in a deterministic order |
The cacheScope field deserves a deliberate choice per endpoint. If your tool list differs by user or tenant, mark it "private" so shared intermediaries do not cache it. A short ttlMs keeps clients polling more often; a long one risks stale tool lists, although listChanged notifications still complement it.
For authorization, the 2026-07-28 authorization page keeps the core resource-server duties. Servers MUST validate that access tokens were issued specifically for them as the intended audience (RFC 8707 Section 2), MUST NOT accept or pass through other tokens, and return 401 for invalid or expired tokens, 403 for insufficient scopes and 400 for malformed requests. Authorization MUST be included in every HTTP request from client to server, and tokens never go in the query string.
The server-side items to audit:
- Servers MUST account for scope hierarchies, where a broader scope implies narrower ones, when deciding whether a token is sufficient.
- On insufficient scope, clients compute the union of previously requested scopes and the scopes in the current challenge, so your challenge can list what the current operation needs.
- Protected resources SHOULD NOT include
offline_accessinWWW-Authenticatescope or in Protected Resource Metadatascopes_supported, because refresh tokens are not a resource requirement.
For related reading, see Best MCP Servers 2025: 6 Tools That Fixed My AI Coding Workflow (Finally).
FAQ
Must I drop 2025-11-25 support now?
No. The versioning page says clients and servers MAY support multiple protocol versions simultaneously. Deprecated features stay in the specification for at least twelve months, or at least ninety days under the expedited-removal exception. Roots, Sampling and Logging are deprecated in 2026-07-28, not removed, and the changelog says they remain fully functional during the deprecation window, though new implementations should not add them.
Must clients call server/discover?
No. Servers MUST implement it; clients MAY call it for up-front version selection or as a backward-compatibility probe on stdio.
Does the MCP-Protocol-Version header replace _meta?
No. On Streamable HTTP the version is carried in both. That duplication is why the -32020 mismatch check exists.
Did client registration change?
Yes, but mostly outside your MCP server. The changelog deprecates OAuth 2.0 Dynamic Client Registration (RFC 7591), requires clients to specify an application_type during registration, and requires clients to validate a present iss against the recorded issuer before redeeming the authorization code. Those duties fall on clients and authorization servers.
What replaces server-initiated elicitation and sampling requests?
The Multi Round-Trip Requests pattern. A server returns an InputRequiredResult whose inputRequests field describes what it needs, and the client retries the original request with inputResponses. Clients MUST treat results from earlier-protocol servers that omit resultType as "complete".
Decide keep, upgrade or pin for each MCP server
This decision rule is a design recommendation. Apply it per server, not per fleet, because each server has its own client mix and SDK:
def decide(server):
if all(c.handshake_only for c in server.clients):
return "keep" # stay on 2025-11-25
if server.sdk_lacks("resultType") or server.sdk_lacks("ttlMs"):
return "pin" # hold, watch the deprecated features registry
return "upgrade" # dual-version gate + server/discover
Keep 2025-11-25 when every client still uses the handshake; nothing on the wire forces a change yet. Pin when at least one client sends a _meta version but your SDK cannot yet emit the required result fields, since a half-migrated server that omits resultType or ttlMs is not compliant. Upgrade to the dual-version gate plus server/discover in every other case.
Concrete next steps:
- Inventory each server's supported versions and record which clients call it over stdio and which over Streamable HTTP.
- Add per-version tests: a stdio legacy handshake, a legacy HTTP request carrying only the header, a 2026-07-28 request with matching header and
_meta, a mismatched pair expecting -32020, and an unsupported version expecting -32022. - Check your gate against the Backward Compatibility page linked from the versioning page, and track the deprecated features registry for removal dates of Roots, Sampling and Logging.
- Audit tool and resource handlers for
Mcp-Session-Iddependencies and replace them with explicit, server-minted handles.
For a deeper build walkthrough, see Master MCP Servers: Build AI-Powered Tools & Integrations from Scratch (2026).
Need a hand implementing this? See Claude AI agent development.