Content

MCP 2026-07-28 Spec Guide: What Changed, Migration Code, and 6 Production Patterns

The 2026-07-28 revision removed sessions, the initialize handshake, ping, and SSE stream resumability. Here is every change that affects a server you already run, what to do about each one, and the six patterns worth adopting.

EngineeringOct 23, 2026

Key takeaways

  • -Protocol-level sessions are gone. The initialize handshake, notifications/initialized and the Mcp-Session-Id header are all removed, and protocol version plus client capabilities now travel in _meta on every request.
  • -SSE stream resumability is removed. Last-Event-ID and event IDs are gone, and a broken stream means the client MUST re-issue the request with a new request ID, which rules out request-ID deduplication entirely.
  • -server/discover is now mandatory for servers to implement, though clients may skip it. Your validation cannot assume discovery happened.
  • -ping, logging/setLevel and notifications/roots/list_changed are removed. Log level is per-request via _meta, and Roots, Sampling and Logging are deprecated with a twelve-month window.
  • -Every result now carries a required resultType field of complete or input_required, which is how the new Multi Round-Trip Requests pattern replaces server-initiated sampling and elicitation.

The 2026-07-28 revision of the Model Context Protocol is the largest since Streamable HTTP landed. It removes protocol sessions, the initialize handshake, ping, logging/setLevel, and SSE stream resumability, and it introduces a new pattern for the case where a server needs more input before it can finish.

If you run an MCP server today, the parts that will break are specific and findable. This post goes through them in the order they are likely to bite, with the SEP numbers so you can read the discussion yourself, then covers six patterns that are worth adopting rather than merely surviving.

Everything here is from the specification's own changelog for the revision, which supersedes the earlier secondary summaries that were circulating, including the one we leaned on when we first wrote about this. Where our earlier coverage differed, the spec wins and we have noted it.

The one-paragraph version

MCP is now stateless by design. A request carries everything needed to serve it, any server instance can serve any request, and nothing is pinned to the process that handled a handshake. In exchange, several conveniences that depended on a session are gone, and retry safety is now visibly the implementer's problem rather than invisibly so.

What breaks first

Sessions and the handshake are gone

Two SEPs did this. SEP-2567 removed protocol-level sessions and the Mcp-Session-Id header from Streamable HTTP. SEP-2575 removed the initialize and notifications/initialized handshake outright.

In their place, every request is self-describing. Protocol version and client capabilities travel in _meta:

  • io.modelcontextprotocol/protocolVersion
  • io.modelcontextprotocol/clientCapabilities
  • io.modelcontextprotocol/clientInfo, which clients SHOULD send on each request

Servers SHOULD identify themselves in each result's _meta under io.modelcontextprotocol/serverInfo. A version mismatch returns UnsupportedProtocolVersionError.

Correction worth flagging: we previously wrote that requests carry an MCP-Protocol-Version header. They do not. The protocol version lives in _meta. The headers that are now required are Mcp-Method and Mcp-Name, which is a different thing entirely and is covered below.

List endpoints no longer vary per connection, which is the deployment payoff: tools/list, resources/list and prompts/list return the same thing to everyone, so they are cacheable and no instance needs to remember who asked.

Where state genuinely must survive across calls, the spec's answer is explicit, server-minted handles passed as ordinary tool arguments. The handle is returned in a result, the model carries it in context, and it comes back as an argument. The state lives in your storage, keyed by something the model holds.

// Before: state hung off the session
const cursor = session.get("cursor");

// After: the handle is an argument like any other
export async function listOrders({ cursor, limit = 50 }) {
  const page = await db.orders.page({ cursor, limit });
  return {
    resultType: "complete",
    content: [{ type: "text", text: render(page.rows) }],
    structuredContent: { nextCursor: page.nextCursor }   // the handle goes back to the model
  };
}

Give handles a TTL. Nothing cleans them up for you now that no session expiry does it implicitly.

