MCP Went Stateless: What the 2026-07-28 Revision Took Out of Our Server

If you run an MCP server, you have a session somewhere. Probably a table, maybe a map in memory. A client connects, sends initialize, gets an Mcp-Session-Id back, and carries that header on every request afterwards. You store the row. You expire it after a while. You make sure a request lands on the instance that owns it, or you share the state between instances.

The 2026-07-28 revision deleted that. Not deprecated it: removed it from the protocol core. The handshake is gone, the session header is gone, and every request now carries its own protocol version and client identity. As the release post puts it, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage."

Archyl's MCP server now serves clients that speak the new revision. This post is what that took, what we measured afterwards, the one thing we got wrong on the first pass, and what we have not done. If you maintain an MCP server, the interesting parts are probably the design decision in the middle, the bug that auditing the new transport turned up in the old one, and the checklist at the end for going and looking at yours.

What the revision actually removed

Straight from the changelog, the parts that touch a server implementation:

  • Protocol-level sessions and the Mcp-Session-Id header are removed from the Streamable HTTP transport. List endpoints no longer vary per connection.
  • The initialize / notifications/initialized handshake is removed. Every request carries its protocol version and client capabilities in _meta, and on Streamable HTTP the same version travels in the MCP-Protocol-Version header.
  • server/discover is new and mandatory. Servers MUST implement it, to advertise supported protocol versions, capabilities and identity. Clients MAY call it before anything else; they are also free to send a request and handle a version error.
  • ping, logging/setLevel and notifications/roots/list_changed are removed.
  • Version mismatches return UnsupportedProtocolVersionError, listing the versions the server does support so the client can retry.

There is more in there (Multi Round-Trip Requests, subscriptions/listen, cacheable list results, a renumbered block of error codes, authorization hardening), and I will come back to which of those we did and which we skipped. The five above are what change the shape of a server rather than its features.

One point worth being precise about, because it changes the decision: this is not a release candidate any more. The release candidate locked on 21 May 2026 and opened a ten-week validation window for SDK maintainers and client implementers. That window closed on 28 July 2026 when the specification shipped, and the versioning page now calls 2026-07-28 "the current protocol version". All four Tier 1 SDKs (TypeScript, Python, Go, C#) speak it as of release day, with Rust in beta. If you were holding off until the RC settled, it has.

What that meant for a server with 181 tools

Archyl's MCP server exposes 181 tools over the C4 model: projects, systems, containers, components, relationships, ADRs, docs, contracts, conformance, drift, DORA, ownership. Before this change, all 181 sat behind a session.

Concretely, in our backend:

  • Every connection minted a row in a mcp_sessions table, with a 24-hour expiry and a background goroutine sweeping stale and expired rows.
  • SSE response channels lived in a map[string]chan *JSONRPCMessage on the server struct, keyed by session ID, which pinned a connection to the process that opened it. That map has since moved, and the reason turned out to be a bug rather than a preference. I come back to it below.
  • Four handlers (tools/list, tools/call, resources/list, resources/read) opened with the same three lines:
if !session.Initialized {
	return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}

That guard is the interesting one. It asks a question the new protocol has made unanswerable: did this caller complete the handshake? There is no handshake to complete.

The decision that kept the change small

The tempting move is to teach those four handlers about statelessness. Add a second condition, or a session.Stateless || in front of each check, or hoist the whole thing into middleware.

We did none of that. The guards are untouched. Instead, a request that declares 2026-07-28 gets an in-memory session built for that one request, which satisfies the guard by construction:

func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
	now := time.Now()
	return &Session{
		Session: &mcpsession.Session{
			ID:              "",
			UserID:          userID,
			OrganizationID:  organizationID,
			Initialized:     true,
			ProtocolVersion: protocolVersion,
			Transport:       "streamable",
			LastAccessedAt:  now,
			CreatedAt:       now,
		},
		Stateless: true,
	}
}

Nothing is persisted. No ID is allocated. No SSE channel is registered. Initialized: true is not a lie or a bypass: under this revision the request genuinely is initialized, because the protocol carries its own version and the handshake it would otherwise have completed no longer exists.

Why this framing matters more than it looks: those four guards sit on an authorization-adjacent path. Each one is the difference between a tool call running and being refused. Editing four call sites that all answer a security-shaped question is four chances to weaken a check, spread across a diff that a reviewer has to hold in their head at once. Building the object the guards already expect is one new function, and every existing check keeps its exact meaning.

