---
title: "MCP Is Now Stateless: A Guide to 2026-07-28 · Highflame"
description: "MCP 2026-07-28 removes the initialize handshake, sessions, and server-initiated requests. What changed at the wire level, and how to migrate a server or client."
doc_version: "0.1.0"
last_updated: "2026-08-07T04:48:55.536Z"
canonical: "https://www.highflame.com/blog/mcp-2026-07-28-spec-stateless"
---

[← All articles](/blog.md)

# MCP Is Now Stateless: A Guide to 2026-07-28

Aug 06, 202621 min read

Highflame Engineering

![MCP Is Now Stateless: A Guide to 2026-07-28](/blog/mcp-2026-07-28-spec-stateless/hero.png)

In the not quite two years since it launched, the Model Context Protocol has become the common way agents reach external tools and data. Claude, ChatGPT, VS Code, and Cursor all speak it, the Tier 1 SDKs are pulling close to half a billion downloads a month, and building an integration once now means it works nearly everywhere.

The recurring criticism was that MCP required a stateful connection between client and server. That requirement traces back to the protocol’s origins in stdio, a transport built for local use, where the client launches the server as a subprocess and the two talk over pipes for as long as that process lives. A session costs nothing there, because there is one client and one server and the connection is a sensible place to keep whatever the two sides agreed on. When MCP went remote, that model came along unchanged. Serving it well meant routing requests to sticky sessions, holding streams open, replaying messages after a drop, and generally carrying more operational weight than an ordinary web service does.

The [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28/changelog), which plenty of people have already started calling MCP 2.0, removes the requirement. Released on July 28 alongside updated TypeScript, Python, Go, and C# SDKs, it makes MCP fully stateless: every request declares its own protocol version, identifies its client, and states its capabilities, so any instance behind an ordinary round-robin load balancer can answer any request. Simon Willison, who [built three projects against the new spec](https://simonwillison.net/2026/Jul/31/stateless-mcp/) in the days after it landed, called it “the most significant change to the MCP spec since it first launched.”

Reaching that point cost a lot of surface area. The specification withdrew the server’s ability to initiate a JSON-RPC request, which changes how sampling, elicitation, and roots work. The standalone SSE stream, `Mcp-Session-Id`, `ping`, `logging/setLevel`, and SSE resumability were all retired alongside it. In exchange, MCP gained several things it did not have before: HTTP headers that let a gateway route and meter traffic without parsing JSON, cache hints on list endpoints, a formal extensions mechanism, and a written deprecation policy with a twelve-month floor.

The rest of this post covers those changes in the order you are likely to hit them, with a migration checklist near the end for anyone maintaining a server or a client today.

## **Every request now describes itself**

Under `2025-11-25` and earlier, a client opened with `initialize`, received the server’s capabilities and a negotiated protocol version, sent `notifications/initialized`, and only then started doing work. Everything after that point relied on the connection remembering what had been agreed.

The latest specification optimizes that opening exchange away by removing `initialize` and `notifications/initialized`, both of which existed to establish session state, along with the negotiation round trip that came with them. Each request now simply carries whatever it needs in `params._meta`:

```
{
  "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": {}
    }
  }
}
```

Three reserved `_meta` keys do the work the handshake used to do. Two are required on every request: `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`. The third, `io.modelcontextprotocol/clientInfo`, is a should, and servers should return `io.modelcontextprotocol/serverInfo` in each result’s `_meta`, so both sides still get the identity information the handshake used to exchange. Log level moved here too, as `io.modelcontextprotocol/logLevel`, and servers must not emit `notifications/message` for a request that did not ask for logging.

Leave out a required field and the request is malformed: the server must reject it with `-32602` (Invalid params), which on HTTP is a `400`. There is a second failure worth designing for. If a request needs a capability the client never declared, the server must return `MissingRequiredClientCapabilityError` (`-32021`), whose `data.requiredCapabilities` names exactly what was missing.

