MCP in 2026: What Changed in the 2026-07-28 Specification and How to Design Production Integrations
The 2026-07-28 MCP revision removed sessions and the initialize handshake. What changed, what is deprecated, and how to design production MCP integrations now.

If you learned the Model Context Protocol from a tutorial written before August 2026, most of what it taught about transport is now wrong. There is no initialize handshake. There is no Mcp-Session-Id. There is no HTTP GET stream to listen on, and streams do not resume. The 2026-07-28 revision, released on 28 July 2026, removed all of them and replaced them with something closer to how the rest of the web works: every request is self-describing, so any request can land on any server instance. Protocol version, client identity and capabilities now travel per request in _meta; application state that must survive across tool calls can be represented by explicit handles passed as tool arguments.
This article is a migration-oriented reading of that revision. For each change it states what you can delete, what you must add, and what you have to keep if you still serve clients from the previous era. It ends with an integration decision table and a production checklist. Everything here is drawn from the specification, its changelog, the deprecated-features registry and the extensions documentation as they stand on 25 September 2026; where the SDKs lag the spec, that is stated.
MCP in September 2026
Three dates anchor the current state:
- 2025-11-25 is the last "legacy" revision: session-based, with an
initializehandshake and server-initiated requests. - 2026-07-28 is the current revision (release candidate 29 May 2026, final 28 July 2026). The specification's own versioning page calls implementations of this and later revisions modern, implementations of 2025-11-25 and earlier legacy, and anything that supports both dual-era. Those three words are used throughout this article.
- 28 July 2026 is also when Tier 1 SDKs shipped for the new revision. The TypeScript SDK was split into focused core, client, server and framework packages, launching at 2.0.0 alongside the protocol. Both SDK lines have moved on since. As of 25 September 2026, the main TypeScript core, client, server and node packages have 2.1.0 releases, while framework adapters can carry their own versions. The Python SDK's current stable v2 release is 2.2.0. Check the current release pages rather than pinning to the launch versions quoted in older write-ups, including this one.
The protocol is governed under the Agentic AI Foundation, with a formal SEP (specification enhancement proposal) process and, new in this revision, a feature lifecycle policy with a minimum twelve-month deprecation window. That last item matters for planning: nothing deprecated on 28 July 2026 can be removed before a revision released on or after 28 July 2027.
What 2026-07-28 removed
Sessions and Mcp-Session-Id
Protocol-level sessions are gone from the Streamable HTTP transport (SEP-2567). A modern server does not mint session IDs, does not echo them, and ignores an Mcp-Session-Id header if a legacy client sends one; a GET or DELETE to the MCP endpoint from an older client gets 405 Method Not Allowed. The consequence the changelog calls out explicitly: tools/list, resources/list and prompts/list no longer vary per connection. If your server previously returned a different tool set depending on session state, that behaviour has no home in the modern protocol; the tool set is a property of the server (and of the authenticated principal), not of a conversation.
The initialize handshake
There is no initialize request and no notifications/initialized (SEP-2575). Instead every request carries its own protocol version and client capabilities in _meta, and the server accepts or rejects each request independently. A version the server does not support gets an UnsupportedProtocolVersionError (code -32022) listing the versions it does support, and the client retries with one of them.
The GET stream, resumability and a few utilities
The standalone SSE stream a client used to open with HTTP GET is gone, replaced by subscriptions/listen (below). SSE resumability and message redelivery (Last-Event-ID, event IDs) are removed from the transport: a broken response stream loses the in-flight request and the client must re-issue it as a new request with a new ID. ping, logging/setLevel and notifications/roots/list_changed are removed too; log level is now requested per call via io.modelcontextprotocol/logLevel in _meta, and a server must not emit notifications/message for a request that did not ask for it.
Server-initiated requests
Servers no longer send their own JSON-RPC requests to clients on any stream. roots/list, sampling/createMessage and elicitation/create still exist as request shapes, but they are now carried inside results (see MRTR below). The notifications/elicitation/complete notification and the elicitationId field, both added only in 2025-11-25, are removed with them.
What replaced it
Per-request _meta
Every request now describes itself. The reserved keys are io.modelcontextprotocol/protocolVersion (required), io.modelcontextprotocol/clientCapabilities (required), and io.modelcontextprotocol/clientInfo (a client SHOULD send it). Servers SHOULD identify themselves with io.modelcontextprotocol/serverInfo in each result's _meta. On Streamable HTTP the version is mirrored into the MCP-Protocol-Version header, and the two must match or the server rejects the request with HeaderMismatch (-32020).
A modern tools/call over HTTP looks like this:
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: 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": {}
}
}
}
There is no prior message. That request is valid as the first thing a client ever sends.
server/discover
Servers MUST implement server/discover, which returns supported protocol versions, capabilities and identity. Clients MAY call it up front to choose a version, and on stdio it doubles as the backward-compatibility probe. It is optional for clients: a client is free to send any RPC inline and handle UnsupportedProtocolVersionError if it guessed wrong. The response is cacheable.
{
"jsonrpc": "2.0",
"id": "discover-1",
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": {}, "resources": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "ExampleServer", "version": "1.0.0" }
}
}
}
subscriptions/listen
A client that wants change notifications sends one subscriptions/listen request, opting in to specific types (toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions). The response is a long-lived SSE stream that carries only those notifications, each tagged with a subscription ID. Request-scoped notifications (notifications/progress, notifications/message) do not travel on it; they stay on the response stream of the request they belong to. Servers are encouraged to send SSE comment lines as keep-alives on this stream.
Multi Round-Trip Requests and InputRequiredResult
This is the change that most affects tool design. When a server needs something from the client mid-call (user input via elicitation, an LLM completion via sampling, or the roots list) it no longer sends a request. It returns an interim result with resultType: "input_required" whose inputRequests map carries the requests it needs fulfilled, plus an opaque requestState blob (SEP-2322). The client gathers the input and retries the original request with a new ID, adding inputResponses and echoing requestState. The server reconstitutes whatever it needs from that blob; the two requests are fully independent and may be served by different instances.
Client Server
| POST tools/call (id 1) |
|---------------------------------------->|
| | needs user input
| InputRequiredResult |
| resultType: "input_required" |
| inputRequests: { github_login: elicitation/create }
| requestState: "<opaque>" |
|<----------------------------------------|
| (client prompts the user) |
| POST tools/call (id 2) |
| original params + inputResponses |
| + requestState |
|---------------------------------------->|
| | reconstitutes state, completes
| result, resultType: "complete" |
|<----------------------------------------|
Every result in the 2026-07-28 protocol carries a required resultType. Modern core results use "complete" or "input_required"; negotiated extensions may define additional values, and the Tasks extension uses resultType: "task" for its CreateTaskResult. Clients MUST treat results from earlier-protocol servers that omit the field as "complete".
Hosting consequences
The transport now mirrors selected body fields into HTTP headers so that intermediaries can route and inspect without parsing JSON. Mcp-Method is required on every POST; Mcp-Name is required on tools/call, resources/read and prompts/get (SEP-2243) (carrying the tool name or resource URI, Base64-wrapped when it is not header-safe). A server MAY annotate a tool parameter with x-mcp-header in its input schema, and conforming clients MUST then mirror that argument into an Mcp-Param-{Name} header; the canonical example is a region parameter that a gateway uses to route the call. Servers that process the body MUST validate that headers and body agree and reject mismatches with 400 and -32020.
Two more changes are aimed squarely at caches and prompt reuse. tools/list, prompts/list, resources/list, resources/read and resources/templates/list results now carry ttlMs (a freshness hint) and cacheScope ("public" or "private", controlling whether a shared intermediary may cache them) (SEP-2549). And servers SHOULD return tools from tools/list in a deterministic order, because a stable tool list is what lets the client's prompt cache hit.
Put together: normal modern MCP requests are stateless HTTP calls. A server can sit behind a round-robin load balancer, be scaled horizontally, and be fronted by a gateway that routes on Mcp-Name or Mcp-Param-Region. Clients and gateways can also cache eligible results using the protocol's own hints: ttlMs expresses freshness, and cacheScope controls whether a result may be shared across authorization contexts. These are MCP-level hints rather than HTTP Cache-Control headers, and they do not mean the server caches anything for you. That statelessness is the design goal the maintainers state in the release post, and it is the reason to migrate even if nothing you had was broken.
One rule to get right the first time: never mark principal-specific or tenant-specific data as public. A public result may be shared by an intermediary across authorization contexts, so anything whose content depends on who asked belongs in private scope. This matters most for resources/read and for any list already filtered by identity or tenant.
One caveat so the picture is complete: subscriptions/listen is still a long-lived SSE stream. If your deployment runs several instances and any of them can change a tool list or a resource, delivering those notifications to a client whose listen stream is held open by a different instance requires shared pub/sub or equivalent infrastructure. Request handling is stateless; notification fan-out is a deployment concern you still have to design.
Stateful work without sessions
Dropping protocol sessions does not force your application to be stateless. It moves state from the transport, where the model could not see it, into tool arguments, where it can. The pattern the maintainers recommend: mint an explicit, server-issued handle from one tool and have the model pass it back to later tools as an ordinary argument.
A worked example. A start_export tool creates a job and returns a handle:
{
"resultType": "complete",
"content": [{ "type": "text", "text": "Export started." }],
"structuredContent": { "export_handle": "exp_9f31c2", "expires_at": "2026-09-21T13:00:00Z" }
}
A later get_export_status call takes export_handle as a parameter. For a horizontally scaled server, prefer keeping the state the handle refers to in shared, durable storage (a database, a job queue, an object store) so that any instance can serve any call. It is possible instead to mirror the handle into a header with x-mcp-header and have a gateway route every call for that export to the instance holding it in memory, but be clear about what that is: application-level affinity, reintroduced by your deployment, even though MCP itself remains sessionless. Handles should carry or imply an expiry, and a call with an expired or unknown handle should return an ordinary tool error the model can act on ("that export has expired, start a new one"), not a transport error.
For interactions that need user input mid-call, use MRTR rather than a handle. Two things about requestState are worth being precise about, because they are easy to get half right.
First, it round-trips through the client, so treat it as untrusted input when it comes back. Integrity protect it and verify it before use, and bind it to the authenticated principal, the originating method, the salient request parameters and an expiry. The official TypeScript helper uses HMAC for this.
Second, signing gives you integrity, not confidentiality. A client can still read a signed but unencrypted payload, so do not put secrets or sensitive internal state in there. If you need the contents hidden, use authenticated encryption such as AEAD, or keep the state server-side and round-trip an opaque reference to it.
One production warning while you are here. The retry carries a fresh JSON-RPC ID, and that does nothing for application side effects. If the first round can create a reservation, a charge or a booking before it returns input_required, the retry can create a second one. Keep the pre-input round free of side effects where you can. Where you cannot, mint an operation identifier, persist the operation state, bind the retry to the same operation, and reconcile instead of repeating. MCP gives you no exactly-once execution; that part is yours.
Compatibility matrix
The specification defines the expected outcome of every client and server pairing. Condensed:
| Client | Server | Outcome |
|---|---|---|
| Modern | Modern | Works. server/discover optional; version mismatch surfaces as UnsupportedProtocolVersionError and the client retries with a supported version. |
| Modern | Legacy | Fails. The legacy server may reject, stay silent, or misinterpret. On stdio, send server/discover first so the failure is deterministic. |
| Dual-era | Modern | Works. The first modern request succeeds or returns a modern error; the client stays modern. |
| Dual-era | Legacy | Works. The modern request gets a 4xx without a recognised modern error body; the client falls back to initialize. |
| Legacy | Modern | Fails. Missing headers and _meta are rejected with 400. Legacy clients have no fall-forward mechanism. |
| Legacy | Dual-era | Works. The server answers initialize and serves the legacy revision. |
The detection rule for a dual-era client on HTTP: attempt a modern request; on 400, inspect the body. A recognised modern JSON-RPC error (UnsupportedProtocolVersion, MissingRequiredClientCapability, HeaderMismatch) means the server is modern, so correct the request or retry with an advertised version. An empty or unrecognised body means legacy, so fall back to initialize. Era is a property of the server, not of a request; cache the result per origin.
A dual-era server chooses its behaviour from how the client opens: a request carrying modern _meta is served statelessly, an initialize request selects legacy semantics scoped to that session. The Python SDK implements exactly this (MCPServer and mcp.Client with a server/discover probe and legacy fallback), and its release notes add a detail worth knowing: idle legacy Streamable HTTP sessions now expire, which does not affect stateless or modern connections.
Do not assume every SDK enables dual-era negotiation the same way. This one catches people. The Python client defaults to mode="auto": it sends a server/discover probe and falls back to initialize against a pre-2026 server, so you get era detection without asking for it. The TypeScript client is the opposite. Its documentation states that an absent mode, or mode: 'legacy', performs the 2025 initialize handshake byte for byte with no probe at all; you get modern negotiation only by passing versionNegotiation: { mode: 'auto' }, or pin a revision with mode: { pin: '2026-07-28' }. Same protocol, opposite defaults. Read your SDK's negotiation documentation before assuming a client is speaking the revision you think it is.
Deprecations and the lifecycle
The revision introduced a formal feature lifecycle (Active, Deprecated, Removed) with a minimum twelve-month deprecation window and a registry of deprecated features (SEP-2596); Roots, Sampling and Logging were deprecated under it (SEP-2577). As of 25 September 2026 the registry lists:
| Feature | Deprecated in | Migration | Earliest removal |
|---|---|---|---|
| Roots | 2026-07-28 | Pass directories or files via tool parameters, resource URIs or server configuration | First revision on or after 2027-07-28 |
| Sampling | 2026-07-28 | Integrate directly with LLM provider APIs | First revision on or after 2027-07-28 |
| Logging | 2026-07-28 | stderr on stdio; OpenTelemetry for observability |
First revision on or after 2027-07-28 |
| Dynamic Client Registration (RFC 7591) | 2026-07-28 | Client ID Metadata Documents | First revision on or after 2027-07-28 |
includeContext: "thisServer" / "allServers" |
2025-11-25 | Omit or use "none" |
Follows Sampling |
| HTTP+SSE transport (2024-11-05) | 2025-03-26 | Streamable HTTP | Registry wording: "Three months after SEP-2596 reaches Final"; check the deprecated-features registry before removal |
Nothing has been removed under the policy yet. Deprecated features keep working during the window, but new implementations should not adopt them. The "earliest removal" column marks when a feature becomes eligible for removal; the registry states that actual removal is a maintainer decision taken during release preparation and may happen later, so treat the registry, not this table, as the source of truth. Note that Roots, Sampling and Elicitation are still delivered through MRTR in the current revision; Roots and Sampling are simply on their way out, while Elicitation is not deprecated.
Authorization hardening
The authorization section is optional for MCP implementations, but where a server implements it the requirements were tightened: OAuth 2.1 (draft 13) with the MCP server acting as a resource server; OAuth 2.0 Protected Resource Metadata (RFC 9728) is now mandatory for authorization server discovery; Client ID Metadata Documents are the preferred registration mechanism, with Dynamic Client Registration deprecated; resource indicators (RFC 8707) bind tokens to the server; authorization servers SHOULD return iss and clients MUST validate it (RFC 9207); client credentials are bound to the issuer that granted them; and a step-up flow lets a server challenge for additional scopes on a per-call basis. Two 2026-07-28 changelog items are easy to miss: the resource-not-found error code moved from -32002 to -32602, and a new error-code allocation policy reserves -32020 to -32099 for the specification (the earlier draft codes -32001, -32003 and -32004 were renumbered to -32020, -32021 and -32022).
Extensions, and an integration decision table
Capabilities now carry an extensions map. Official MCP extensions are maintained in ext- repositories under the Model Context Protocol GitHub organisation, each identified by an io.modelcontextprotocol/ prefix. A repository may contain more than one related extension: ext-auth holds both of the authorization extensions below. Official does not necessarily mean stable either, since extension maturity is tracked separately, so check the extension's current specification before depending on it. In ext-auth today, Enterprise-Managed Authorization is listed as Stable while OAuth Client Credentials is still Draft.
- Tasks (
io.modelcontextprotocol/tasks), for long-running asynchronous work with task state and polling. Moved out of the core in this revision and redesigned (SEP-2663): polling viatasks/getreplaces the blockingtasks/result,tasks/updatecarries client-to-server input,tasks/listis gone, and a server may return a task handle unsolicited. The Python SDK 2.2.0 release notes state the Tasks extension is not yet implemented there; check your SDK before designing around it. - MCP Apps (
io.modelcontextprotocol/ui), for interactive UI surfaces rendered by capable hosts. - OAuth Client Credentials (
io.modelcontextprotocol/oauth-client-credentials), for machine-to-machine authentication where no interactive user authorization is available: background services, CI/CD jobs, server-to-server integrations, workers and daemons. It supplements the normal user authorization flow rather than replacing it. - Enterprise-Managed Authorization (
io.modelcontextprotocol/enterprise-managed-authorization), for centralised enterprise identity and access policy through an organisation's IdP. - Skills (
io.modelcontextprotocol/skills), which serves Agent Skills through MCP resources. SEP-2640 reached Final on 13 September 2026, and the extension addsskills/listandskills/getalongside an optionalresources/directory/read.
Worth separating two things that are easy to conflate. Core authorization and authorization extensions are not the same layer: RFC 9728 discovery, resource indicators and issuer validation are core requirements wherever authorization is implemented, while OAuth Client Credentials and Enterprise-Managed Authorization are optional extensions beyond that flow. A server does not need an authorization extension merely to implement normal MCP OAuth authorization.
The extension ecosystem also moves independently of the core protocol. Extensions are versioned independently, their maturity differs, and the client support matrix on the documentation site is maintained by the community rather than generated from implementations. That page says as much itself, and points you at each extension's specification and repository for the latest status. So check the extension's own specification and repository before depending on it, and do not assume every client or SDK implements every official extension.
Which integration shape to choose:
| Situation | Recommended shape | Why |
|---|---|---|
| Local developer tools, one user, one machine | stdio | No network, no auth, server/discover as the probe; stateless semantics still apply |
| Internal service used by several agents | Streamable HTTP, modern only, behind your normal load balancer | Stateless core; route on Mcp-Method / Mcp-Name; cache list results with ttlMs |
| Public or partner-facing server | Streamable HTTP, modern only, with the authorization section implemented | RFC 9728 discovery, resource-bound tokens, scope step-up; validate Origin |
| Many servers behind one entry point | Gateway that validates headers against bodies and routes on Mcp-Name and Mcp-Param-* |
This is what the mirrored headers exist for; the gateway must reject header and body mismatches |
| Long-running work (minutes to hours) | Tasks extension where the SDK supports it; otherwise an explicit handle plus a polling tool | Avoid holding a response stream open across minutes; streams are not resumable |
| Clients you do not control and cannot upgrade | Dual-era server for the twelve-month window, with a dated plan to drop legacy | Legacy clients cannot fall forward; only the server can bridge |
Failure modes
Legacy assumptions in new code. A common migration bug is code that still sends initialize first, or expects an Mcp-Session-Id in the response, and then treats a modern server's 400 as a connection failure. A modern 400 carries a JSON-RPC error body that tells you exactly what to do; read it before retrying.
Buffering proxies. Streamed responses depend on the proxy passing SSE events through as they arrive. The specification recommends servers send X-Accel-Buffering: no; nginx-style proxies otherwise hold events and the stream looks dead. Keep-alive comment lines on subscriptions/listen streams prevent idle timeouts.
Header and body disagreement. Anything that rewrites bodies (a middleware that renames a tool, a proxy that normalises JSON) will produce HeaderMismatch rejections. The mirrored headers are validated against the body on purpose, so intermediaries must rewrite both or neither. Intermediaries that route on headers should also check the MCP-Protocol-Version indicates a revision that requires validation, and reject older traffic rather than trust unvalidated headers.
Stale cached lists. With ttlMs on list results, a client may legitimately serve a cached tool list for the hint's duration. If you add or remove tools, emit notifications/tools/list_changed to subscribers and keep ttlMs short during rollouts. Treat change notifications as the signal to invalidate and refetch, rather than assuming a stale entry disappears on its own because a TTL exists somewhere.
Lost in-flight requests. Streams do not resume. A load balancer that drains connections mid-response will lose the request, and the client must re-issue it with a new ID; make long tools idempotent or hand them a handle so the retry is safe.
Non-deterministic tools/list. A tool list that changes order between calls defeats both client-side caching and the model provider's prompt cache. Sort it.
Production checklist and tradeoffs
- Every request carries
_metawith protocol version and client capabilities; the HTTP header matches the body. server/discoveris implemented and its result is cacheable.- No modern code path depends on a protocol session; cross-call state on the 2026-07-28 path is represented explicitly, as a handle in tool arguments with an expiry and a model-readable error when stale.
- Mid-call input uses MRTR;
requestStateis integrity protected, verified on return, bound to principal and expiry, and free of secrets; the retry path is tested with a different server instance handling the second request, and any pre-input side effect is reconciled rather than repeated. tools/listis deterministic and returnsttlMsandcacheScope; changes are announced onsubscriptions/listen.- Origin is validated; local servers bind to localhost; the authorization section, where used, follows the 2026-07-28 rules (RFC 9728 discovery, resource indicators,
issvalidation). - Proxies pass SSE through unbuffered and forward
Mcp-*headers untouched. - Roots, Sampling, Logging and Dynamic Client Registration are not adopted in new code; existing uses have a migration ticket dated before July 2027.
- If legacy clients still exist, legacy and session behaviour is isolated to the compatibility path and has a written removal date. The legacy wire did not become stateless; it is simply on a clock.
- The SDK's support for Tasks, DPoP and the
jwt-bearergrant is checked against its release notes before any design depends on them.
If you are migrating today
The short version, in the order it usually gets done:
- Delete
initializeandnotifications/initializedfrom the modern path. - Stop minting, echoing or expecting
Mcp-Session-Id. - Add
io.modelcontextprotocol/protocolVersionandclientCapabilitiesto every request, and mirror the version into the HTTP header. - Implement
server/discoverand make its result cacheable. - Move anything the server used to ask the client for into MRTR.
- Move cross-call state into explicit handles backed by durable storage.
- Replace the old GET stream with
subscriptions/listen, and plan notification fan-out if you run more than one instance. - Validate mirrored headers against the body, and set
ttlMsandcacheScopedeliberately. - Isolate legacy compatibility behind one path with a removal date, if you still need it.
If you are writing a new server rather than porting one, the companion piece walks through building an MCP server for internal tools with the Python SDK, including token verification and tool errors a model can act on.
The tradeoffs are real. Stateless requests mean more bytes per call (_meta on every request, capabilities repeated) and no transport-level continuity for free; you pay for that with explicit handles and MRTR retries. Dual-era support doubles your test matrix for a year. And the SDKs are not uniformly ahead of the spec. In exchange, an MCP server becomes something a platform team can host, scale, cache, route and observe with the tools it already has, which is the difference between a demo integration and one you can put a production agent on.
If you are migrating, I would like to hear which 2025-era assumption broke first in your codebase. The next article in this series walks through building a modern MCP server for internal tools end to end: transport, authentication and error handling.
Drafted with AI assistance and reviewed, edited and approved by the author.