SSE resumability is removed, and this one is bigger than it looks

SEP-2575 also removed SSE stream resumability and message redelivery: the Last-Event-ID header and SSE event IDs are gone from Streamable HTTP.

The consequence is stated plainly in the changelog: a broken response stream loses the in-flight request, and clients MUST re-issue it as a new request with a new request ID.

Read that twice if you own a server that performs side effects. It means:

  • A dropped connection mid-call is now an ordinary, expected, spec-sanctioned event with a defined recovery, and the recovery is a full re-send.
  • Request-ID deduplication is explicitly ruled out. You cannot dedupe on the JSON-RPC request ID, because the spec requires the retry to use a different one. Any dedupe scheme built on request identity is dead on arrival.

So if your tools/call charges a card, sends an email, or creates an invoice, the protocol now tells your client to call it again when the network hiccups, and guarantees you cannot recognise the retry from the request ID. Idempotency has to come from somewhere else, and we went through where in MCP went stateless, so where do idempotency keys live now.

ping, logging/setLevel and roots/list_changed are removed

Also SEP-2575. Three methods simply no longer exist:

  • ping
  • logging/setLevel
  • notifications/roots/list_changed

Log level is now set per request via io.modelcontextprotocol/logLevel in _meta, and servers MUST NOT emit notifications/message for a request that did not include that field. If your server logs chattily by default, it is now non-conformant: silence is the default and the client asks for noise per call.

Grep for ping first. Health checks built on it need to become ordinary HTTP health checks, which they probably should have been.

Notifications move to subscriptions/listen

The HTTP GET endpoint is gone, and so are resources/subscribe and resources/unsubscribe. Both are replaced by a single subscriptions/listen: one long-lived POST-response stream carrying opted-in server-to-client change notifications.

Clients opt in to specific types:

  • toolsListChanged
  • promptsListChanged
  • resourcesListChanged
  • resourceSubscriptions

The server acknowledges and tags each notification with io.modelcontextprotocol/subscriptionId.

One distinction that is easy to miss: request-scoped notifications do not move. notifications/progress and notifications/message continue to flow on the response stream of the request they belong to, not on the subscriptions/listen stream. If you conflated the two while migrating, progress updates will go to the wrong place.

Every result needs a resultType

From SEP-2322: all results now carry a required resultType field. "complete" for an ordinary result, "input_required" for an interim one.

Clients MUST treat a result from an earlier-protocol server that omits the field as "complete", so old servers keep working. New servers should set it explicitly on every return path, including error paths that return a result rather than an error.

Tasks moved out of core

SEP-2663 moved experimental tasks into an official extension, io.modelcontextprotocol/tasks, and redesigned it:

  • The blocking tasks/result method is replaced by polling with tasks/get.
  • tasks/update is new, for client-to-server input.
  • tasks/list is removed.
  • Servers may return task handles unsolicited, with no per-request opt-in.

OAuth changes

Four things, and the first one is a client-side MUST:

  • Authorization servers SHOULD include iss in authorization responses per RFC 9207, and clients MUST validate a present iss against the recorded issuer before redeeming the code (SEP-2468).
  • Clients must specify an appropriate application_type during Dynamic Client Registration, to avoid OpenID Connect redirect URI conflicts (SEP-837).
  • Client credentials are bound to the issuing authorization server: key persisted credentials by issuer, never reuse across servers, re-register when the server changes (SEP-2352).
  • Dynamic Client Registration itself is deprecated in favour of Client ID Metadata Documents, though it stays available for authorization servers that do not support them.

Error codes renumbered

Two separate changes here.

Resource-not-found moves from -32002 to -32602 (Invalid Params), aligning with JSON-RPC.

And there is now an allocation policy for the server-error range: -32000 to -32019 stays implementation-defined with existing SDK usage grandfathered, while -32020 to -32099 is reserved for the specification. Three codes introduced in the draft were renumbered accordingly:

ErrorOldNew
HeaderMismatch-32001-32020
MissingRequiredClientCapability-32003-32021
UnsupportedProtocolVersion-32004-32022

If you hardcoded any of those three, or you are matching on -32002 for a missing resource, those are string-search-and-fix jobs.

What is deprecated, with twelve months

The revision adopts a formal feature lifecycle with Active, Deprecated and Removed states and a minimum twelve-month deprecation window. Deprecated features still work. New implementations should not adopt them.

DeprecatedSEPSuggested migration
RootsSEP-2577Pass directories or files via tool parameters, resource URIs, or server configuration
SamplingSEP-2577Integrate directly with an LLM provider API
LoggingSEP-2577Log to stderr on stdio, or use OpenTelemetry
HTTP+SSE transportSEP-2596Streamable HTTP
includeContext values "thisServer" / "allServers"SEP-2596Omit the field, or use "none"
OAuth Dynamic Client RegistrationPR #2858Client ID Metadata Documents

The Sampling deprecation deserves a note. Sampling let a server ask the client's model to generate something, which was the mechanism for servers that needed an LLM without having their own API key. The suggested migration, integrating directly with a provider API, means the server now needs its own credentials. That is a real change in the trust and cost model of a server, not just an API swap.

Six patterns worth adopting

Surviving the migration is the floor. These are the things the revision makes newly worth doing.

1. Route and meter at the edge with Mcp-Method and Mcp-Name

SEP-2243 requires Mcp-Method and Mcp-Name headers on Streamable HTTP POSTs. Mcp-Method is the method, such as tools/call; Mcp-Name is the name, such as the tool being invoked.

This is the cheapest observability win in the change set, because your proxy can now tell a tools/list from a tools/call without parsing a JSON-RPC body:

# Rate limit expensive tools without touching the app
limit_req_zone $http_mcp_name zone=per_tool:10m rate=10r/s;

# Per-tool latency metrics, no body parsing
log_format mcp '$http_mcp_method $http_mcp_name $request_time $status';

The same SEP adds x-mcp-header support for custom headers derived from tool parameters.

2. Let clients cache your lists

SEP-2549 requires ttlMs and cacheScope on results from tools/list, prompts/list, resources/list, resources/read and resources/templates/list, via a new CacheableResult interface.

ttlMs is a freshness hint in milliseconds. cacheScope is "public" or "private" and controls whether shared intermediaries may cache the response. Both complement the existing listChanged notifications rather than replacing them.

export async function toolsList() {
  return {
    resultType: "complete",
    tools: TOOLS,
    ttlMs: 300_000,        // five minutes
    cacheScope: "public"   // identical for every caller, so a CDN may hold it
  };
}

Set these deliberately. Deciding to think about it later means clients guess, and a tools/list on every connection is pure waste now that nothing varies per connection.

3. Return tools in a deterministic order

A minor change with a real payoff: servers SHOULD return tools from tools/list in a deterministic order, to enable client-side caching and improve LLM prompt cache hit rates.

If your tool list comes out of a map or a database query without an ORDER BY, the order can shift between calls, which busts the client's cache and the model's prompt cache for no reason. Sort by name and forget about it.

4. Adopt MRTR for anything that needs more input

Multi Round-Trip Requests (SEP-2322) replace server-initiated roots/list, sampling/createMessage and elicitation/create. Instead of calling back into the client, a server that needs more information returns:

export async function transferFunds(args) {
  if (!args.confirmation) {
    return {
      resultType: "input_required",
      inputRequests: [{
        type: "confirmation",
        prompt: `Transfer ${fmt(args.amount)} to ${args.payee}?`
      }],
      requestState: { intent: await stageTransfer(args) }  // your own correlation id
    };
  }
  // ... reached only once the client retries with inputResponses
}

The client responds by retrying the original request with inputResponses attached.