Results carry the mirror image. The server identifies itself the same way, in the result’s `_meta`, and every result declares its type:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "content": [{ "type": "text", "text": "62F and clear" }],
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "ExampleServer",
        "version": "1.0.0"
      }
    }
  }
}
```

Worth noting before you build anything on those two fields: `clientInfo` and `serverInfo` are self-reported and the protocol does not verify either one. The spec is explicit that they exist for display, logging, and debugging, and that implementations should not make security decisions on them.

Version mismatches are now handled as an error rather than as a negotiation. If the server does not speak the version you asked for, it tells you what it does speak:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28", "2025-11-25"],
      "requested": "1900-01-01"
    }
  }
}
```

The client picks a mutually supported version out of `supported` and retries. In the common case where the client guessed right, this costs nothing, which is the point of moving negotiation off the happy path.

You can still ask up front if you want to. Servers must implement `server/discover`, which returns supported versions, capabilities, identity, and an optional `instructions` string of natural-language guidance for the model, and clients may call it before anything else. On stdio it also serves as the backward-compatibility probe. On HTTP most clients will skip it and simply make the call they wanted to make.

## **Sessions retired, and state became an explicit choice**

Streamable HTTP dropped `Mcp-Session-Id` along with the HTTP DELETE that used to terminate a session. A server that only speaks this revision should ignore an incoming `Mcp-Session-Id` header, never mint or echo one, and answer GET or DELETE on the MCP endpoint with `405 Method Not Allowed`.

One consequence is easy to miss on a first read: list endpoints no longer vary per connection. `tools/list`, `resources/list`, and `prompts/list` return the same thing to every client at the same protocol version and auth context. If your server was quietly showing a different tool catalog per session, that behavior no longer has a home in the protocol.

Servers that genuinely need state across calls are expected to mint it themselves and pass it as ordinary tool arguments. A `create_query` tool returns a `queryId`, and a `fetch_page` tool takes that ID as a parameter. The handle lives in the JSON body where your logging, your gateway, and your policy engine can all see it, rather than in a transport header that only the connection understood.

SSE resumability went the same way. With `Last-Event-ID` and SSE event IDs both retired, a broken response stream loses the in-flight request, and the client must re-issue it as a new request with a new JSON-RPC id. For long tool calls over unreliable networks that is a real regression, and the spec’s answer is to move that work to the [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) instead of replaying it at the transport layer.

## **The wire is now readable by infrastructure**

The same revision that took state out of the transport put more information into it. Streamable HTTP now mirrors selected body fields into HTTP headers, and those headers are required for compliance:

Header

Source field

Required for

`MCP-Protocol-Version`

`protocolVersion` in `_meta`

Every POST

`Mcp-Method`

`method`

Every request

`Mcp-Name`

`params.name` or `params.uri`

`tools/call`, `resources/read`, `prompts/get`

A tool call now looks like this on the wire:

```
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":1,"method":"tools/call","params":{...}}
```

That opens up a category of work that previously required deserializing every request body. An nginx rule, a WAF, a rate limiter, or an API gateway can route on `Mcp-Method`, meter per tool using `Mcp-Name`, and reject an unrecognized protocol version, all without touching the JSON.

Servers can extend this to their own parameters by annotating them with `x-mcp-header` in the tool’s `inputSchema`:

```
{
  "name": "execute_sql",
  "inputSchema": {
    "type": "object",
    "properties": {
      "region": {
        "type": "string",
        "x-mcp-header": "Region"
      },
      "query": { "type": "string" }
    },
    "required": ["region", "query"]
  }
}
```

The client then emits `Mcp-Param-Region: us-west1` alongside the body, so a gateway can route a Spanner query to the right region without reading the arguments. Using `x-mcp-header` is optional for servers, but clients must support it, and a client on Streamable HTTP must exclude from `tools/list` any tool whose annotation breaks the rules.

Those rules are worth reading before you annotate anything. Only primitive types qualify (`integer`, `string`, `boolean`, and explicitly not `number`), and the annotated property must be statically reachable from the schema root through a chain of `properties` keys alone. The chain cannot pass through `items` or any other array keyword, through `oneOf`, `anyOf`, `allOf`, or `not`, through `if`/`then`/`else`, or through `$ref`. Nested objects are fine as long as every step is a `properties` key.

### **The header is not the source of truth**

