Revision 2026-07-28 of the Model Context Protocol removes sessions from the wire: no Mcp-Session-Id header, no initialize handshake, no standalone GET stream, and no Last-Event-ID replay (MCP key changes, 2026-07-28). If your remote server holds anything in memory keyed by a session id, that state now has to travel as an explicit, server-minted handle passed as an ordinary tool argument. That makes the migration a data modelling job more than a transport rewrite, and the audit is the part most teams skip.
Key takeaways
- Sessions and the
initializehandshake are gone. Every POST carries its protocol version, client identity and capabilities in_meta, and servers MUST implementserver/discover. - Cross-call state moves into opaque handles that the server mints, binds to the authenticated caller, and expires. The model passes them back as tool arguments.
- Streams are no longer resumable. A dropped response stream loses the in-flight request, so non-idempotent tools can double-execute on the client retry.
- List results now carry
ttlMsandcacheScope, and no longer vary per connection. Per-caller tool menus need rethinking. - Server-initiated sampling, elicitation and roots become
InputRequiredResultretries under the MRTR pattern, and sampling, roots and logging are all deprecated. - Ship a dual-era server: an
initializerequest selects legacy semantics, per-request_metaselects the stateless path.
What exactly did 2026-07-28 remove?
Six things that most production MCP servers touch. The Streamable HTTP binding and the changelog spell them out:
- Protocol sessions. The
Mcp-Session-Idheader is gone. A modern-only server that receives one SHOULD ignore it and MUST NOT mint or echo session ids. - The handshake.
initializeandnotifications/initializedare removed. Version,clientInfoandclientCapabilitiesride in_metaon every request. - The GET stream. HTTP GET and DELETE on the MCP endpoint now get
405 Method Not Allowed. Long-lived server-to-client change notifications come from asubscriptions/listenrequest whose response stream stays open. - Resumability. SSE event ids and
Last-Event-IDreplay are removed. Clients MUST re-issue a lost request with a new request id. - Server-initiated requests on streams. A server MUST NOT send independent JSON-RPC requests on a response stream. Sampling, elicitation and roots are folded into Multi Round-Trip Requests.
- Small utilities.
ping,logging/setLevelandnotifications/roots/list_changedare removed. Log level is now per request viaio.modelcontextprotocol/logLevelin_meta, and servers MUST NOT emitnotifications/messagefor requests that omit it.
The upside is the failure mode that vanishes. Under 2025-11-25 and earlier, a request carrying a session id the server did not recognise got 404, and the client had to re-initialise. Deploy a second replica without sticky routing, or roll a pod, and every in-flight client re-initialised. That entire class of bug is now unreachable, and load balancers can route on Mcp-Method and Mcp-Name headers instead of session affinity.
How do I find the session dependencies in my own server?
Grep for the session object your SDK hands to tool handlers, then classify every write to it. In practice the hits fall into six buckets, and only the last two are hard:
- Auth context cached at initialisation. Move it to per-request token verification, which is where it belonged anyway (see adding OAuth to a remote MCP server).
- Expensive setup, such as a database pool or a warmed index. Hoist it to process scope, keyed by tenant rather than by connection.
- Pagination cursors held server side. Make the cursor opaque and self-describing, and return it in the result.
- Per-caller tool filtering computed once at
initialize. Recompute per request from the token, or stop filtering (next section). - Working sets: an opened dataset, a scratch workspace, an uploaded file, a partially built query. These become handles.
- Multi-step conversational state, such as "the project the user selected two calls ago". This is the one that needs a design decision, not a mechanical port.
The cheap verification: run three replicas behind a round-robin balancer with affinity disabled, then exercise your longest tool chain. Any tool that fails on call two is session dependent. MCP Inspector is useful for the single-replica behaviour, but it will not catch affinity assumptions, because it talks to one process.
Where does cross-call state go now?
Into server-minted handles passed as ordinary tool arguments, which is the migration path the specification names directly (SEP-2567). The tool that creates state returns an identifier; every tool that consumes that state takes the identifier as a required parameter. The transport carries nothing.
# Before: state lived in the transport session
def open_dataset(path, ctx):
ctx.session.state["dataset"] = load(path) # dies with the connection
return "loaded"
def query_dataset(sql, ctx):
return ctx.session.state["dataset"].query(sql)
# After: state lives behind a handle the server mints
HANDLE_TTL_S = 3600
def open_dataset(path):
subject = auth_subject() # from the verified access token
handle = store.put(load(path), owner=subject, ttl=HANDLE_TTL_S)
return {"dataset": handle, "expires_in": HANDLE_TTL_S}
def query_dataset(dataset, sql):
subject = auth_subject()
ds = store.get(dataset)
if ds is None:
raise ToolError("dataset handle expired or unknown; call open_dataset again")
if ds.owner != subject:
raise ToolError("dataset handle does not belong to this caller")
return ds.query(sql)
Four rules keep handles from becoming a security hole. Make them opaque and unguessable, because they now cross an untrusted boundary: the model sees them, and they may end up in logs or transcripts. Bind ownership server side and check it on every use, since a handle is an identifier and never an authorization statement. Give them a TTL and say so in the result, so the model can recover instead of looping. And never encode trusted fields such as a tenant id inside a handle the caller could forge; store the mapping and look it up.
A handle is a pointer into your own storage, checked against the caller's token on every call. If your code path reads a field out of the handle and trusts it, you have built a bearer credential with no revocation.
Describe the parameter so the model knows where the value comes from. Handles are the one place where prose in the schema pays for itself:
{
"name": "query_dataset",
"description": "Run SQL against a dataset opened by open_dataset.",
"inputSchema": {
"type": "object",
"properties": {
"dataset": {
"type": "string",
"description": "Handle returned by open_dataset. Expires after 1 hour. Do not invent or reuse a handle from an earlier task."
},
"sql": { "type": "string" }
},
"required": ["dataset", "sql"]
}
}
What happens to per-connection tool lists?
They are no longer allowed to vary per connection, and list results are now cacheable by design. tools/list, prompts/list, resources/list, resources/read and resources/templates/list return a CacheableResult carrying a required ttlMs freshness hint and a cacheScope of public or private, where public permits shared intermediaries to cache the response. Servers SHOULD also return tools in a deterministic order, which improves prompt cache hit rates on the client.
Two consequences. If your tool menu genuinely depends on the authenticated identity, you can still vary it per token, but you MUST mark those results cacheScope: "private" and accept that clients will hold them for ttlMs, so a permission change will not appear instantly. If the menu is the same for everyone, mark it public, keep the order stable, and let intermediaries absorb the traffic.
My preference is to stop filtering the list for authorization and enforce at call time instead, returning a precise permission error the model can relay. A stable, deterministic tool list caches better and reproduces better in evals. Filtering to fight context cost is a separate problem with better answers, covered in tool search versus code execution.
What replaces Last-Event-ID for long-running calls?
Nothing at the transport layer, which is why idempotency is now your problem. The spec is explicit: a broken response stream loses the in-flight request, and the client MUST re-issue it as a new request with a new request id. Closing the stream is also the cancellation signal, so notifications/cancelled is not expected on HTTP any more.
Concretely: a charge_card or create_deployment tool that used to survive a flaky connection through event replay can now execute twice. Derive an idempotency key from the arguments plus the caller, store the result against it, and return the stored result on a repeat call. Do that before you migrate, not after.
For work longer than a client or proxy timeout, use the tasks extension (io.modelcontextprotocol/tasks), which moved out of the core protocol in this revision. The server returns a CreateTaskResult with a durable taskId, ttlMs and pollIntervalMs; the client polls tasks/get until a terminal status of completed, failed or cancelled. Check that the client declared the extension in its per-request capabilities first, because you must never return a task to a client that did not opt in. If you keep streaming instead, send X-Accel-Buffering: no so reverse proxies stop buffering, and emit a periodic SSE comment line as a keep-alive on long-lived streams. Progress notifications still flow on the response stream of the request they belong to, as described in streaming progress from a long-running tool.
What replaces server-initiated sampling and elicitation?
The Multi Round-Trip Requests pattern, where the server returns instead of asking. Rather than sending a sampling/createMessage or elicitation/create request down a stream, the server returns an InputRequiredResult with resultType: "input_required" and an inputRequests field; the client gathers the input and retries the original request with matching inputResponses (MRTR). Every result now carries a resultType, and results from older servers that omit it are treated as complete.
The catch for a stateless server: there is no session to park half-finished work in between the two round trips. Either recompute cheaply on the retry, or encode what you need in requestState so the retry can pick up where you left off. The correlation notification and elicitationId from 2025-11-25 are gone precisely because the retry is the signal.
Also plan for deprecations: roots, sampling and logging are all marked Deprecated in this revision, with a minimum twelve month window. The suggested migrations are to pass paths as tool parameters or resource URIs instead of roots, to call your LLM provider directly instead of sampling, and to log to stderr or OpenTelemetry instead of the logging capability. If you built on server-side sampling or on elicitation, those pages still describe how the legacy path works, but new work should not add either.
How do I serve both eras during the migration?
Run a dual-era server and let the client's first request pick the branch. A request carrying modern per-request _meta is served statelessly under this revision; an initialize request selects legacy semantics scoped to the session or process, and a server MAY serve both on the same endpoint (Versioning and Compatibility). Version mismatch is answered with UnsupportedProtocolVersionError (-32022) listing the versions you do support, and the client retries.
The header contract tightens at the same time. Every POST MUST carry MCP-Protocol-Version, Mcp-Method, and Mcp-Name for tools/call, resources/read and prompts/get. Header values that disagree with the body are rejected with 400 and JSON-RPC error -32020 (HeaderMismatch), which exists so a balancer routing on the header and a server executing on the body cannot diverge:
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: query_dataset
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{
"name":"query_dataset",
"arguments":{"dataset":"ds_9f2c41ab","sql":"select count(*) from orders"},
"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"},
"io.modelcontextprotocol/clientCapabilities":{}
}}}
Two details that bite during a rollout. First, 404 now means unknown RPC method and carries a JSON-RPC error with code -32601, so do not reuse it for anything else. Second, a modern-only server should name its supported versions in whatever error it returns to an initialize request, because legacy clients have no way to fall forward and that message may be the only diagnostic a user ever sees. Before you set a date, confirm which protocol versions your SDK build actually implements; the transport shape changed enough that a working Streamable HTTP server on an older SDK will not speak this revision by upgrading a config flag.
Trade-offs and what I would do
For a remote server at any scale, this revision is a clear net win and you should plan for it now. You delete the session store, the sticky routing rules, the session expiry sweeper and the reconnect storm that follows a deploy. What you gain is explicit handle lifecycle: storage, TTL, ownership checks and revocation become code you own rather than behaviour the transport gave you for free. That is more work, and it is also auditable, testable, and survives a pod restart.
The costs are honest ones. Every request now repeats protocol version, client info and capabilities in _meta, which is a small constant tax on token count and payload size. Handles consume schema surface and model attention, and models do occasionally pass a stale or invented handle, so your error messages have to be specific enough that the next tool call is a recovery rather than a repeat. Losing stream resumability is a downgrade for anyone who was relying on replay, and the answer (idempotency keys plus the tasks extension) is more code than a header was.
If you ship stdio servers only, the pressure is lower, but you still lose the handshake and owe an implementation of server/discover. If your client fleet is entirely legacy today, do not rush the cutover; do the audit anyway, because it usually surfaces state you did not know was connection scoped.
The sequence I would follow: freeze new session-dependent features immediately, add handle-based equivalents behind the same tool names so the model-facing contract does not change, make every mutating tool idempotent, ship dual-era with legacy still answering initialize, and delete the session store only once your telemetry shows no client has opened a legacy connection for a full release cycle.
Sources
- MCP specification 2026-07-28: Key Changes
- MCP specification: Streamable HTTP transport
- MCP specification: Versioning and Compatibility
- MCP specification: Multi Round-Trip Requests
- SEP-2567: Remove protocol-level sessions
- MCP Tasks extension specification
- MCP specification 2025-11-25: Transports (legacy session behaviour)