The safety consequence is the thing to internalise. MRTR is a spec-sanctioned replay of a request you have already partially processed. Everything your handler does before returning input_required will run again on the retry. So stage, do not commit: stageTransfer above writes a pending intent and is safe to run twice, while the actual transfer happens only on the path that has inputResponses.

Note also that notifications/elicitation/complete and the elicitationId field are removed. A server needing to correlate across retries encodes its own identifier in requestState, as above.

5. Propagate trace context through _meta

SEP-414 documents OpenTelemetry trace context propagation conventions for _meta keys: traceparent, tracestate and baggage.

With Logging deprecated in favour of OpenTelemetry, this is the sanctioned way to get a tool call to show up in the same trace as the request that triggered it. Read those keys off _meta and start your span as a child.

6. Decide where every mutating tool gets its idempotency key

This is not in the specification, which is exactly why it belongs on your list. The spec says nothing about idempotency or retry safety, and after this revision it tells clients to re-issue broken requests with a new request ID.

So for each mutating tool, write down where its key comes from: a client-supplied argument, a store your handler manages, or the execution layer underneath the tool. Our view is the third, because key generation and retry logic have to be owned by the same component or they drift apart. In Swytchcode that is per-integration config:

{
  "execution_policy": {
    "idempotency": {
      "mode": "dynamic",
      "header_name": "Idempotency-Key"
    }
  }
}

mode: "none" is the default, "dynamic" generates a key and reuses it across that execution's own retries, and each exec call gets its own key so two deliberate actions stay two actions. The idempotency guide and the manifest.json reference have the details, and the dedicated post has the argument.

Credentials are the other thing a stateless server should not be holding in process memory. They live in Swytchcode's own local store outside the project, and the execution mode is a project setting answered once at swy init:

Terminal running swytchcode init and choosing an editor and execution mode

swy init writes the editor and execution mode into .swytchcode/tooling.json. Sandbox versus production is never an environment variable, which matters more when any instance can serve any request.

Claude Code running swytchcode list and swytchcode info commands to find the right methods before executing

An agent finding a method and inspecting its inputs before executing it. The unit that gets its own idempotency key is the execution, which is why "one key per exec call" is a rule you can reason about from the tool definition.

A migration checklist

Roughly ordered by how likely each is to bite.

  1. Grep for Mcp-Session-Id and for your framework's session object. Each hit is state that becomes an explicit handle, or state you never needed.
  2. Grep for ping. It is removed. Health checks become ordinary HTTP.
  3. Grep for logging/setLevel. Removed. Read io.modelcontextprotocol/logLevel from _meta per request, and emit nothing when it is absent.
  4. Add resultType to every return path. "complete" unless it is an MRTR interim result.
  5. Stop assuming initialization ran. Read version, identity and capabilities from _meta on each request, and define behaviour for when they are missing.
  6. Implement server/discover. Servers MUST. Clients MAY call it, so your validation still has to stand alone.
  7. Replace server-initiated sampling and elicitation with MRTR, and make everything before the input_required return safe to run twice.
  8. Move notifications to subscriptions/listen, keeping request-scoped notifications/progress and notifications/message on their own request's response stream.
  9. Emit Mcp-Method and Mcp-Name, then actually use them at the edge.
  10. Set ttlMs and cacheScope on all five list and read results, and sort your tool list.
  11. Handle broken streams as new requests. No Last-Event-ID, no redelivery, new request ID on retry.
  12. Fix OAuth: validate iss, set application_type, key credentials by issuer, plan for Client ID Metadata Documents.
  13. Fix error codes: -32002 to -32602 for resource-not-found, and the three renumbered draft codes.
  14. Decide the idempotency key owner for every mutating tool. Any tool where the answer is "we have not decided" will produce a duplicate eventually.
  15. Drop sticky sessions and session stores from your deployment. This is the payoff.

Things we got wrong