It also fails in a safe direction. If our version detection is wrong and a stateless request is misread as a legacy one, the consequence is that a session row gets created for it. Nothing is let through that would not have been let through before. The reverse design, loosening the guards and gating them on a version string, fails the other way.

Statelessness did not cost us any tenancy either, because identity never came from the session row in the first place. The stateless session carries the user and organization resolved from the API key or OAuth token presented on that request, scopes are re-derived on every call so revoking a key takes effect immediately, and tools/call still refuses a session with no bound tenant. There is now one less thing to steal: no stored session ID that could be replayed. On the legacy path we kept the corresponding check, so a session ID cannot let one credential act as the identity stored on someone else's session.

The routing, in one switch

The whole decision lives in the HTTP handler, before the JSON-RPC body is even parsed:

switch {
case mcp.IsModernProtocolVersion(requestedVersion):
	// Stateless: the request describes itself, so nothing is looked up,
	// nothing is written, and no Mcp-Session-Id comes back.
	session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)

case sessionID != "":
	// Handshake-based client with a session: look it up, and check it
	// belongs to this credential.

default:
	// Legacy client that has not handshaken yet: mint a session as before.
}

Two details in there that are easy to miss:

IsModernProtocolVersion is a string comparison against "2026-07-28". Revisions are YYYY-MM-DD, so lexical order is chronological order, and a future revision lands on the stateless side by default rather than falling back to the handshake. It only gets that far if we support it: an unrecognized version is rejected before the switch, with the supported list in the error data so the client can retry.

And the response header:

if !session.Stateless {
	c.Set("Mcp-Session-Id", session.ID)
}

A stateless session has no ID. Echoing an empty Mcp-Session-Id would tell a client to reuse something that does not exist, which is a worse bug than not sending it, and the kind of thing that only shows up against a client you did not write.

The rest of the changelog, read properly

The version switch is the interesting decision. The rest of the revision is a list of small requirements that are easy to miss and cheap to check, so we went back through the changelog line by line. Four of them landed in that pass.

resultType on every result. The revision makes the field required: "complete" for a finished answer, "input_required" for the interim result in the multi round-trip pattern. Clients are told to treat its absence from an older server as "complete", but a client reading the final revision looks for it. Ours carry it now, from one Result struct embedded in each result type rather than each type remembering the field on its own.

ttlMs and cacheScope on list results. Required on tools/list, prompts/list, resources/list, resources/read and resources/templates/list, through a new CacheableResult interface. We host three of those five, and they return 60000 and private. Sixty seconds is a hint rather than a contract: long enough to stop an agent re-listing 181 tools on every turn, short enough that a tool registered mid-session shows up quickly. private is a decision, not a default we accepted. Every result we return is scoped to the caller's organization, so no shared intermediary may cache one and hand it to a different tenant.

DELETE /mcp from a client declaring 2026-07-28. DELETE terminated a protocol-level session, and there are no protocol-level sessions any more. The spec says answer 405, so that is what a modern client gets. A handshake-based client keeps the old behaviour.

An unimplemented method now returns HTTP 404 carrying JSON-RPC -32601. The status code on its own is ambiguous: a legacy HTTP+SSE server that does not host the modern endpoint at all also answers 404. The JSON-RPC body is what tells the two apart, and the spec is explicit that a client uses it to decide whether to fall back to initialize or retry.

And one we got wrong on the first pass

Our unsupported-version error returned -32600, the generic JSON-RPC "invalid request". That was defensible right up until this revision, which defines an error code allocation policy splitting the JSON-RPC server-error range: -32000 to -32019 stays implementation-defined, -32020 to -32099 belongs to the specification. The codes introduced during the draft were renumbered into that block. HeaderMismatch went -32001-32020, MissingRequiredClientCapability -32003-32021, and UnsupportedProtocolVersion -32004-32022.

A client written against the final revision looks for -32022. It would not have recognized what we were sending, and the failure mode is the one this whole revision is designed to avoid: the client cannot tell "wrong version, here are the ones I speak" from "your request was malformed", so it has nothing to retry with.

Nothing caught that except reading the changelog a second time, which is this post's own argument pointed back at us. The renumbering is item 12 of the minor changes, after the entries on OpenTelemetry _meta keys and JSON Schema keywords. It is the kind of line you skim.