The body stays authoritative, and the spec is careful about this. Any server that parses the body must validate that header values match the corresponding body values, decoding the Base64 sentinel form first where one was used. A mismatch is a `400 Bad Request` with JSON-RPC error `-32020`:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32020,
    "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
  }
}
```

The problem this closes is a split brain between two components that read different sources of truth: a load balancer routing on `Mcp-Name: read_file` while the server executes `params.name = "delete_file"`.

There is a second guardrail aimed at intermediaries, and it is easy to skip. An intermediary that enforces policy on mirrored headers should check that `MCP-Protocol-Version` names a revision that requires header/body validation, and reject the request if the version is older or the header is absent. Without that check, an attacker declares `2025-11-25`, where nothing validates headers against the body, and your gateway enforces policy on values the server will never verify.

Values that cannot ride in an ASCII header use a sentinel encoding, as in `Mcp-Param-Text: =?base64?IHBhZGRlZCA=?=`. The markers are lowercase and case-sensitive, and a plain-ASCII value that happens to look like the sentinel must itself be encoded so it cannot be mistaken for one.

## **Multi Round-Trip Requests replace everything the server used to initiate**

Losing the ability to initiate a request is the most invasive change for anyone whose server calls back into the client. `roots/list`, `sampling/createMessage`, and `elicitation/create` survive as request shapes, but a server can only ask for them now by returning a result indicating that it needs input, then waiting for the client to come back with it.

That signal rides on `resultType`, the field every result carries. `"complete"` marks an ordinary result, as in the example above, and `"input_required"` marks a request for input. The set is not closed: extensions define their own values, and the Tasks extension returns `"task"`. A client must reject any value it does not recognize, and must treat a result from an older server that omits the field entirely as `"complete"`.

An `InputRequiredResult` carries an `inputRequests` map keyed by server-assigned identifiers, plus an opaque `requestState` blob:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Please provide your GitHub username",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}
```

The client gathers the input, then retries the original request with a matching `inputResponses` map and the exact `requestState` value echoed back. The retry uses a new JSON-RPC id, since it is a genuinely independent request rather than a continuation of the first one. `InputRequiredResult` is only legal on `tools/call`, `resources/read`, and `prompts/get`, and servers must not return it anywhere else.

Two requirements tend to surprise implementers. The server must include at least one of `inputRequests` or `requestState`, and must not ask for a capability the client never declared, so no elicitation capability in `_meta.io.modelcontextprotocol/clientCapabilities` means no `elicitation/create` in your `inputRequests`. Servers also must not assume the client will ever come back. MRTR gives you no delivery guarantee and no way to hold a transaction open across the gap. That is also why `notifications/elicitation/complete` and the `elicitationId` field from `2025-11-25` were both removed: with no session to correlate against, a server-initiated completion signal has nowhere to go.

### **requestState is attacker-controlled input**

`requestState` is minted by the server, handed to the client, and handed back later. It makes a round trip through a party the server does not control, and the spec treats it accordingly.

If `requestState` influences authorization, resource access, or business logic, servers must integrity-protect it with HMAC or AEAD and must reject state that fails verification. To bound replay, servers should bind three things into the protected payload and check each on receipt: the authenticated principal, so state presented by a different principal is rejected; a short TTL; and an identifier for the originating request, such as the method name plus a digest of its salient parameters.

Even with all three in place, you have bounded the replay window and prevented cross-user reuse without making the state single-use. If a given `requestState` must be redeemed at most once, the spec says you enforce that server-side, with storage, which is the same state the stateless design was meant to let you avoid. The revision does not pretend otherwise, and it limits the requirement to the cases that actually need it. A reasonable mental model is that `requestState` behaves like a signed cookie rather than a session pointer, and the controls that apply to one apply to the other.

## **subscriptions/listen replaces the GET stream**

The specification consolidated three mechanisms into one. The standalone `GET /mcp` SSE stream, `resources/subscribe`, and `resources/unsubscribe` were all replaced by `subscriptions/listen`, a POST whose response is a long-lived SSE stream. The client passes a `notifications` filter naming what it wants: `toolsListChanged`, `promptsListChanged`, and `resourcesListChanged` are booleans, while `resourceSubscriptions` takes an array of resource URIs. A server must not send a type the client did not ask for.