We said requests carry an MCP-Protocol-Version header. They do not. The protocol version travels in _meta under io.modelcontextprotocol/protocolVersion. The required headers are Mcp-Method and Mcp-Name. We had this from a secondary summary and did not check it against the changelog, which is the whole reason this post is sourced from the spec instead.

We described server/discover as simply optional. It is optional for clients to call and mandatory for servers to implement. The practical advice is unchanged, since you cannot assume a client called it, but "optional RPC" undersells a MUST.

We thought resources/subscribe survived. It does not. It and resources/unsubscribe are replaced by subscriptions/listen along with the HTTP GET endpoint.

We moved progress notifications to the subscription stream. They belong on the response stream of the request they relate to. Our first migration sent progress to subscriptions/listen and clients stopped seeing it mid-call.

FAQ

What is the headline change in MCP 2026-07-28?

Protocol-level sessions are gone. The initialize handshake, notifications/initialized and the Mcp-Session-Id header are removed, and every request now carries its own protocol version and client capabilities in _meta. Any instance can serve any request.

What was the previous revision?

2025-11-25. This changelog lists changes since that version.

Where does state live now that sessions are gone?

In explicit, server-minted handles passed as ordinary tool arguments. The server returns an identifier, the model carries it, and it comes back as an argument. The data lives in your own storage, keyed by that handle, and you are responsible for its TTL.

Can I still dedupe retries on the JSON-RPC request ID?

No, and this is now explicit. SSE resumability was removed, and the spec requires a client whose response stream broke to re-issue the request with a new request ID. Any deduplication built on request identity cannot work.

Is ping really gone?

Yes, along with logging/setLevel and notifications/roots/list_changed. Use ordinary HTTP health checks, and read log level per request from io.modelcontextprotocol/logLevel in _meta.

Do I have to implement server/discover?

Yes. Servers MUST implement it. Clients MAY call it before anything else for version selection, or use it as a backward-compatibility probe on stdio, but they are not obliged to, so your per-request validation still has to stand on its own.

What replaces sampling and elicitation?

Multi Round-Trip Requests. The server returns an InputRequiredResult with resultType: "input_required" and an inputRequests field; the client retries the original request with inputResponses. Servers no longer call back into clients.

Does MRTR create a replay problem?

Yes, and you should design for it. MRTR works by re-sending a request the server has already partially handled, so anything your handler does before returning input_required runs more than once. Stage work there and commit only once the inputs are present.

How long do deprecated features keep working?

A minimum of twelve months under the new feature lifecycle policy. Roots, Sampling, Logging, HTTP+SSE, the two includeContext values and OAuth Dynamic Client Registration are all Deprecated rather than Removed, and remain functional during the window.

What are ttlMs and cacheScope for?

They let a server state its own caching terms on list and read results. ttlMs is a freshness hint in milliseconds; cacheScope of "public" or "private" controls whether shared intermediaries may cache it. They complement listChanged notifications rather than replacing them.

Does any of this address idempotency?

No. The specification does not address idempotency, retry safety or request-ID semantics, and it did not before this revision either. What changed is that the absence is now impossible to paper over, since there is no session to hang a dedupe cache off and retries are required to use a new request ID.

Wrapping up

This revision makes MCP honest about what it is: a stateless request protocol. The deployment story genuinely improves, lists become cacheable, and the edge gets headers it can route on without parsing bodies.

What it hands back to you is retry safety. Streams break, the spec tells clients to re-send with a new identifier, and nothing in the protocol will tell your server that the call it is about to perform is one it already performed. That is a solvable problem, but it has to be solved somewhere specific, per mutating tool, on purpose.

Start with the greps in the checklist, since they are mechanical and they find the real surface area. Then spend the saved afternoon on item fourteen, which is the one nobody's migration guide will mention because it is not in the spec.

If you want the long version of that argument, where idempotency keys live now is the companion to this post. For running an MCP server with credentials and policy handled underneath, the Jev MCP server post covers swy mcp serve, and npx swytchcode installs the CLI.

More content