One rename cost us nothing. Resource-not-found moved from -32002 to -32602, to line up with JSON-RPC's "invalid params", and resources/read was already answering -32602 for an unknown URI.

What we measured

All of this was measured against a running container on this build, with a real API key, so we could count rows in Postgres directly.

Test Result
tools/list with MCP-Protocol-Version: 2026-07-28, no handshake 181 tools
Mcp-Session-Id echoed on that response none
resultType on tools/list and on server/discover complete
ttlMs / cacheScope on tools/list 60000 / private
server/discover ["2026-07-28", "2025-03-26"]
Unsupported version declared -32022, supported list in the error data
DELETE /mcp from a client declaring 2026-07-28 405
Unknown method 404 carrying -32601
Legacy initialize handshake still works
Legacy tools/list with a session id 181 tools
mcp_sessions rows created by 10 stateless requests 0
mcp_sessions rows created by 3 legacy requests 3

The last pair is the one to look at. Ten requests, no rows. The three legacy requests each arrived without a session ID, so each minted one; a well-behaved handshake-based client that reuses its ID gets one row for the life of its session, not one per call. The point is the zero: on the stateless path there is nothing to write, nothing to expire, and nothing for the cleanup goroutine to find.

The request that produced the first row of that table, pointed at the public endpoint:

curl -s https://api.archyl.com/mcp \
  -H "X-API-Key: $ARCHYL_API_KEY" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

No initialize. No session. 181 tools.

The bug the deprecated transport was hiding

Auditing the new transport made us look at the old one, and the old one had a real bug.

The HTTP+SSE transport from 2024-11-05 splits a conversation across two connections. The client opens a long-lived stream with GET, the server's first event tells it where to POST, and from then on every message goes out on a POST while every response comes back on the stream. Those two connections do not have to land on the same instance.

Ours assumed they did. Response channels lived in that map[string]chan *JSONRPCMessage on the server struct, so a POST handled by instance B wrote its response into a channel that existed on instance B and that nobody on instance B was reading. The stream was on instance A. The client waited.

What makes that worse than a design smell is that nothing was logged. No error, no warning, no failed request. The POST returned 202 Accepted, which was true, the message had been accepted, and the answer went nowhere. From outside it is indistinguishable from a slow tool call. It only happens on a horizontally scaled deployment, which is exactly where you have the least appetite for reproducing something by hand.

The map is now a Redis pub/sub router in streamrouter.go. A response for a stream this process is holding is delivered directly and never makes the round trip. A response for a stream held elsewhere is published to mcp:stream:<sessionID>, and the instance holding that stream is subscribed to it. Any instance can take the POST. No session affinity is required, and no sticky-session rule has to be maintained in a load balancer config that nobody remembers writing.

Two things are worth saying about that, because a router is a dependency.

Redis is now on this path. If it is unreachable at startup the router falls back to local-only delivery and logs a warning rather than refusing to boot, because local-only is correct for a single instance and only wrong once there is a second one. The failure is loud on purpose: the alternative is the silent hang we just removed. If you deploy this, the startup line to look for is MCP stream router: Redis connected. Its absence is the whole story.

And the router fixes routing, not location. The stream is still a connection held by one process; Redis carries responses to that process, it does not move the stream. That part is irreducible. An open connection lives where it was opened, in any protocol.

What we have not done

This is where an announcement usually stops. Two things are worth saying plainly, because you can check both.

Archyl speaks 2026-07-28 on the path that matters. It is not stateless end to end.

The stateless path genuinely is stateless: no session lookup, no session write, no Mcp-Session-Id, nothing pinning a request to a process. That path can sit behind a plain round-robin load balancer.

Our server also still answers the older HTTP+SSE transport at /sse, but we have stopped documenting it. Every page that used to print that URL now prints /mcp, and that is the only endpoint we ask anyone to configure.

The reason is the dependency we just added. The router removes the affinity requirement only where Redis is reachable. Where it is not, delivery falls back to local-only, which is correct on one instance and silently wrong on two. Our own production does not run Redis today, so the fallback is what we are running. We would rather point everyone at the transport whose correctness does not depend on an instance count than publish one whose does.

What remains true of /sse wherever it runs: the stream is a connection held by a single process, and a session row exists in Postgres for the life of it. Removing the affinity requirement is not the same as removing the state. We are not announcing a date to retire that transport.