Two details separate a working client from one that quietly misses events. The server replies with `notifications/subscriptions/acknowledged`, and its `notifications` field reflects only the subset it agreed to honor, with unsupported types omitted, so a client should compare the acknowledgment against what it asked for. Every message on the stream then carries `io.modelcontextprotocol/subscriptionId` in `_meta`, and the value is the JSON-RPC id of the `subscriptions/listen` request itself. That is what lets one client hold several subscriptions at once and tell them apart, which matters most on stdio where they all share a single channel.

A clean shutdown is also distinguishable from a dropped one. When the server ends a subscription itself, it should first answer the original `subscriptions/listen` request with an empty result; a stream that closes without one was an unexpected disconnect, and the client may treat that as a cue to reconnect. On stdio it must then re-send `subscriptions/listen`, because the server keeps no subscription state across connections.

Request-scoped notifications do not travel on that stream. `notifications/progress` and `notifications/message` flow only on the SSE response stream of the request they belong to, so a client can have two kinds of stream open at once carrying entirely separate traffic.

Two operational details are called out in the spec and are easy to get wrong. Servers should send `X-Accel-Buffering: no` so that nginx and similar proxies stop buffering SSE events and holding them until a chunk fills. And on the long-lived listen stream, servers are encouraged to emit an SSE comment line (`:\r\n`) periodically as a keep-alive, so a quiet connection is not closed by an intermediary or an idle timeout.

Cancellation got simpler on HTTP. Closing the SSE response stream is itself the cancellation signal, and the server must stop sending messages for that request. Because each request has its own stream, the disconnect is unambiguous. `notifications/cancelled` now applies only to stdio.

## **Lists are cacheable, and tool order matters**

Because list endpoints no longer vary per connection, their results became cacheable in a way they were not before. Servers must now include two caching hints on any `"complete"` result from `server/discover`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`. `ttlMs` is a freshness hint in milliseconds, with semantics borrowed from `Cache-Control: max-age`, and the spec is careful to call it a freshness check rather than a polling timer. `cacheScope` is `"public"` or `"private"`: public means the response holds nothing user-specific and any shared cache may serve it to anyone, while private means it may be reused within one authorization context and never across two.

Two things are excluded. Interim `"input_required"` results carry no hints at all, and any result produced by an MRTR retry, meaning any request carrying `inputResponses` or `requestState`, must not be cached, because those inputs are not part of the cache key.

The hints complement `listChanged` notifications rather than replacing them, and a notification invalidates a still-fresh cached response immediately. Since list endpoints no longer vary per connection, a tool catalog marked `cacheScope: "public"` can sit in a CDN or gateway cache and serve every client at that protocol version.

That last part deserves care on an authenticated server. A `"public"` response may be shared between callers even though it came back from an authenticated endpoint, so marking a per-tenant `tools/list` public hands one tenant’s catalog to the next. The spec says plainly that `cacheScope` is not an access control, and that servers must enforce per-primitive authorization themselves.

Alongside this is a quieter recommendation: servers should return tools from `tools/list` in a deterministic order. The reason is prompt caching rather than tidiness. Tool definitions get serialized into the model’s prompt, so a stable order keeps the prefix byte-identical between calls and the provider’s prompt cache keeps hitting. A server that shuffles its tool list invalidates that prefix on every call.

## **Authorization hardening**

Authorization picked up four changes, none of which depend on the stateless rework. They are worth adopting even if you defer everything else in this post.

**Issuer validation.** Following [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207), authorization servers should include the `iss` parameter in authorization responses, and MCP clients must validate a present `iss` against the recorded issuer before redeeming the authorization code. This closes the authorization server mix-up attack, where a malicious AS tricks a client into redeeming a code at the wrong endpoint. Servers advertise support through `authorization_response_iss_parameter_supported: true`.

**Client ID Metadata Documents.** Dynamic Client Registration is deprecated, and CIMD replaces it. The `client_id` becomes an HTTPS URL pointing at a JSON document the authorization server fetches on demand:

```
{
  "client_id": "https://app.example.com/oauth/client-metadata.json",
  "client_name": "Example MCP Client",
  "client_uri": "https://app.example.com",
  "redirect_uris": [
    "http://127.0.0.1:3000/callback",
    "http://localhost:3000/callback"
  ],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

The `client_id` value inside the document must match the URL exactly, the URL must use `https` and contain a path component, and the authorization server must validate both the match and the requested redirect URI before showing a consent screen. Support is advertised as `client_id_metadata_document_supported: true`.

The practical advantage over DCR is portability. A CIMD client ID is a URL the authorization server resolves whenever it needs to, so nothing has to be re-registered when the authorization server changes. The client preference order is now pre-registration first, then CIMD, then DCR as a fallback, and prompting the user only when nothing else is available.

**Credential binding.** Clients that persist client credentials must key them by the authorization server’s `issuer` identifier, must not reuse them with a different authorization server, and must re-register when it changes. If protected resource metadata starts pointing at a different issuer than the one your credentials came from, the client should surface an error instead of silently trying them.

**application\_type.** Clients using DCR must now specify an appropriate `application_type`. Omitting it defaults to `"web"` under OpenID Connect, which is the reason so many CLI and desktop clients have been hitting rejected `localhost` redirect URIs. Native apps, desktop apps, CLI tools, and locally-hosted web apps should send `"native"`. Non-OIDC servers ignore the parameter safely.

## **What is deprecated, and what is simply gone**

Removed outright in this revision:

-   `initialize` and `notifications/initialized`
-   `Mcp-Session-Id`, and the DELETE that terminated a session
-   The standalone GET SSE endpoint
-   `resources/subscribe` and `resources/unsubscribe`
-   `ping`
-   `logging/setLevel`
-   `notifications/roots/list_changed`
-   `notifications/elicitation/complete` and the `elicitationId` field
-   SSE resumability: `Last-Event-ID` and event IDs
-   Server-initiated JSON-RPC requests
-   `tasks/list` and the blocking `tasks/result`, as Tasks moved to an extension

Deprecated, with a minimum twelve-month window before removal under the new [feature lifecycle policy](https://modelcontextprotocol.io/community/feature-lifecycle):

Feature

Suggested migration

Roots

Pass paths as tool parameters or resource URIs

Sampling

Call an LLM provider API directly

Logging

Write to `stderr`, or use OpenTelemetry

HTTP+SSE transport

Streamable HTTP

Dynamic Client Registration

Client ID Metadata Documents

`includeContext`: `"thisServer"`, `"allServers"`

Omit it, or use `"none"`

Deprecating Roots, Sampling, and Logging is the most opinionated call in the release. All three were client-side capabilities the server reached back into, and all three are awkward to express once there is no session to reach back through. The lifecycle policy itself may end up mattering more than any individual deprecation: MCP now has written states (Active, Deprecated, Removed), a twelve-month floor, and a [registry of deprecated features](https://modelcontextprotocol.io/specification/2026-07-28/deprecated), so removals stop being a surprise.

Error codes were tidied up at the same time. The JSON-RPC server-error range is now partitioned, with `-32000` to `-32019` left implementation-defined and existing SDK usage grandfathered, and `-32020` to `-32099` reserved for the specification. Codes introduced during the draft cycle were renumbered, so anyone who tracked the release candidates needs to update them:

Error

Draft code

Final code

`HeaderMismatch`

`-32001`

`-32020`

`MissingRequiredClientCapability`

`-32003`

`-32021`

`UnsupportedProtocolVersion`

`-32004`

`-32022`

Separately, resource-not-found moved from `-32002` to `-32602` (Invalid Params) to line up with JSON-RPC.

## **Extensions absorb what left the core**

Several features left the core protocol in this revision, and the extensions mechanism is where they went. `ClientCapabilities` and `ServerCapabilities` gained an `extensions` field, a map of prefixed identifiers to per-extension settings objects:

```
{
  "capabilities": {
    "tools": {},
    "extensions": {
      "io.modelcontextprotocol/tasks": {}
    }
  }
}
```

Tasks moved out of the experimental core into `io.modelcontextprotocol/tasks` and was redesigned on the way. Polling through `tasks/get` replaces the blocking `tasks/result`, a new `tasks/update` carries client-to-server input mid-task, `tasks/list` is gone, and `tasks/cancel` asks for a stop the server acknowledges but is not obliged to honor. A server can now decide on its own that a call will take a while and answer with a `CreateTaskResult` instead of holding a stream open. It still needs permission to do so: the client declares the tasks extension in its capabilities once, and a server must never hand a task to a client that did not.

The handle is durable, and that is what replaces the resumability taken out of the transport. A client that crashes or reconnects resumes polling the same `taskId`. Tasks report a status of `working`, `input_required`, `completed`, `failed`, or `cancelled`, and the last three are terminal.

[MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) and [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) are extensions as well. We wrote about [what EMA does and where it stops](/blog/mcp-enterprise-managed-authorization.md) when it shipped. The structural point is that the core protocol now has a way to shrink, and a feature can mature on its own schedule instead of being frozen into a revision before it is ready.

Two smaller additions round out the release. OpenTelemetry trace context propagation is now a documented convention in `_meta`, using `traceparent`, `tracestate`, and `baggage`, so tracing a call across an agent, a gateway, and a server no longer requires everyone’s private hack. And `inputSchema` and `outputSchema` were loosened to allow any JSON Schema 2020-12 keywords, with `structuredContent` allowing any JSON value, accompanied by `$ref` resolution requirements and resource bounds on composition keywords so that the extra freedom does not become a denial-of-service surface.

## **Migrating**

The two eras have names now. **Modern** means per-request metadata, `2026-07-28` and later. **Legacy** means an `initialize` handshake, `2025-11-25` and earlier. **Dual-era** means supporting both.

A dual-era client on HTTP sends a modern request first and, on `400 Bad Request`, inspects the body before deciding anything. Modern servers also return `400` for `UnsupportedProtocolVersionError`, `MissingRequiredClientCapabilityError`, and header validation failures, so a recognized modern JSON-RPC error means retry with a supported version rather than fall back. An empty or unrecognized body means the server is legacy, and the client falls back to `initialize`. On stdio, the probe is `server/discover` instead.

Cache that determination. Era is a property of the server process on stdio or the origin on HTTP, not of an individual request, so clients should hold the result for that lifetime and may persist it across restarts, re-probing if the assumption later fails.

A dual-era server picks its behavior from how the client opens: modern `_meta` gets stateless handling, an `initialize` request gets legacy session semantics, and both can run on the same endpoint. If you support only modern versions, name your supported protocol versions in whatever error you return to `initialize`. Legacy clients have no fall-forward mechanism, and that error message is often the only diagnostic a user will see.

Two combinations have no shim at all. A modern client cannot talk to a legacy server, and a legacy client cannot talk to a modern server. Both fail, and the spec’s compatibility matrix says so plainly.

For an existing server, a workable order of operations:

1.  Stop reading `Mcp-Session-Id`. Return `405` on GET and DELETE.
2.  Read version, client info, and capabilities from `params._meta` on every request.
3.  Implement `server/discover`.
4.  Add `resultType: "complete"` to every result.
5.  Validate `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` against the body, returning `-32020` on mismatch.
6.  Add `ttlMs` and `cacheScope` to list results, and make your tool ordering deterministic.
7.  Rewrite any sampling, elicitation, or roots callback as MRTR, and sign your `requestState`.
8.  Replace `resources/subscribe` with `subscriptions/listen`.
9.  Move long-running work to the Tasks extension, since SSE resumability is gone.

All four Tier 1 SDKs (TypeScript, Python, Go, and C#) ship 2026-07-28 support, with Rust in beta. On the infrastructure side, [Cloudflare reports](https://blog.cloudflare.com/mcp-v2/) that a stateless server no longer needs `McpAgent` or Durable Objects and can deploy to a plain Worker, with Durable Objects reserved for the cases that genuinely coordinate state. Simon Willison, who [built three projects against the new spec](https://simonwillison.net/2026/Jul/31/stateless-mcp/) including a CLI explorer and a Datasette plugin, summarized the developer experience as “so much cleaner from both a client- and server-side implementation perspective.”

## **What this means for anyone securing MCP traffic**

The header changes help security tooling for the same reason they help load balancers. Policy that used to require a JSON parser in the hot path now runs off `Mcp-Method` and `Mcp-Name` directly, which covers per-tool rate limits, per-tool authorization, and per-tool audit. Deterministic tool ordering and `cacheScope: "public"` make a shared catalog cache workable too, giving you one place to inspect what a server is actually offering.

Two things move in the other direction. `requestState` puts server-authored state in the client’s hands on every multi-turn tool call. The spec mandates integrity protection and recommends principal binding, a TTL, and a request digest, which are the right controls and also the kind that get postponed. If you review MCP server implementations, that is a good first place to look.

Header and body validation also only exists from `2026-07-28` forward. A request that arrives claiming `2025-11-25` carries no such guarantee, which is why the spec tells intermediaries to check the version before trusting a mirrored header. Any gateway that enforces on headers without pinning the version is open to a downgrade followed by a header/body desync. Neither of these is a flaw in the revision, and the spec names both directly. They are simply where the attack surface moved.

What has not changed is the harder problem underneath. A valid token and a well-formed `tools/call` still say nothing about whether the call should run. `Mcp-Name: execute_sql` is a much better signal than an opaque POST body, and it is still not an answer to whether this agent, in this context, should be running that query right now. We have [written about that gap before](/blog/authentication-isnt-enough.md). 2026-07-28 does not close it, but by making the traffic legible to the infrastructure sitting in front of the server, it gives whatever does close it a lot more to work with.

## Frequently asked questions

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

MCP is now stateless. The initialize / notifications/initialized handshake and the Mcp-Session-Id header are gone. Every request carries its own protocol version, client identity, and capabilities in the \_meta field, so any server instance behind a round-robin load balancer can answer any request.

Does 2026-07-28 break existing MCP servers and clients?

Yes, for anything on Streamable HTTP. Session IDs, the GET stream, resources/subscribe, Last-Event-ID resumability, ping, logging/setLevel, and server-initiated JSON-RPC requests are all removed. Dual-era servers can keep answering initialize alongside the new per-request metadata, and dual-era clients probe by sending a modern request first and inspecting the body of a 400.

What replaces elicitation and sampling in MCP 2026-07-28?

Multi Round-Trip Requests. Instead of the server opening its own JSON-RPC request on a stream, it returns a result with resultType: "input\_required" carrying an inputRequests map. The client gathers the answers and retries the original request with a matching inputResponses map and the opaque requestState blob the server minted.

Is Dynamic Client Registration still supported in MCP?

DCR is deprecated in favor of Client ID Metadata Documents, where the client\_id is an HTTPS URL pointing at a JSON metadata document the authorization server fetches on demand. DCR stays available for backwards compatibility with authorization servers that do not support CIMD, and the feature lifecycle policy guarantees at least twelve months before removal.

Highflame Engineering

Engineering and research on agent identity, runtime policy, and securing autonomous AI at Highflame.

▸ Continue reading

-   [
    
    ![AI Gateway Benchmarks: Highflame vs LiteLLM vs Bifrost (2026)](/blog/the-three-moments-your-ai-gateway-can-ruin/hero.png)
    
    EngineeringJul 22, 2026
    
    #### AI Gateway Benchmarks: Highflame vs LiteLLM vs Bifrost (2026)
    
    ](/blog/the-three-moments-your-ai-gateway-can-ruin.md)
-   [
    
    ![AI Gateway Load Testing: What Breaks at Concurrency (and How to Fix It)](/blog/your-ai-gateway-is-fine-until-everyone-hits-it-at-once/hero.png)
    
    EngineeringJul 01, 2026
    
    #### AI Gateway Load Testing: What Breaks at Concurrency (and How to Fix It)
    
    ](/blog/your-ai-gateway-is-fine-until-everyone-hits-it-at-once.md)
-   [
    
    ![Claude Code Sandboxing: How Anthropic Contains Coding Agents (and How to Cover Your Fleet)](/blog/how-anthropic-contains-its-own-coding-agents-and-get-that-coverage-across-your-fleet/hero.svg)
    
    EngineeringJun 22, 2026
    
    #### Claude Code Sandboxing: How Anthropic Contains Coding Agents (and How to Cover Your Fleet)
    
    ](/blog/how-anthropic-contains-its-own-coding-agents-and-get-that-coverage-across-your-fleet.md)

## Start securing your agents today.

[Book a demo](/contact.md)

## Sitemap

Full site map: [/sitemap.md](https://www.highflame.com/sitemap.md). Machine index: [/sitemap-index.xml](https://www.highflame.com/sitemap-index.xml).