The clock on that transport is not ours, though, and it is shorter than we assumed. HTTP+SSE has been deprecated since the 2025-03-26 revision; what 2026-07-28 did was reclassify it as Deprecated under the new feature lifecycle policy. That policy sets a minimum twelve-month window between deprecation and eligibility for removal, which is what Roots, Sampling and Logging get: earliest removal in "the first revision released on or after 2027-07-28". HTTP+SSE does not get twelve months, because it was already deprecated long before the policy existed. The deprecated features registry lists its earliest removal as "Three months after SEP-2596 reaches Final". Removal is still a Core Maintainer decision taken at release preparation and may happen later, but if you are running HTTP+SSE anywhere, that is the row to read.

We implemented the shape of the revision, not all of it. What ships is version negotiation, the stateless request path, server/discover, the unsupported-version error with the right code, resultType, the cache hints, and the 405 and 404 the transport asks for, alongside the handshake path for clients that still need it. Here is what is not there:

  • The Mcp-Method and Mcp-Name request headers, and the validation that comes with them. This is the largest gap. The revision requires a POST to mirror its method, and its params.name or params.uri, into headers, and requires the server to reject any mismatch with 400 and -32020 HeaderMismatch. The reason is not tidiness. In the spec's own words, it "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)". The same rule covers MCP-Protocol-Version, whose value MUST match the one in the request's _meta. We read the version from the header only and never look at _meta, so we cannot detect a mismatch we are required to reject. The header is available before the body is parsed, which is why we read it there. That is not a reason to skip the cross-check.
  • subscriptions/listen, and Multi Round-Trip Requests with InputRequiredResult. Whole features rather than fixes. We never implemented resources/subscribe, so the method that replaces it costs us nothing today.
  • Origin header validation. The spec marks it a MUST, with 403 on an invalid origin, as the defence against DNS rebinding. We do not do it on /mcp.
  • extensions on capabilities, and deterministic ordering from tools/list. The second is a SHOULD, aimed at client-side caching and LLM prompt-cache hit rates. Ours come out of a Go map, so the order is whatever that map gives us on the day.
  • Dynamic Client Registration. This revision deprecates it in favour of Client ID Metadata Documents, and we still expose POST /register. It stays available for authorization servers that do not support the replacement, so this is a migration rather than a break, on the same twelve-month clock as Roots, Sampling and Logging.
  • server/discover sits behind the same API key as everything else on /mcp. It will not answer an anonymous caller, which is a deliberate choice and not what a client discovering a server expects.

The rest is work, and it is on the list rather than done.

If you run your own MCP server

The checks worth running against yours:

  1. Send tools/list with MCP-Protocol-Version: 2026-07-28 and no handshake. If you get "session not initialized", your server is not serving the current revision.
  2. Call server/discover. It is mandatory now. If it returns method-not-found, that is the smallest gap to close.
  3. Declare a version you do not support. Check that the error carries the list of versions you do, and that its code is -32022 rather than a generic one. This is the check we failed.
  4. Read any result. Every one of them needs resultType, and your list results need ttlMs and cacheScope on top of it.
  5. Look at what you echo in Mcp-Session-Id on a stateless request. Empty is worse than absent.
  6. Count your writes. Send ten stateless requests and check whether anything landed in your session store. That number is the honest answer to whether the migration worked.
  7. If you still serve HTTP+SSE and run more than one instance, POST to one while the stream is held by another. A client that hangs with nothing in the logs is the bug we had. Then read the deprecation registry row above.

The gap between "accepts the new version header" and "actually stateless" is where most of the work is, and only step 6 tells you which side you are on.

Connect it

The endpoint is unchanged, and both revisions work against it. For Claude Code, a .mcp.json in your project root:

{
  "mcpServers": {
    "archyl": {
      "type": "http",
      "url": "https://api.archyl.com/mcp",
      "headers": {
        "X-API-Key": "YOUR_API_KEY"
      }
    }
  }
}

Your client picks the revision. If it speaks 2026-07-28, it gets served without a handshake and without a session. If it does not, nothing changes for it.

Full setup for Claude Code, Cursor, VS Code, Codex, Warp, Windsurf and Antigravity, plus the scopes that decide what an agent can change, is in the MCP server documentation.